diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs index 9e493b00..1f0c0e11 100644 --- a/utoipa-gen/src/lib.rs +++ b/utoipa-gen/src/lib.rs @@ -36,18 +36,12 @@ mod security_requirement; use crate::path::{Path, PathAttr}; -#[cfg(any( - feature = "actix_extras", - feature = "rocket_extras", - feature = "axum_extras" -))] -use ext::ArgumentResolver; - -use self::path::response::DeriveResponse; +use self::path::response::{derive, DeriveResponse}; #[proc_macro_error] #[proc_macro_derive(ToSchema, attributes(schema, aliases))] -/// ToSchema derive macro. +/// Generate reusable OpenAPI schema to be used +/// together with [`OpenApi`][openapi_derive]. /// /// This is `#[derive]` implementation for [`ToSchema`][to_schema] trait. The macro accepts one /// `schema` @@ -62,6 +56,18 @@ use self::path::response::DeriveResponse; /// OpenAPI. OpenAPI has only a boolean flag to determine deprecation. While it is totally okay to declare deprecated with reason /// `#[deprecated = "There is better way to do this"]` the reason would not render in OpenAPI spec. /// +/// Doc comments on fields will resolve to field descriptions in generated OpenAPI doc. On struct +/// level doc comments will resolve to object descriptions. +/// +/// ```rust +/// /// This is a pet +/// #[derive(utoipa::ToSchema)] +/// struct Pet { +/// /// Name for your pet +/// name: String, +/// } +/// ``` +/// /// # Struct Optional Configuration Options for `#[schema(...)]` /// * `example = ...` Can be _`json!(...)`_. _`json!(...)`_ should be something that /// _`serde_json::json!`_ can parse as a _`serde_json::Value`_. @@ -101,7 +107,7 @@ use self::path::response::DeriveResponse; /// This is useful in cases where the default type does not correspond to the actual type e.g. when /// any third-party types are used which are not [`ToSchema`][to_schema]s nor [`primitive` types][primitive]. /// Value can be any Rust type what normally could be used to serialize to JSON or custom type such as _`Object`_. -/// _`Object`_ will be rendered as generic OpenAPI object. +/// _`Object`_ will be rendered as generic OpenAPI object _(`type: object`)_. /// * `title = ...` Literal string value. Can be used to define title for struct in OpenAPI /// document. Some OpenAPI code generation libraries also use this field as a name for the /// struct. @@ -119,7 +125,7 @@ use self::path::response::DeriveResponse; /// This is useful in cases where the default type does not correspond to the actual type e.g. when /// any third-party types are used which are not [`ToSchema`][to_schema]s nor [`primitive` types][primitive]. /// Value can be any Rust type what normally could be used to serialize to JSON or custom type such as _`Object`_. -/// _`Object`_ will be rendered as generic OpenAPI object. +/// _`Object`_ will be rendered as generic OpenAPI object _(`type: object`)_. /// * `inline` If the type of this field implements [`ToSchema`][to_schema], then the schema definition /// will be inlined. **warning:** Don't use this for recursive data types! /// * `nullable` Defines property is nullable (note this is different to non-required). @@ -308,14 +314,18 @@ use self::path::response::DeriveResponse; /// /// # Examples /// -/// _**Example struct with struct level example.**_ +/// _**Simple example of a Pet with descriptions and object level example.**_ /// ```rust /// # use utoipa::ToSchema; +/// /// This is a pet. /// #[derive(ToSchema)] /// #[schema(example = json!({"name": "bob the cat", "id": 0}))] /// struct Pet { +/// /// Unique id of a pet. /// id: u64, +/// /// Name of a pet. /// name: String, +/// /// Age of a pet if known. /// age: Option, /// } /// ``` @@ -452,7 +462,7 @@ use self::path::response::DeriveResponse; /// }; /// ``` /// -/// _**Use a virtual `Object` type to render generic `object` in OpenAPI spec.**_ +/// _**Use a virtual `Object` type to render generic `object` _(`type: object`)_ in OpenAPI spec.**_ /// ```rust /// # use utoipa::ToSchema; /// # mod custom { @@ -483,7 +493,7 @@ use self::path::response::DeriveResponse; /// } /// ``` /// -/// Add `title` to the enum. +/// _**Add `title` to the enum.**_ /// ```rust /// #[derive(utoipa::ToSchema)] /// #[schema(title = "UserType")] @@ -494,7 +504,7 @@ use self::path::response::DeriveResponse; /// } /// ``` /// -/// Example with validation attributes. +/// _**Example with validation attributes.**_ /// ```rust /// #[derive(utoipa::ToSchema)] /// struct Item { @@ -507,7 +517,7 @@ use self::path::response::DeriveResponse; /// } /// ```` /// -/// _**Use `schema_with` to manually implement schema for a field**_ +/// _**Use `schema_with` to manually implement schema for a field.**_ /// ```rust /// # use utoipa::openapi::schema::{Object, ObjectBuilder}; /// fn custom_type() -> Object { @@ -538,6 +548,7 @@ use self::path::response::DeriveResponse; /// [serde attributes]: https://serde.rs/attributes.html /// [discriminator]: openapi/schema/struct.Discriminator.html /// [enum_schema]: derive.ToSchema.html#enum-optional-configuration-options-for-schema +/// [openapi_derive]: derive.OpenApi.html pub fn derive_to_schema(input: TokenStream) -> TokenStream { let DeriveInput { attrs, @@ -554,7 +565,7 @@ pub fn derive_to_schema(input: TokenStream) -> TokenStream { #[proc_macro_error] #[proc_macro_attribute] -/// Path attribute macro. +/// Path attribute macro implements OpenAPI path for the decorated function. /// /// This is a `#[derive]` implementation for [`Path`][path] trait. Macro accepts set of attributes that can /// be used to configure and override default values what are resolved automatically. @@ -566,6 +577,17 @@ pub fn derive_to_schema(input: TokenStream) -> TokenStream { /// OpenAPI. OpenAPI has only a boolean flag to determine deprecation. While it is totally okay to declare deprecated with reason /// `#[deprecated = "There is better way to do this"]` the reason would not render in OpenAPI spec. /// +/// Doc comment at decorated function will be used for _`description`_ and _`summary`_ of the path. +/// First line of the doc comment will be used as the _`summary`_ and the whole doc comment will be +/// used as _`description`_. +/// ```rust +/// /// This is a summary of the operation +/// /// +/// /// All lines of the doc comment will be included to operation description. +/// #[utoipa::path(get, path = "/operation")] +/// fn operation() {} +/// ``` +/// /// # Path Attributes /// /// * `operation` _**Must be first parameter!**_ Accepted values are known http operations such as @@ -719,7 +741,7 @@ pub fn derive_to_schema(input: TokenStream) -> TokenStream { /// ) /// ``` /// -/// **Use `ToReponse` trait to define response attributes instead of tuple:** +/// **Use `ToResponse` trait to define response attributes instead of tuple:** /// /// `ReusableResponse` must be a type that implements [`ToResponse`][to_response_trait]. /// @@ -1196,6 +1218,7 @@ pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream { feature = "axum_extras" ))] { + use ext::ArgumentResolver; let args = resolved_path.as_mut().map(|path| mem::take(&mut path.args)); let (arguments, into_params_types) = PathOperations::resolve_arguments(&ast_fn.sig.inputs, args); @@ -1226,7 +1249,8 @@ pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream { #[proc_macro_error] #[proc_macro_derive(OpenApi, attributes(openapi))] -/// OpenApi derive macro. +/// Generate OpenApi base object with defaults from +/// project settings. /// /// This is `#[derive]` implementation for [`OpenApi`][openapi] trait. The macro accepts one `openapi` argument. /// @@ -1431,7 +1455,8 @@ pub fn openapi(input: TokenStream) -> TokenStream { #[proc_macro_error] #[proc_macro_derive(IntoParams, attributes(param, into_params))] -/// IntoParams derive macro. +/// Generate [path parameters][path_params] from struct's +/// fields. /// /// This is `#[derive]` implementation for [`IntoParams`][into_params] trait. /// @@ -1448,6 +1473,15 @@ pub fn openapi(input: TokenStream) -> TokenStream { /// While it is totally okay to declare deprecated with reason /// `#[deprecated = "There is better way to do this"]` the reason would not render in OpenAPI spec. /// +/// Doc comment on struct fields will be used as description for the generated parameters. +/// ```rust +/// #[derive(utoipa::IntoParams)] +/// struct Query { +/// /// Query todo items by name. +/// name: String +/// } +/// ``` +/// /// # IntoParams Container Attributes for `#[into_params(...)]` /// /// The following attributes are available for use in on the container attribute `#[into_params(...)]` for the struct @@ -1691,7 +1725,7 @@ pub fn openapi(input: TokenStream) -> TokenStream { /// } /// ```` /// -/// _**Use `schema_with` to manually implement schema for a field**_ +/// _**Use `schema_with` to manually implement schema for a field.**_ /// ```rust /// # use utoipa::openapi::schema::{Object, ObjectBuilder}; /// fn custom_type() -> Object { @@ -1743,7 +1777,8 @@ pub fn into_params(input: TokenStream) -> TokenStream { #[proc_macro_error] #[proc_macro_derive(ToResponse, attributes(response, content, to_schema))] -/// Derive response macro. +/// Generate resuable OpenAPI response what can be used +/// in [`utoipa::path`][path] or in [`OpenApi`][openapi]. /// /// This is `#[derive]` implementation for [`ToResponse`][to_response] trait. /// @@ -1757,7 +1792,7 @@ pub fn into_params(input: TokenStream) -> TokenStream { /// enum variants with `#[content]` attribute. **Note!** [`ToSchema`] need to be implemented for /// the field or variant type. /// -/// Type derived with _`ToResponse`_ uses doc comment provided as a description for the reponse. It +/// Type derived with _`ToResponse`_ uses provided doc comment as a description for the reponse. It /// can alternatively be overridden with _`description = ...`_ attribute. /// /// _`ToResponse`_ can be used in four different ways to generate OpenAPI response component. @@ -1795,7 +1830,7 @@ pub fn into_params(input: TokenStream) -> TokenStream { /// response without body. /// /// ```rust -/// //// Success response which does not have body. +/// /// Success response which does not have body. /// #[derive(utoipa::ToResponse)] /// struct SuccessResponse; /// ``` @@ -1913,6 +1948,8 @@ pub fn into_params(input: TokenStream) -> TokenStream { /// /// [to_response]: trait.ToResponse.html /// [primitive]: https://doc.rust-lang.org/std/primitive/index.html +/// [path]: attr.path.html +/// [openapi]: derive.OpenApi.html pub fn to_response(input: TokenStream) -> TokenStream { let DeriveInput { attrs, @@ -1932,6 +1969,168 @@ pub fn to_response(input: TokenStream) -> TokenStream { response.to_token_stream().into() } +#[proc_macro_error] +#[proc_macro_derive( + IntoResponses, + attributes(response, to_schema, ref_response, to_response) +)] +/// Generate responses with status codes what +/// can be attached to the [`utoipa::path`][path_into_responses]. +/// +/// This is `#[derive]` implementation of [`IntoResponses`][into_responses] trait. [`IntoResponses`] +/// can be used to decorate _`structs`_ and _`enums`_ to generate response maps that can be used in +/// [`utoipa::path`][path_into_responses]. If _`struct`_ is decorated with [`IntoResponses`] it will be +/// used to create a map of responses containing single response. Decorating _`enum`_ with +/// [`IntoResponses`] will create a map of responses with a response for each variant of the _`enum`_. +/// +/// Named field _`struct`_ decorated with [`IntoResponses`] will create a response with inlined schema +/// generated from the body of the struct. This is a conveniency which allows users to directly +/// create responses with schemas without first creating a separate [response][to_response] type. +/// +/// Unit _`struct`_ behaves similarly to then named field struct. Only difference is that it will create +/// a response without content since there is no inner fields. +/// +/// Unnamed field _`struct`_ decorated with [`IntoResponses`] will by default create a response with +/// referenced [schema][to_schema] if field is object or schema if type is [primitive +/// type][primitive]. _`#[to_schema]`_ attribute at field of unnamed _`struct`_ can be used to inline +/// the schema if type of the field implements [`ToSchema`][to_schema] trait. Alternatively +/// _`#[to_response]`_ and _`#[ref_response]`_ can be used at field to either reference a reusable +/// [response][to_response] or inline a reusable [response][to_response]. In both cases the field +/// type is expected to implement [`ToResponse`][to_response] trait. +/// +/// +/// Enum decorated with [`IntoResponses`] will create a response for each variant of the _`enum`_. +/// Each variant must have it's own _`#[response(...)]`_ definition. Unit variant will behave same +/// as unit _`struct`_ by creating a response without content. Similarly named field variant and +/// unnamed field variant behaves the same as it was named field _`struct`_ and unnamed field +/// _`struct`_. +/// +/// _`#[response]`_ attribute can be used at named structs, unnamed structs, unit structs and enum +/// variants to alter [response attributes](#intoresponses-response-attributes) of responses. +/// +/// Doc comment on a _`struct`_ or _`enum`_ variant will be used as a description for the response. +/// It can also be overridden with _`description = "..."`_ attribute. +/// +/// # IntoResponses `#[response(...)]` attributes +/// +/// * `status = ...` Must be provided. Is either a valid http status code integer. E.g. _`200`_ or a +/// string value representing a range such as _`"4XX"`_ or `"default"` or a valid _`http::status::StatusCode`_. +/// _`StatusCode`_ can either be use path to the status code or _status code_ constant directly. +/// +/// * `description = "..."` Define description for the response as str. This can be used to +/// override the default description resolved from doc comments if present. +/// +/// * `content_type = "..." | content_type = [...]` Can be used to override the default behavior of auto resolving the content type +/// from the `body` attribute. If defined the value should be valid content type such as +/// _`application/json`_. By default the content type is _`text/plain`_ for +/// [primitive Rust types][primitive], `application/octet-stream` for _`[u8]`_ and +/// _`application/json`_ for struct and complex enum types. +/// Content type can also be slice of **content_type** values if the endpoint support returning multiple +/// response content types. E.g _`["application/json", "text/xml"]`_ would indicate that endpoint can return both +/// _`json`_ and _`xml`_ formats. **The order** of the content types define the default example show first in +/// the Swagger UI. Swagger UI wil use the first _`content_type`_ value as a default example. +/// +/// * `headers(...)` Slice of response headers that are returned back to a caller. +/// +/// * `example = ...` Can be _`json!(...)`_. _`json!(...)`_ should be something that +/// _`serde_json::json!`_ can parse as a _`serde_json::Value`_. +/// +/// * `examples(...)` Define mulitple examples for single response. This attribute is mutually +/// exclusive to the _`example`_ attribute and if both are defined this will override the _`example`_. +/// * `name = ...` This is first attribute and value must be literal string. +/// * `summary = ...` Short description of example. Value must be literal string. +/// * `description = ...` Long description of example. Attribute supports markdown for rich text +/// representation. Value must be literal string. +/// * `value = ...` Example value. It must be _`json!(...)`_. _`json!(...)`_ should be something that +/// _`serde_json::json!`_ can parse as a _`serde_json::Value`_. +/// * `external_value = ...` Define URI to literal example value. This is mutually exclusive to +/// the _`value`_ attribute. Value must be literal string. +/// +/// _**Example of example definition.**_ +/// ```text +/// ("John" = (summary = "This is John", value = json!({"name": "John"}))) +/// ``` +/// +/// # Examples +/// +/// _**Named struct response with inlined schema.**_ +/// ```rust +/// /// This is success response +/// #[derive(utoipa::IntoResponses)] +/// #[response(status = 200)] +/// struct SuccessResponse { +/// value: String, +/// } +/// ``` +/// +/// _**Unit struct response without content.**_ +/// ```rust +/// #[derive(utoipa::IntoResponses)] +/// #[response(status = NOT_FOUND)] +/// struct NotFound; +/// ``` +/// +/// _**Unnamed struct response with inlined response schema.**_ +/// ```rust +/// # #[derive(utoipa::ToSchema)] +/// # struct Foo; +/// #[derive(utoipa::IntoResponses)] +/// #[response(status = 201)] +/// struct CreatedResponse(#[to_schema] Foo); +/// ``` +/// +/// _**Enum with multiple responses.**_ +/// ```rust +/// # #[derive(utoipa::ToResponse)] +/// # struct Response { +/// # message: String, +/// # } +/// # #[derive(utoipa::ToSchema)] +/// # struct BadRequest {} +/// #[derive(utoipa::IntoResponses)] +/// enum UserResponses { +/// /// Success response description. +/// #[response(status = 200)] +/// Success { value: String }, +/// +/// #[response(status = 404)] +/// NotFound, +/// +/// #[response(status = 400)] +/// BadRequest(BadRequest), +/// +/// #[response(status = 500)] +/// ServerError(#[ref_response] Response), +/// +/// #[response(status = 418)] +/// TeaPot(#[to_response] Response), +/// } +/// ``` +/// +/// [into_responses]: trait.IntoResponses.html +/// [to_schema]: trait.ToSchema.html +/// [to_response]: trait.ToResponse.html +/// [path_into_responses]: attr.path.html#responses-from-intoresponses +/// [primitive]: https://doc.rust-lang.org/std/primitive/index.html +pub fn into_responses(input: TokenStream) -> TokenStream { + let DeriveInput { + attrs, + ident, + generics, + data, + .. + } = syn::parse_macro_input!(input); + + let into_responses = derive::IntoResponses { + attributes: attrs, + ident, + generics, + data, + }; + + into_responses.to_token_stream().into() +} + /// Tokenizes slice or Vec of tokenizable items as array either with reference (`&[...]`) /// or without correctly to OpenAPI JSON. #[cfg_attr(feature = "debug", derive(Debug))] diff --git a/utoipa-gen/src/path.rs b/utoipa-gen/src/path.rs index 749f1ee4..22399a50 100644 --- a/utoipa-gen/src/path.rs +++ b/utoipa-gen/src/path.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::ops::Deref; use std::{io::Error, str::FromStr}; @@ -73,8 +74,8 @@ pub(crate) const PATH_STRUCT_PREFIX: &str = "__path_"; #[cfg_attr(feature = "debug", derive(Debug))] pub struct PathAttr<'p> { path_operation: Option, - request_body: Option, - responses: Vec, + request_body: Option>, + responses: Vec>, pub(super) path: Option, operation_id: Option, tag: Option, @@ -473,8 +474,8 @@ struct Operation<'a> { description: Option<&'a Vec>, deprecated: &'a Option, parameters: &'a Vec>, - request_body: Option<&'a RequestBodyAttr>, - responses: &'a Vec, + request_body: Option<&'a RequestBodyAttr<'a>>, + responses: &'a Vec>, security: Option<&'a Array<'a, SecurityRequirementAttr>>, } @@ -534,13 +535,13 @@ impl ToTokens for Operation<'_> { /// Represents either `ref("...")` or `Type` that can be optionally inlined with `inline(Type)`. #[cfg_attr(feature = "debug", derive(Debug))] -enum PathType { +enum PathType<'p> { Ref(String), - Type(InlineType), + MediaType(InlineType<'p>), InlineSchema(TokenStream2, Type), } -impl Parse for PathType { +impl Parse for PathType<'_> { fn parse(input: syn::parse::ParseStream) -> syn::Result { let fork = input.fork(); let is_ref = if (fork.parse::>()?).is_some() { @@ -555,26 +556,26 @@ impl Parse for PathType { parenthesized!(ref_stream in input); Ok(Self::Ref(ref_stream.parse::()?.value())) } else { - Ok(Self::Type(input.parse()?)) + Ok(Self::MediaType(input.parse()?)) } } } // inline(syn::Type) | syn::Type #[cfg_attr(feature = "debug", derive(Debug))] -struct InlineType { - ty: Type, +struct InlineType<'i> { + ty: Cow<'i, Type>, is_inline: bool, } -impl InlineType { +impl InlineType<'_> { /// Get's the underlying [`syn::Type`] as [`TypeTree`]. fn as_type_tree(&self) -> TypeTree { TypeTree::from_type(&self.ty) } } -impl Parse for InlineType { +impl Parse for InlineType<'_> { fn parse(input: syn::parse::ParseStream) -> syn::Result { let fork = input.fork(); let is_inline = if let Some(ident) = fork.parse::>()? { @@ -593,7 +594,10 @@ impl Parse for InlineType { input.parse::()? }; - Ok(InlineType { ty, is_inline }) + Ok(InlineType { + ty: Cow::Owned(ty), + is_inline, + }) } } diff --git a/utoipa-gen/src/path/parameter.rs b/utoipa-gen/src/path/parameter.rs index 538c8145..6dda1844 100644 --- a/utoipa-gen/src/path/parameter.rs +++ b/utoipa-gen/src/path/parameter.rs @@ -104,7 +104,7 @@ pub struct ValueParameter<'a> { parameter_ext: Option, /// Type only when value parameter is parsed - parsed_type: Option, + parsed_type: Option>, } impl<'p> ValueParameter<'p> { diff --git a/utoipa-gen/src/path/request_body.rs b/utoipa-gen/src/path/request_body.rs index ce0f0385..791155cd 100644 --- a/utoipa-gen/src/path/request_body.rs +++ b/utoipa-gen/src/path/request_body.rs @@ -50,15 +50,15 @@ use super::{PathType, PathTypeTree}; /// ``` #[derive(Default)] #[cfg_attr(feature = "debug", derive(Debug))] -pub struct RequestBodyAttr { - content: Option, +pub struct RequestBodyAttr<'r> { + content: Option>, content_type: Option, description: Option, example: Option, examples: Option>, } -impl Parse for RequestBodyAttr { +impl Parse for RequestBodyAttr<'_> { fn parse(input: syn::parse::ParseStream) -> syn::Result { const EXPECTED_ATTRIBUTE_MESSAGE: &str = "unexpected attribute, expected any of: content, content_type, description, examples"; @@ -133,14 +133,14 @@ impl Parse for RequestBodyAttr { } } -impl ToTokens for RequestBodyAttr { +impl ToTokens for RequestBodyAttr<'_> { fn to_tokens(&self, tokens: &mut TokenStream2) { if let Some(body_type) = &self.content { let media_type_schema = match body_type { PathType::Ref(ref_type) => quote! { utoipa::openapi::schema::Ref::new(#ref_type) }, - PathType::Type(body_type) => { + PathType::MediaType(body_type) => { let type_tree = body_type.as_type_tree(); MediaTypeSchema { type_tree: &type_tree, @@ -180,7 +180,7 @@ impl ToTokens for RequestBodyAttr { .content("application/json", #content.build()) }); } - PathType::Type(body_type) => { + PathType::MediaType(body_type) => { let type_tree = body_type.as_type_tree(); let content_type = self .content_type diff --git a/utoipa-gen/src/path/response.rs b/utoipa-gen/src/path/response.rs index 6c1f6cc9..65015041 100644 --- a/utoipa-gen/src/path/response.rs +++ b/utoipa-gen/src/path/response.rs @@ -27,6 +27,8 @@ use super::{ PathTypeTree, }; +pub mod derive; + enum DeriveResponseType<'r> { Unnamed(Type, &'r [Attribute]), Named(Type, &'r Punctuated), @@ -56,10 +58,10 @@ impl DeriveResponse { Fields::Unnamed(unnamed) => { if unnamed.unnamed.len() <= 1 { unnamed - .unnamed - .iter() - .next() - .map(|field| DeriveResponseType::Unnamed(field.ty.clone(), field.attrs.as_slice())).unwrap_or_else(|| abort!(unnamed.span(), "Unnamed struct used for `ToResponse` must have one argument")) + .unnamed + .iter() + .next() + .map(|field| DeriveResponseType::Unnamed(field.ty.clone(), field.attrs.as_slice())).unwrap_or_else(|| abort!(unnamed.span(), "Unnamed struct used for `ToResponse` must have one argument")) } else { abort!( unnamed.span(), @@ -74,26 +76,26 @@ impl DeriveResponse { } } - fn parse_derive_response_value(&self, attributes: &[Attribute]) -> Option { + fn parse_derive_response_value( + &self, + attributes: &[Attribute], + ) -> Option { attributes .iter() .filter(|attribute| attribute.path.get_ident().unwrap() == "response") .map(|attribute| { attribute - .parse_args::() + .parse_args::() .unwrap_or_abort() }) - .reduce(|mut acc, item| { - acc.merge_from(item); - acc - }) + .reduce(|acc, item| acc.merge_from(item)) } - fn create_response( - &self, + fn create_response<'r>( + &'r self, description: String, - ty: Option, - content: Punctuated, + ty: Option>, + content: Punctuated, Comma>, ) -> ResponseTuple { let response_value = self.parse_derive_response_value(self.attributes.as_slice()); if let Some(response_value) = response_value { @@ -159,7 +161,10 @@ impl ToTokens for DeriveResponse { .any(|attribute| attribute.path.get_ident().unwrap() == "to_schema"); self.create_response( description, - Some(PathType::Type(InlineType { ty, is_inline })), + Some(PathType::MediaType(InlineType { + ty: Cow::Owned(ty), + is_inline, + })), Punctuated::new(), ) } @@ -230,7 +235,10 @@ impl ToTokens for DeriveResponse { field.map(|(ty, content_type)| { Content( content_type, - PathType::Type(InlineType { ty, is_inline }), + PathType::MediaType(InlineType { + ty: Cow::Owned(ty), + is_inline, + }), example.map(|(example, _)| example), examples.map(|(examples, _)| examples), ) @@ -276,14 +284,14 @@ impl ToTokens for DeriveResponse { } #[cfg_attr(feature = "debug", derive(Debug))] -pub enum Response { +pub enum Response<'r> { /// A type that implements `utoipa::IntoResponses`. IntoResponses(ExprPath), /// The tuple definition of a response. - Tuple(ResponseTuple), + Tuple(ResponseTuple<'r>), } -impl Parse for Response { +impl Parse for Response<'_> { fn parse(input: ParseStream) -> syn::Result { if input.fork().parse::().is_ok() { Ok(Self::IntoResponses(input.parse()?)) @@ -298,17 +306,17 @@ impl Parse for Response { /// Parsed representation of response attributes from `#[utoipa::path]` attribute. #[derive(Default)] #[cfg_attr(feature = "debug", derive(Debug))] -pub struct ResponseTuple { +pub struct ResponseTuple<'r> { status_code: ResponseStatus, - inner: Option, + inner: Option>, } const RESPONSE_INCOMPATIBLE_ATTRIBUTES_MSG: &str = "The `response` attribute may only be used in conjunction with the `status` attribute"; -impl ResponseTuple { +impl<'r> ResponseTuple<'r> { // This will error if the `response` attribute has already been set - fn as_value(&mut self, span: Span) -> syn::Result<&mut ResponseValue> { + fn as_value(&mut self, span: Span) -> syn::Result<&mut ResponseValue<'r>> { if self.inner.is_none() { self.inner = Some(ResponseTupleInner::Value(ResponseValue::default())); } @@ -320,7 +328,7 @@ impl ResponseTuple { } // Use with the `response` attribute, this will fail if an incompatible attribute has already been set - fn set_ref_type(&mut self, span: Span, ty: InlineType) -> syn::Result<()> { + fn set_ref_type(&mut self, span: Span, ty: InlineType<'r>) -> syn::Result<()> { match &mut self.inner { None => self.inner = Some(ResponseTupleInner::Ref(ty)), Some(ResponseTupleInner::Ref(r)) => *r = ty, @@ -333,12 +341,12 @@ impl ResponseTuple { } #[cfg_attr(feature = "debug", derive(Debug))] -enum ResponseTupleInner { - Value(ResponseValue), - Ref(InlineType), +enum ResponseTupleInner<'r> { + Value(ResponseValue<'r>), + Ref(InlineType<'r>), } -impl Parse for ResponseTuple { +impl Parse for ResponseTuple<'_> { fn parse(input: ParseStream) -> syn::Result { const EXPECTED_ATTRIBUTE_MESSAGE: &str = "unexpected attribute, expected any of: status, description, body, content_type, headers, example, examples, response"; @@ -406,17 +414,17 @@ impl Parse for ResponseTuple { #[derive(Default)] #[cfg_attr(feature = "debug", derive(Debug))] -pub struct ResponseValue { +pub struct ResponseValue<'r> { description: String, - response_type: Option, + response_type: Option>, content_type: Option>, headers: Vec
, example: Option, examples: Option>, - content: Punctuated, + content: Punctuated, Comma>, } -impl ToTokens for ResponseTuple { +impl ToTokens for ResponseTuple<'_> { fn to_tokens(&self, tokens: &mut TokenStream2) { match self.inner.as_ref().unwrap() { ResponseTupleInner::Ref(res) => { @@ -446,7 +454,7 @@ impl ToTokens for ResponseTuple { utoipa::openapi::schema::Ref::new(#ref_type) } .to_token_stream(), - PathType::Type(ref path_type) => { + PathType::MediaType(ref path_type) => { let type_tree = path_type.as_type_tree(); MediaTypeSchema { type_tree: &type_tree, @@ -499,7 +507,7 @@ impl ToTokens for ResponseTuple { .content("application/json", #content) }); } - PathType::Type(path_type) => { + PathType::MediaType(path_type) => { let type_tree = path_type.as_type_tree(); let default_type = type_tree.get_default_content_type(); tokens.extend(quote! { @@ -540,9 +548,21 @@ impl ToTokens for ResponseTuple { } } +trait DeriveResponseValue: Parse { + fn merge_from(self, other: Self) -> Self; + + fn from_attributes(attributes: &[Attribute]) -> Option { + attributes + .iter() + .filter(|attribute| attribute.path.get_ident().unwrap() == "response") + .map(|attribute| attribute.parse_args::().unwrap_or_abort()) + .reduce(|acc, item| acc.merge_from(item)) + } +} + #[derive(Default)] #[cfg_attr(feature = "debug", derive(Debug))] -struct DeriveResponseValue { +struct DeriveToResponseValue { content_type: Option>, headers: Vec
, description: String, @@ -550,29 +570,31 @@ struct DeriveResponseValue { examples: Option<(Punctuated, Ident)>, } -impl DeriveResponseValue { - fn merge_from(&mut self, value: DeriveResponseValue) { - if value.content_type.is_some() { - self.content_type = value.content_type; +impl DeriveResponseValue for DeriveToResponseValue { + fn merge_from(mut self, other: Self) -> Self { + if other.content_type.is_some() { + self.content_type = other.content_type; } - if !value.headers.is_empty() { - self.headers = value.headers; + if !other.headers.is_empty() { + self.headers = other.headers; } - if !value.description.is_empty() { - self.description = value.description; + if !other.description.is_empty() { + self.description = other.description; } - if value.example.is_some() { - self.example = value.example; + if other.example.is_some() { + self.example = other.example; } - if value.examples.is_some() { - self.examples = value.examples; + if other.examples.is_some() { + self.examples = other.examples; } + + self } } -impl Parse for DeriveResponseValue { +impl Parse for DeriveToResponseValue { fn parse(input: ParseStream) -> syn::Result { - let mut response = DeriveResponseValue::default(); + let mut response = DeriveToResponseValue::default(); while !input.is_empty() { let ident = input.parse::()?; @@ -611,6 +633,97 @@ impl Parse for DeriveResponseValue { } } +#[derive(Default)] +struct DeriveIntoResponsesValue { + status: ResponseStatus, + content_type: Option>, + headers: Vec
, + description: String, + example: Option<(AnyValue, Ident)>, + examples: Option<(Punctuated, Ident)>, +} + +impl DeriveResponseValue for DeriveIntoResponsesValue { + fn merge_from(mut self, other: Self) -> Self { + self.status = other.status; + + if other.content_type.is_some() { + self.content_type = other.content_type; + } + if !other.headers.is_empty() { + self.headers = other.headers; + } + if !other.description.is_empty() { + self.description = other.description; + } + if other.example.is_some() { + self.example = other.example; + } + if other.examples.is_some() { + self.examples = other.examples; + } + + self + } +} + +impl Parse for DeriveIntoResponsesValue { + fn parse(input: ParseStream) -> syn::Result { + let mut response = DeriveIntoResponsesValue::default(); + const MISSING_STATUS_ERROR: &str = "missing expected `status` attribute"; + let first_span = input.span(); + + let status_ident = input + .parse::() + .map_err(|error| Error::new(error.span(), MISSING_STATUS_ERROR))?; + + if status_ident == "status" { + response.status = parse_utils::parse_next(input, || input.parse::())?; + } else { + return Err(Error::new(status_ident.span(), MISSING_STATUS_ERROR)); + } + + if response.status.to_token_stream().is_empty() { + return Err(Error::new(first_span, MISSING_STATUS_ERROR)); + } + + while !input.is_empty() { + let ident = input.parse::()?; + let attribute_name = &*ident.to_string(); + + match attribute_name { + "description" => { + response.description = parse::description(input)?; + } + "content_type" => { + response.content_type = Some(parse::content_type(input)?); + } + "headers" => { + response.headers = parse::headers(input)?; + } + "example" => { + response.example = Some((parse::example(input)?, ident)); + } + "examples" => { + response.examples = Some((parse::examples(input)?, ident)); + } + _ => { + return Err(Error::new( + ident.span(), + format!("unexected attribute: {attribute_name}, expected any of: description, content_type, headers, example, examples"), + )); + } + } + + if !input.is_empty() { + input.parse::()?; + } + } + + Ok(response) + } +} + #[derive(Default)] #[cfg_attr(feature = "debug", derive(Debug))] struct ResponseStatus(TokenStream2); @@ -695,14 +808,14 @@ impl ToTokens for ResponseStatus { // ("application/json2" = Response2, example = "...", examples("...", "...")) // ) #[cfg_attr(feature = "debug", derive(Debug))] -struct Content( +struct Content<'c>( String, - PathType, + PathType<'c>, Option, Option>, ); -impl Parse for Content { +impl Parse for Content<'_> { fn parse(input: ParseStream) -> syn::Result { let content; parenthesized!(content in input); @@ -745,7 +858,7 @@ impl Parse for Content { } } -pub struct Responses<'a>(pub &'a [Response]); +pub struct Responses<'a>(pub &'a [Response<'a>]); impl ToTokens for Responses<'_> { fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { @@ -831,7 +944,7 @@ impl ToTokens for Responses<'_> { #[cfg_attr(feature = "debug", derive(Debug))] struct Header { name: String, - value_type: Option, + value_type: Option>, description: Option, } diff --git a/utoipa-gen/src/path/response/derive.rs b/utoipa-gen/src/path/response/derive.rs new file mode 100644 index 00000000..e89ddc3c --- /dev/null +++ b/utoipa-gen/src/path/response/derive.rs @@ -0,0 +1,283 @@ +use std::borrow::Cow; +use std::{iter, mem}; + +use proc_macro2::{Ident, TokenStream}; +use proc_macro_error::{abort, emit_error}; +use quote::{quote, ToTokens}; +use syn::punctuated::Punctuated; +use syn::spanned::Spanned; +use syn::token::Comma; +use syn::{Attribute, Data, Field, Fields, Generics, Path, Type, TypePath}; + +use crate::component::schema::NamedStructSchema; +use crate::doc_comment::CommentAttributes; +use crate::path::{InlineType, PathType}; +use crate::Array; + +use super::{ + DeriveIntoResponsesValue, DeriveResponseValue, ResponseTuple, ResponseTupleInner, ResponseValue, +}; + +pub struct IntoResponses { + pub attributes: Vec, + pub data: Data, + pub generics: Generics, + pub ident: Ident, +} + +impl ToTokens for IntoResponses { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + let responses = match &self.data { + Data::Struct(struct_value) => match &struct_value.fields { + Fields::Named(fields) => { + let response = + NamedStructResponse::new(&self.attributes, &self.ident, &fields.named).0; + let status = &response.status_code; + + Array::from_iter(iter::once(quote!((#status, #response)))) + } + Fields::Unnamed(fields) => { + let field = fields + .unnamed + .iter() + .next() + .expect("Unnamed struct must have 1 field"); + + let response = + UnnamedStructResponse::new(&self.attributes, &field.ty, &field.attrs).0; + let status = &response.status_code; + + Array::from_iter(iter::once(quote!((#status, #response)))) + } + Fields::Unit => { + let response = UnitStructResponse::new(&self.attributes).0; + let status = &response.status_code; + + Array::from_iter(iter::once(quote!((#status, #response)))) + } + }, + Data::Enum(enum_value) => enum_value + .variants + .iter() + .map(|variant| match &variant.fields { + Fields::Named(fields) => { + NamedStructResponse::new(&variant.attrs, &variant.ident, &fields.named).0 + } + Fields::Unnamed(fields) => { + let field = fields + .unnamed + .iter() + .next() + .expect("Unnamed enum variant must have 1 field"); + UnnamedStructResponse::new(&variant.attrs, &field.ty, &field.attrs).0 + } + Fields::Unit => UnitStructResponse::new(&variant.attrs).0, + }) + .map(|response| { + let status = &response.status_code; + quote!((#status, utoipa::openapi::RefOr::from(#response))) + }) + .collect::>(), + Data::Union(_) => abort!(self.ident, "`IntoReponses` does not support `Union` type"), + }; + + let ident = &self.ident; + let (impl_generics, ty_generics, where_clause) = self.generics.split_for_impl(); + + let responses = if responses.len() > 0 { + Some(quote!( .responses_from_iter(#responses))) + } else { + None + }; + tokens.extend(quote!{ + impl #impl_generics utoipa::IntoResponses for #ident #ty_generics #where_clause { + fn responses() -> std::collections::BTreeMap> { + utoipa::openapi::response::ResponsesBuilder::new() + #responses + .build() + .into() + } + } + }) + } +} + +trait Response { + fn to_type(ident: &Ident) -> Type { + let path = Path::from(ident.clone()); + let type_path = TypePath { path, qself: None }; + Type::Path(type_path) + } + + fn has_no_field_attributes(attribute: &Attribute) -> (bool, &'static str) { + const ERROR: &str = "Unexpected field attribute, field attributes are only supported unnamed field structs or enum variants"; + + let ident = attribute.path.get_ident().unwrap(); + match &*ident.to_string() { + "to_schema" => (false, ERROR), + "ref_response" => (false, ERROR), + "to_response" => (false, ERROR), + _ => (true, ERROR), + } + } + + fn validate_attributes<'a, I: IntoIterator>( + attributes: I, + validate: impl Fn(&Attribute) -> (bool, &'static str), + ) { + for attribute in attributes { + let (valid, message) = validate(attribute); + if !valid { + emit_error!(attribute, message) + } + } + } +} + +fn create_response_value( + description: String, + response_value: DeriveIntoResponsesValue, + response_type: Option, +) -> ResponseValue { + ResponseValue { + description: if response_value.description.is_empty() && !description.is_empty() { + description + } else { + response_value.description + }, + headers: response_value.headers, + example: response_value.example.map(|(example, _)| example), + examples: response_value.examples.map(|(examples, _)| examples), + content_type: response_value.content_type, + response_type, + ..Default::default() + } +} + +struct UnnamedStructResponse<'u>(ResponseTuple<'u>); + +impl Response for UnnamedStructResponse<'_> {} + +impl<'u> UnnamedStructResponse<'u> { + fn new(attributes: &[Attribute], ty: &'u Type, inner_attributes: &[Attribute]) -> Self { + let is_inline = inner_attributes + .iter() + .any(|attribute| attribute.path.get_ident().unwrap() == "to_schema"); + let ref_response = inner_attributes + .iter() + .any(|attribute| attribute.path.get_ident().unwrap() == "ref_response"); + let to_response = inner_attributes + .iter() + .any(|attribute| attribute.path.get_ident().unwrap() == "to_response"); + + if is_inline && (ref_response || to_response) { + abort!( + ty.span(), + "Attribute `to_schema` cannot be used with `ref_response` and `to_response` attribute" + ) + } + let mut derive_value = DeriveIntoResponsesValue::from_attributes(attributes) + .expect("`IntoResponses` must have `#[response(...)]` attribute"); + let description = CommentAttributes::from_attributes(attributes).as_formatted_string(); + let status_code = mem::take(&mut derive_value.status); + + match (ref_response, to_response) { + (false, false) => { + let response = create_response_value( + description, + derive_value, + Some(PathType::MediaType(InlineType { + ty: Cow::Borrowed(ty), + is_inline, + })), + ); + Self(ResponseTuple { + inner: Some(super::ResponseTupleInner::Value(response)), + status_code, + }) + } + (true, false) => Self(ResponseTuple { + inner: Some(ResponseTupleInner::Ref(InlineType { + ty: Cow::Borrowed(ty), + is_inline: false, + })), + status_code, + }), + (false, true) => Self(ResponseTuple { + inner: Some(ResponseTupleInner::Ref(InlineType { + ty: Cow::Borrowed(ty), + is_inline: true, + })), + status_code, + }), + (true, true) => { + abort!( + ty.span(), + "Cannot define `ref_response` and `to_response` attribute simultaneously" + ); + } + } + } +} + +struct NamedStructResponse<'n>(ResponseTuple<'n>); + +impl Response for NamedStructResponse<'_> {} + +impl NamedStructResponse<'_> { + fn new(attributes: &[Attribute], ident: &Ident, fields: &Punctuated) -> Self { + Self::validate_attributes(attributes, Self::has_no_field_attributes); + Self::validate_attributes( + fields.iter().flat_map(|field| &field.attrs), + Self::has_no_field_attributes, + ); + + let mut derive_value = DeriveIntoResponsesValue::from_attributes(attributes) + .expect("`IntoResponses` must have `#[response(...)]` attribute"); + let description = CommentAttributes::from_attributes(attributes).as_formatted_string(); + let status_code = mem::take(&mut derive_value.status); + + let inline_schema = NamedStructSchema { + attributes, + fields, + alias: None, + features: None, + generics: None, + rename_all: None, + struct_name: Cow::Owned(ident.to_string()), + }; + + let ty = Self::to_type(ident); + let response_value = create_response_value( + description, + derive_value, + Some(PathType::InlineSchema(inline_schema.to_token_stream(), ty)), + ); + + Self(ResponseTuple { + status_code, + inner: Some(ResponseTupleInner::Value(response_value)), + }) + } +} + +struct UnitStructResponse<'u>(ResponseTuple<'u>); + +impl Response for UnitStructResponse<'_> {} + +impl UnitStructResponse<'_> { + fn new(attributes: &[Attribute]) -> Self { + Self::validate_attributes(attributes, Self::has_no_field_attributes); + + let mut derive_value = DeriveIntoResponsesValue::from_attributes(attributes) + .expect("`IntoResponses` must have `#[response(...)]` attribute"); + let status_code = mem::take(&mut derive_value.status); + let description = CommentAttributes::from_attributes(attributes).as_formatted_string(); + let response_value = create_response_value(description, derive_value, None); + + Self(ResponseTuple { + status_code, + inner: Some(ResponseTupleInner::Value(response_value)), + }) + } +} diff --git a/utoipa-gen/tests/response_derive_test.rs b/utoipa-gen/tests/response_derive_test.rs index 9ba89ad4..d2deeebb 100644 --- a/utoipa-gen/tests/response_derive_test.rs +++ b/utoipa-gen/tests/response_derive_test.rs @@ -495,3 +495,308 @@ fn derive_response_with_inline_unnamed_schema() { }) ) } + +macro_rules! into_responses { + ( $(#[$meta:meta])* $key:ident $ident:ident $($tt:tt)* ) => { + { + #[derive(utoipa::IntoResponses)] + $(#[$meta])* + #[allow(unused)] + $key $ident $( $tt )* + + let responses = <$ident as utoipa::IntoResponses>::responses(); + serde_json::to_value(responses).unwrap() + } + }; +} + +#[test] +fn derive_into_reponses_inline_named_struct_repsonse() { + let responses = into_responses! { + /// This is success response + #[response(status = 200)] + struct SuccessResponse { + value: String, + } + }; + + assert_json_eq!( + responses, + json!({ + "200": { + "content": { + "application/json": { + "schema": { + "description": "This is success response", + "properties": { + "value": { + "type": "string" + }, + }, + "required": ["value"], + "type": "object" + } + } + }, + "description": "This is success response" + } + }) + ) +} + +#[test] +fn derive_into_reponses_unit_struct() { + let responses = into_responses! { + /// Not found response + #[response(status = NOT_FOUND)] + struct NotFound; + }; + + assert_json_eq!( + responses, + json!({ + "404": { + "description": "Not found response" + } + }) + ) +} + +#[test] +fn derive_into_responses_unnamed_struct_inline_schema() { + #[derive(utoipa::ToSchema)] + #[allow(unused)] + struct Foo { + bar: String, + } + + let responses = into_responses! { + #[response(status = 201)] + struct CreatedResponse(#[to_schema] Foo); + }; + + assert_json_eq!( + responses, + json!({ + "201": { + "content": { + "application/json": { + "schema": { + "properties": { + "bar": { + "type": "string" + }, + }, + "required": ["bar"], + "type": "object" + } + } + }, + "description": "" + } + }) + ) +} + +#[test] +fn derive_into_responses_unnamed_struct_with_primitive_schema() { + let responses = into_responses! { + #[response(status = 201)] + struct CreatedResponse(String); + }; + + assert_json_eq!( + responses, + json!({ + "201": { + "content": { + "text/plain": { + "schema": { + "type": "string", + } + } + }, + "description": "" + } + }) + ) +} + +#[test] +fn derive_into_responses_unnamed_struct_ref_schema() { + #[derive(utoipa::ToSchema)] + #[allow(unused)] + struct Foo { + bar: String, + } + + let responses = into_responses! { + #[response(status = 201)] + struct CreatedResponse(Foo); + }; + + assert_json_eq!( + responses, + json!({ + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Foo", + } + } + }, + "description": "" + } + }) + ) +} + +#[test] +fn derive_into_responses_unnamed_struct_ref_response() { + #[derive(utoipa::ToResponse)] + #[allow(unused)] + struct Foo { + bar: String, + } + + let responses = into_responses! { + #[response(status = 201)] + struct CreatedResponse(#[ref_response] Foo); + }; + + assert_json_eq!( + responses, + json!({ + "201": { + "$ref": "#/components/responses/Foo" + } + }) + ) +} + +#[test] +fn derive_into_responses_unnamed_struct_to_response() { + #[derive(utoipa::ToResponse)] + #[allow(unused)] + struct Foo { + bar: String, + } + + let responses = into_responses! { + #[response(status = 201)] + struct CreatedResponse(#[to_response] Foo); + }; + + assert_json_eq!( + responses, + json!({ + "201": { + "content": { + "application/json": { + "schema": { + "properties": { + "bar": { + "type": "string" + } + }, + "required": ["bar"], + "type": "object", + } + } + }, + "description": "" + } + }) + ) +} + +#[test] +fn derive_into_responses_enum_with_multiple_responses() { + #[derive(utoipa::ToSchema)] + #[allow(unused)] + struct BadRequest { + value: String, + } + + #[derive(utoipa::ToResponse)] + #[allow(unused)] + struct Response { + message: String, + } + + let responses = into_responses! { + enum UserResponses { + /// Success response + #[response(status = 200)] + Success { value: String }, + + #[response(status = 404)] + NotFound, + + #[response(status = 400)] + BadRequest(BadRequest), + + #[response(status = 500)] + ServerError(#[ref_response] Response), + + #[response(status = 418)] + TeaPot(#[to_response] Response), + } + }; + + assert_json_eq!( + responses, + json!({ + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "value": { + "type": "string" + } + }, + "description": "Success response", + "required": ["value"], + "type": "object", + } + } + }, + "description": "Success response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequest" + } + } + }, + "description": "", + }, + "404": { + "description": "" + }, + "418": { + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object", + } + } + }, + "description": "", + }, + "500": { + "$ref": "#/components/responses/Response" + } + }) + ) +} diff --git a/utoipa/src/openapi/response.rs b/utoipa/src/openapi/response.rs index cde16710..145d6ca9 100644 --- a/utoipa/src/openapi/response.rs +++ b/utoipa/src/openapi/response.rs @@ -50,15 +50,17 @@ impl ResponsesBuilder { /// Add responses from an iterator over a pair of `(status_code, response): (String, Response)`. pub fn responses_from_iter< - I: Iterator, + I: IntoIterator, C: Into, R: Into>, >( mut self, iter: I, ) -> Self { - self.responses - .extend(iter.map(|(code, response)| (code.into(), response.into()))); + self.responses.extend( + iter.into_iter() + .map(|(code, response)| (code.into(), response.into())), + ); self }