From 8d5149ff8acf8af71af30bdb5baf29291e1c8462 Mon Sep 17 00:00:00 2001 From: Juha Kukkonen Date: Tue, 1 Oct 2024 22:07:05 +0300 Subject: [PATCH] Auto collect tuple responses schema references (#1071) Refactor Tuple responses parsing unifying it with request body parsing. This allows reusing same components when serialized to tokens making it less error prone and removing duplication. This also removes the `content_type = [...]` array format from `ToResponse` and `IntoResponses` derive types as well as from tuple style responses. Same as with request bodies the multiple content types need to defined with `content(...)` attribute. Implement auto collect response schema references from tuple style responses within `#[utoipa::path(...)]` attribute macro. Schema references will be collected recursively in same manner as for request bodies. Example of supported syntax. The `User` will be automatically collected to OpenApi when `get_user` path is registered to the `OpenApi`. ```rust #[derive(utoipa::ToSchema)] struct User { name: String, } #[utoipa::path( get, path = "/user", responses( (status = 200, body = User) ) )] fn get_user() {} ``` --- utoipa-gen/src/component/schema/enums.rs | 2 +- utoipa-gen/src/lib.rs | 59 +- utoipa-gen/src/path.rs | 147 +--- utoipa-gen/src/path/media_type.rs | 45 +- utoipa-gen/src/path/parameter.rs | 8 +- utoipa-gen/src/path/request_body.rs | 60 +- utoipa-gen/src/path/response.rs | 687 +++++++----------- utoipa-gen/src/path/response/derive.rs | 239 +++--- utoipa-gen/src/path/response/header.rs | 165 +++++ utoipa-gen/tests/path_response_derive_test.rs | 109 ++- utoipa-gen/tests/response_derive_test.rs | 44 -- 11 files changed, 783 insertions(+), 782 deletions(-) create mode 100644 utoipa-gen/src/path/response/header.rs diff --git a/utoipa-gen/src/component/schema/enums.rs b/utoipa-gen/src/component/schema/enums.rs index 2da64e26..87908999 100644 --- a/utoipa-gen/src/component/schema/enums.rs +++ b/utoipa-gen/src/component/schema/enums.rs @@ -1005,7 +1005,7 @@ where /// `RefOrOwned` is simple `Cow` like type to wrap either `ref` or owned value. This allows passing /// either owned or referenced values as if they were owned like the `Cow` does but this works with -/// non clonable types. Thus values cannot be modified but they can be passed down as re-referenced +/// non cloneable types. Thus values cannot be modified but they can be passed down as re-referenced /// values by dereffing the original value. `Roo::Ref(original.deref())`. #[cfg_attr(feature = "debug", derive(Debug))] pub enum Roo<'t, T> { diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs index 2457d080..df6676f8 100644 --- a/utoipa-gen/src/lib.rs +++ b/utoipa-gen/src/lib.rs @@ -1041,15 +1041,11 @@ pub fn derive_to_schema(input: TokenStream) -> TokenStream { /// that free form _`ref`_ is accessible via OpenAPI doc or Swagger UI, users are responsible for making /// these guarantees. /// -/// * `content_type = "..."` or `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 mixed 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 will use the first _`content_type`_ value as a default example. +/// * `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 mixed enum types. /// /// * `headers(...)` Slice of response headers that are returned back to a caller. /// @@ -1059,10 +1055,8 @@ pub fn derive_to_schema(input: TokenStream) -> TokenStream { /// * `response = ...` Type what implements [`ToResponse`][to_response_trait] trait. This can alternatively be used to /// define response attributes. _`response`_ attribute cannot co-exist with other than _`status`_ attribute. /// -/// * `content((...), (...))` Can be used to define multiple return types for single response status. Supported format for single -/// _content_ is `(content_type = response_body, example = "...", examples(...))`. _`example`_ -/// and _`examples`_ are optional arguments. Examples attribute behaves exactly same way as in -/// the response and is mutually exclusive with the example attribute. +/// * `content((...), (...))` Can be used to define multiple return types for single response status. Supports same syntax as +/// [multiple request body content][`macro@path#multiple-request-body-content`]. /// /// * `examples(...)` Define multiple examples for single response. This attribute is mutually /// exclusive to the _`example`_ attribute and if both are defined this will override the _`example`_. @@ -1159,13 +1153,6 @@ pub fn derive_to_schema(input: TokenStream) -> TokenStream { /// ) /// ``` /// -/// **Response with multiple response content types:** -/// ```text -/// responses( -/// (status = 200, description = "Success response", body = Pet, content_type = ["application/json", "text/xml"]) -/// ) -/// ``` -/// /// **Multiple response return types with _`content(...)`_ attribute:** /// /// _**Define multiple response return types for single response status with their own example.**_ @@ -1628,8 +1615,8 @@ pub fn derive_to_schema(input: TokenStream) -> TokenStream { /// path = "/user", /// responses( /// (status = 200, content( -/// ("application/vnd.user.v1+json" = User1, example = json!({"id": "id".to_string()})), -/// ("application/vnd.user.v2+json" = User2, example = json!({"id": 2})) +/// (User1 = "application/vnd.user.v1+json", example = json!({"id": "id".to_string()})), +/// (User2 = "application/vnd.user.v2+json", example = json!({"id": 2})) /// ) /// ) /// ) @@ -2525,15 +2512,11 @@ pub fn into_params(input: TokenStream) -> TokenStream { /// * `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 mixed 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 will use the first _`content_type`_ value as a default example. +/// * `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 mixed enum types. /// /// * `headers(...)` Slice of response headers that are returned back to a caller. /// @@ -2692,15 +2675,11 @@ pub fn to_response(input: TokenStream) -> TokenStream { /// * `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 mixed 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 will use the first _`content_type`_ value as a default example. +/// * `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 mixed enum types. /// /// * `headers(...)` Slice of response headers that are returned back to a caller. /// diff --git a/utoipa-gen/src/path.rs b/utoipa-gen/src/path.rs index 209f5666..c274af81 100644 --- a/utoipa-gen/src/path.rs +++ b/utoipa-gen/src/path.rs @@ -6,11 +6,11 @@ use proc_macro2::{Ident, Span, TokenStream as TokenStream2}; use quote::{quote, quote_spanned, ToTokens}; use syn::punctuated::Punctuated; use syn::spanned::Spanned; -use syn::token::{Comma, Paren}; +use syn::token::Comma; use syn::{parenthesized, parse::Parse, Token}; -use syn::{Expr, ExprLit, Lit, LitStr, Type}; +use syn::{Expr, ExprLit, Lit, LitStr}; -use crate::component::{GenericType, TypeTree}; +use crate::component::{ComponentSchema, GenericType, TypeTree}; use crate::{ as_tokens_or_diagnostics, parse_utils, Deprecated, Diagnostics, OptionExt, ToTokensDiagnostics, }; @@ -472,6 +472,32 @@ impl<'p> ToTokensDiagnostics for Path<'p> { }; let operation = as_tokens_or_diagnostics!(&operation); + fn to_schema_references( + mut schemas: TokenStream2, + component_schema: ComponentSchema, + ) -> TokenStream2 { + for reference in component_schema.schema_references { + let name = &reference.name; + let tokens = &reference.tokens; + let references = &reference.references; + + schemas.extend(quote!( schemas.push((#name, #tokens)); )); + schemas.extend(quote!( #references; )); + } + + schemas + } + + let response_schemas = self + .path_attr + .responses + .iter() + .map(|response| response.get_component_schemas()) + .collect::, Diagnostics>>()? + .into_iter() + .flatten() + .fold(TokenStream2::new(), to_schema_references); + let schemas = self .path_attr .request_body @@ -479,18 +505,7 @@ impl<'p> ToTokensDiagnostics for Path<'p> { .map_try(|request_body| request_body.get_component_schemas())? .into_iter() .flatten() - .fold(TokenStream2::new(), |mut schemas, component_schema| { - for reference in component_schema.schema_references { - let name = &reference.name; - let tokens = &reference.tokens; - let references = &reference.references; - - schemas.extend(quote!( schemas.push((#name, #tokens)); )); - schemas.extend(quote!( #references; )); - } - - schemas - }); + .fold(TokenStream2::new(), to_schema_references); let mut tags = self.path_attr.tags.clone(); if let Some(tag) = self.path_attr.tag.as_ref() { @@ -538,6 +553,7 @@ impl<'p> ToTokensDiagnostics for Path<'p> { impl utoipa::__dev::SchemaReferences for #impl_for { fn schemas(schemas: &mut Vec<(String, utoipa::openapi::RefOr)>) { #schemas + #response_schemas } } @@ -651,82 +667,10 @@ impl ToTokens for Summary<'_> { } } -/// Represents either `ref("...")` or `Type` that can be optionally inlined with `inline(Type)`. -#[cfg_attr(feature = "debug", derive(Debug))] -enum PathType<'p> { - Ref(String), - MediaType(InlineType<'p>), - InlineSchema(TokenStream2, Type), -} - -impl Parse for PathType<'_> { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let fork = input.fork(); - let is_ref = if (fork.parse::>()?).is_some() { - fork.peek(Paren) - } else { - false - }; - - if is_ref { - input.parse::()?; - let ref_stream; - parenthesized!(ref_stream in input); - Ok(Self::Ref(ref_stream.parse::()?.value())) - } else { - Ok(Self::MediaType(input.parse()?)) - } - } -} - -// inline(syn::Type) | syn::Type -#[cfg_attr(feature = "debug", derive(Debug))] -struct InlineType<'i> { - ty: Cow<'i, Type>, - is_inline: bool, -} - -impl InlineType<'_> { - /// Get's the underlying [`syn::Type`] as [`TypeTree`]. - fn as_type_tree(&self) -> Result { - TypeTree::from_type(&self.ty) - } -} - -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::>()? { - ident == "inline" && fork.peek(Paren) - } else { - false - }; - - let ty = if is_inline { - input.parse::()?; - let inlined; - parenthesized!(inlined in input); - - inlined.parse::()? - } else { - input.parse::()? - }; - - Ok(InlineType { - ty: Cow::Owned(ty), - is_inline, - }) - } -} - pub trait PathTypeTree { /// Resolve default content type based on current [`Type`]. fn get_default_content_type(&self) -> Cow<'static, str>; - #[allow(unused)] - /// Check whether [`TypeTree`] an option - fn is_option(&self) -> bool; - /// Check whether [`TypeTree`] is a Vec, slice, array or other supported array type fn is_array(&self) -> bool; } @@ -769,11 +713,6 @@ impl<'p> PathTypeTree for TypeTree<'p> { } } - /// Check whether [`TypeTree`] an option - fn is_option(&self) -> bool { - matches!(self.generic_type, Some(GenericType::Option)) - } - /// Check whether [`TypeTree`] is a Vec, slice, array or other supported array type fn is_array(&self) -> bool { match self.generic_type { @@ -792,8 +731,8 @@ impl<'p> PathTypeTree for TypeTree<'p> { mod parse { use syn::parse::ParseStream; use syn::punctuated::Punctuated; - use syn::token::{Bracket, Comma}; - use syn::{bracketed, Result}; + use syn::token::Comma; + use syn::Result; use crate::path::example::Example; use crate::{parse_utils, AnyValue}; @@ -803,26 +742,6 @@ mod parse { parse_utils::parse_next_literal_str_or_expr(input) } - #[inline] - pub(super) fn content_type(input: ParseStream) -> Result> { - parse_utils::parse_next(input, || { - let look_content_type = input.lookahead1(); - if look_content_type.peek(Bracket) { - let content_types; - bracketed!(content_types in input); - Ok( - Punctuated::::parse_terminated( - &content_types, - )? - .into_iter() - .collect(), - ) - } else { - Ok(vec![input.parse::()?]) - } - }) - } - #[inline] pub(super) fn example(input: ParseStream) -> Result { parse_utils::parse_next(input, || AnyValue::parse_lit_str_or_json(input)) diff --git a/utoipa-gen/src/path/media_type.rs b/utoipa-gen/src/path/media_type.rs index fb613112..e6f5f0a7 100644 --- a/utoipa-gen/src/path/media_type.rs +++ b/utoipa-gen/src/path/media_type.rs @@ -48,7 +48,7 @@ impl Parse for MediaTypeAttr<'_> { Error::new( error.span(), format!( - "missing content type e.g. `\"application/json\"`, {error}" + r#"missing content type e.g. `"application/json"`, {error}"# ), ) })?, @@ -97,11 +97,24 @@ impl<'m> MediaTypeAttr<'m> { let name = &*attribute.to_string(); match name { - "example" => media_type.example = Some(parse_utils::parse_next(input, || AnyValue::parse_any(input))?), - "examples" => media_type.examples = parse_utils::parse_comma_separated_within_parenthesis(input)?, + "example" => { + media_type.example = Some(parse_utils::parse_next(input, || { + AnyValue::parse_any(input) + })?) + } + "examples" => { + media_type.examples = parse_utils::parse_comma_separated_within_parenthesis(input)? + } // // TODO implement encoding support // "encoding" => (), - unexpected => return Err(syn::Error::new(attribute.span(), format!("unexpected attribute: {unexpected}, expected any of: schema, example, examples"))), + unexpected => { + return Err(syn::Error::new( + attribute.span(), + format!( + "unexpected attribute: {unexpected}, expected any of: example, examples" + ), + )) + } } if !input.is_empty() { @@ -211,6 +224,11 @@ pub enum DefaultSchema<'d> { /// `content_type` without actual schema. #[default] None, + /// Support for raw tokens as Schema. Used in response derive. + Raw { + tokens: TokenStream, + ty: Cow<'d, Type>, + }, } impl ToTokensDiagnostics for DefaultSchema<'_> { @@ -235,6 +253,11 @@ impl ToTokensDiagnostics for DefaultSchema<'_> { component_tokens.to_tokens(tokens); } + Self::Raw { + tokens: raw_tokens, .. + } => { + raw_tokens.to_tokens(tokens); + } // nada Self::None => (), } @@ -271,6 +294,10 @@ impl DefaultSchema<'_> { Ok(type_tree.get_default_content_type()) } Self::Ref(_) => Ok(Cow::Borrowed("application/json")), + Self::Raw { ty, .. } => { + let type_tree = TypeTree::from_type(ty.as_ref())?; + Ok(type_tree.get_default_content_type()) + } Self::None => Ok(Cow::Borrowed("")), } } @@ -318,11 +345,17 @@ impl Parse for DefaultSchema<'_> { } } +impl<'r> From> for Schema<'r> { + fn from(value: ParsedType<'r>) -> Self { + Self::Default(DefaultSchema::TypePath(value)) + } +} + // inline(syn::TypePath) | syn::TypePath #[cfg_attr(feature = "debug", derive(Debug))] pub struct ParsedType<'i> { - ty: Cow<'i, Type>, - is_inline: bool, + pub ty: Cow<'i, Type>, + pub is_inline: bool, } impl ParsedType<'_> { diff --git a/utoipa-gen/src/path/parameter.rs b/utoipa-gen/src/path/parameter.rs index 5fb9b0e3..7bb3ede1 100644 --- a/utoipa-gen/src/path/parameter.rs +++ b/utoipa-gen/src/path/parameter.rs @@ -24,12 +24,12 @@ use crate::{ }, Feature, ToTokensExt, }, - ComponentSchema, Container, + ComponentSchema, Container, TypeTree, }, parse_utils, Diagnostics, Required, ToTokensDiagnostics, }; -use super::InlineType; +use super::media_type::ParsedType; /// Parameter of request such as in path, header, query or cookie /// @@ -192,7 +192,7 @@ impl ToTokensDiagnostics for ParameterSchema<'_> { Ok(()) } ParameterType::Parsed(inline_type) => { - let type_tree = inline_type.as_type_tree()?; + let type_tree = TypeTree::from_type(inline_type.ty.as_ref())?; let required: Required = (!type_tree.is_option()).into(); let mut schema_features = Vec::::new(); schema_features.clone_from(&self.features); @@ -224,7 +224,7 @@ enum ParameterType<'p> { feature = "axum_extras" ))] External(crate::component::TypeTree<'p>), - Parsed(InlineType<'p>), + Parsed(ParsedType<'p>), } #[derive(Default)] diff --git a/utoipa-gen/src/path/request_body.rs b/utoipa-gen/src/path/request_body.rs index 970e5b5b..553aab72 100644 --- a/utoipa-gen/src/path/request_body.rs +++ b/utoipa-gen/src/path/request_body.rs @@ -11,30 +11,6 @@ use crate::{parse_utils, Diagnostics, Required, ToTokensDiagnostics}; use super::media_type::{MediaTypeAttr, Schema}; use super::parse; -#[allow(unused)] -enum ComponentSchemaIter { - Iter(Box>), - Option(std::option::IntoIter), -} - -impl Iterator for ComponentSchemaIter { - type Item = T; - - fn next(&mut self) -> Option { - match self { - Self::Iter(iter) => iter.next(), - Self::Option(option) => option.next(), - } - } - - fn size_hint(&self) -> (usize, Option) { - match self { - Self::Iter(iter) => iter.size_hint(), - Self::Option(option) => option.size_hint(), - } - } -} - /// Parsed information related to request body of path. /// /// Supported configuration options: @@ -85,14 +61,14 @@ impl Iterator for ComponentSchemaIter { #[cfg_attr(feature = "debug", derive(Debug))] pub struct RequestBodyAttr<'r> { description: Option, - media_type: Vec>, + content: Vec>, } impl<'r> RequestBodyAttr<'r> { fn new() -> Self { Self { description: Default::default(), - media_type: vec![MediaTypeAttr::default()], + content: vec![MediaTypeAttr::default()], } } @@ -103,7 +79,7 @@ impl<'r> RequestBodyAttr<'r> { ))] pub fn from_schema(schema: Schema<'r>) -> RequestBodyAttr<'r> { Self { - media_type: vec![MediaTypeAttr { + content: vec![MediaTypeAttr { schema, ..Default::default() }], @@ -115,7 +91,7 @@ impl<'r> RequestBodyAttr<'r> { &self, ) -> Result, Diagnostics> { Ok(self - .media_type + .content .iter() .map(|media_type| media_type.schema.get_component_schema()) .collect::, Diagnostics>>()? @@ -134,6 +110,7 @@ impl Parse for RequestBodyAttr<'_> { let group; syn::parenthesized!(group in input); + let mut is_content_group = false; let mut request_body_attr = RequestBodyAttr::new(); while !group.is_empty() { let ident = group @@ -146,10 +123,11 @@ impl Parse for RequestBodyAttr<'_> { if group.peek(Token![=]) { group.parse::()?; let schema = MediaTypeAttr::parse_schema(&group)?; - if let Some(media_type) = request_body_attr.media_type.get_mut(0) { + if let Some(media_type) = request_body_attr.content.get_mut(0) { media_type.schema = Schema::Default(schema); } } else if group.peek(Paren) { + is_content_group = true; fn group_parser<'a>( input: ParseStream, ) -> syn::Result> { @@ -166,17 +144,22 @@ impl Parse for RequestBodyAttr<'_> { .into_iter() .collect::>(); - request_body_attr.media_type = media_type; + request_body_attr.content = media_type; } else { return Err(Error::new(ident.span(), "unexpected content format, expected either `content = schema` or `content(...)`")); } } "content_type" => { + if is_content_group { + return Err(Error::new(ident.span(), "cannot set `content_type` when content(...) is defined in group form")); + } let content_type = parse_utils::parse_next(&group, || { parse_utils::LitStrOrExpr::parse(&group) - })?; + }).map_err(|error| Error::new(error.span(), + format!(r#"invalid content_type, must be literal string or expression, e.g. "application/json", {error} "#) + ))?; - if let Some(media_type) = request_body_attr.media_type.get_mut(0) { + if let Some(media_type) = request_body_attr.content.get_mut(0) { media_type.content_type = Some(content_type); } } @@ -184,17 +167,14 @@ impl Parse for RequestBodyAttr<'_> { request_body_attr.description = Some(parse::description(&group)?); } _ => { - if let Err(error) = MediaTypeAttr::parse_named_attributes( + MediaTypeAttr::parse_named_attributes( request_body_attr - .media_type + .content .get_mut(0) .expect("parse request body named attributes must have media type"), &group, &ident, - ) { - return Err(Error::new(error.span(), - format!("unexpected attribute: {attribute_name}, expected any of: content, content_type, description, examples, example"))); - } + )?; } } @@ -215,7 +195,7 @@ impl Parse for RequestBodyAttr<'_> { }; Ok(RequestBodyAttr { - media_type: vec![media_type], + content: vec![media_type], description: None, }) } else { @@ -227,7 +207,7 @@ impl Parse for RequestBodyAttr<'_> { impl ToTokensDiagnostics for RequestBodyAttr<'_> { fn to_tokens(&self, tokens: &mut TokenStream) -> Result<(), Diagnostics> { let media_types = self - .media_type + .content .iter() .map(|media_type| { let default_content_type_result = media_type.schema.get_default_content_type(); diff --git a/utoipa-gen/src/path/response.rs b/utoipa-gen/src/path/response.rs index c902f33f..655f2f9b 100644 --- a/utoipa-gen/src/path/response.rs +++ b/utoipa-gen/src/path/response.rs @@ -7,19 +7,25 @@ use syn::{ punctuated::Punctuated, spanned::Spanned, token::Comma, - Attribute, Error, ExprPath, Generics, LitInt, LitStr, Token, TypePath, + Attribute, Error, ExprPath, LitInt, LitStr, Token, TypePath, }; use crate::{ - component::{features::attributes::Inline, ComponentSchema, Container, TypeTree}, - parse_utils, AnyValue, Array, Diagnostics, ToTokensDiagnostics, + component::ComponentSchema, parse_utils, path::media_type::Schema, AnyValue, Diagnostics, + ToTokensDiagnostics, }; -use self::link::LinkTuple; +use self::{header::Header, link::LinkTuple}; -use super::{example::Example, parse, status::STATUS_CODES, InlineType, PathType, PathTypeTree}; +use super::{ + example::Example, + media_type::{DefaultSchema, MediaTypeAttr, ParsedType}, + parse, + status::STATUS_CODES, +}; pub mod derive; +mod header; pub mod link; #[cfg_attr(feature = "debug", derive(Debug))] @@ -42,6 +48,54 @@ impl Parse for Response<'_> { } } +impl Response<'_> { + pub fn get_component_schemas( + &self, + ) -> Result, Diagnostics> { + match self { + Self::Tuple(tuple) => match &tuple.inner { + // Only tuple type will have `ComponentSchema`s as of now + Some(ResponseTupleInner::Value(value)) => { + Ok(ResponseComponentSchemaIter::Iter(Box::new( + value + .content + .iter() + .map(|media_type| media_type.schema.get_component_schema()) + .collect::, Diagnostics>>()? + .into_iter() + .flatten(), + ))) + } + _ => Ok(ResponseComponentSchemaIter::Empty), + }, + Self::IntoResponses(_) => Ok(ResponseComponentSchemaIter::Empty), + } + } +} + +pub enum ResponseComponentSchemaIter<'a, T> { + Iter(Box + 'a>), + Empty, +} + +impl<'a, T> Iterator for ResponseComponentSchemaIter<'a, T> { + type Item = T; + + fn next(&mut self) -> Option { + match self { + Self::Iter(iter) => iter.next(), + Self::Empty => None, + } + } + + fn size_hint(&self) -> (usize, Option) { + match self { + Self::Iter(iter) => iter.size_hint(), + Self::Empty => (0, None), + } + } +} + /// Parsed representation of response attributes from `#[utoipa::path]` attribute. #[derive(Default)] #[cfg_attr(feature = "debug", derive(Debug))] @@ -54,20 +108,36 @@ const RESPONSE_INCOMPATIBLE_ATTRIBUTES_MSG: &str = "The `response` attribute may only be used in conjunction with the `status` attribute"; 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<'r>> { - if self.inner.is_none() { - self.inner = Some(ResponseTupleInner::Value(ResponseValue::default())); - } - if let ResponseTupleInner::Value(val) = self.inner.as_mut().unwrap() { - Ok(val) - } else { - Err(Error::new(span, RESPONSE_INCOMPATIBLE_ATTRIBUTES_MSG)) - } + /// Set as `ResponseValue` the content. This will fail if `response` attribute is already + /// defined. + fn set_as_value syn::Result<()>>( + &mut self, + ident: &Ident, + attribute: &str, + op: F, + ) -> syn::Result<()> { + match &mut self.inner { + Some(ResponseTupleInner::Value(value)) => { + op(value)?; + } + Some(ResponseTupleInner::Ref(_)) => { + return Err(Error::new(ident.span(), format!("Cannot use `{attribute}` in conjunction with `response`. The `response` attribute can only be used in conjunction with `status` attribute."))); + } + None => { + let mut value = ResponseValue { + content: vec![MediaTypeAttr::default()], + ..Default::default() + }; + op(&mut value)?; + self.inner = Some(ResponseTupleInner::Value(value)) + } + }; + + Ok(()) } // 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<'r>) -> syn::Result<()> { + fn set_ref_type(&mut self, span: Span, ty: ParsedType<'r>) -> syn::Result<()> { match &mut self.inner { None => self.inner = Some(ResponseTupleInner::Ref(ty)), Some(ResponseTupleInner::Ref(r)) => *r = ty, @@ -82,12 +152,13 @@ impl<'r> ResponseTuple<'r> { #[cfg_attr(feature = "debug", derive(Debug))] enum ResponseTupleInner<'r> { Value(ResponseValue<'r>), - Ref(InlineType<'r>), + Ref(ParsedType<'r>), } 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"; + const EXPECTED_ATTRIBUTES: &str = + "status, description, body, content_type, headers, example, examples, response"; let mut response = ResponseTuple::default(); @@ -95,51 +166,28 @@ impl Parse for ResponseTuple<'_> { let ident = input.parse::().map_err(|error| { Error::new( error.span(), - format!("{EXPECTED_ATTRIBUTE_MESSAGE}, {error}"), + format!( + "unexpected attribute, expected any of: {EXPECTED_ATTRIBUTES}, {error}" + ), ) })?; - let attribute_name = &*ident.to_string(); - - match attribute_name { + let name = &*ident.to_string(); + match name { "status" => { response.status_code = parse_utils::parse_next(input, || input.parse::())?; } - "description" => { - response.as_value(input.span())?.description = parse::description(input)?; - } - "body" => { - response.as_value(input.span())?.response_type = - Some(parse_utils::parse_next(input, || input.parse())?); - } - "content_type" => { - response.as_value(input.span())?.content_type = - Some(parse::content_type(input)?); - } - "headers" => { - response.as_value(input.span())?.headers = headers(input)?; - } - "example" => { - response.as_value(input.span())?.example = Some(parse::example(input)?); - } - "examples" => { - response.as_value(input.span())?.examples = Some(parse::examples(input)?); - } - "content" => { - response.as_value(input.span())?.content = - parse_utils::parse_comma_separated_within_parenthesis(input)?; - } - "links" => { - response.as_value(input.span())?.links = - parse_utils::parse_comma_separated_within_parenthesis(input)?; - } "response" => { response.set_ref_type( input.span(), parse_utils::parse_next(input, || input.parse())?, )?; } - _ => return Err(Error::new(ident.span(), EXPECTED_ATTRIBUTE_MESSAGE)), + _ => { + response.set_as_value(&ident, name, |value| { + value.parse_named_attributes(input, &ident) + })?; + } } if !input.is_empty() { @@ -147,10 +195,6 @@ impl Parse for ResponseTuple<'_> { } } - if response.inner.is_none() { - response.inner = Some(ResponseTupleInner::Value(ResponseValue::default())) - } - Ok(response) } } @@ -173,53 +217,144 @@ impl<'r> From<(ResponseStatus, ResponseValue<'r>)> for ResponseTuple<'r> { } } -pub struct DeriveResponsesAttributes { - derive_value: T, +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct ResponseValue<'r> { description: parse_utils::LitStrOrExpr, + headers: Vec
, + links: Punctuated, + content: Vec>, + is_content_group: bool, } -impl<'r> From> for ResponseValue<'r> { - fn from(value: DeriveResponsesAttributes) -> Self { - Self::from_derive_into_responses_value(value.derive_value, value.description) +impl Parse for ResponseValue<'_> { + fn parse(input: ParseStream) -> syn::Result { + let mut response_value = ResponseValue::default(); + + while !input.is_empty() { + let ident = input.parse::().map_err(|error| { + Error::new( + error.span(), + format!( + "unexpected attribute, expected any of: {expected_attributes}, {error}", + expected_attributes = ResponseValue::EXPECTED_ATTRIBUTES + ), + ) + })?; + response_value.parse_named_attributes(input, &ident)?; + + if !input.is_empty() { + input.parse::()?; + } + } + + Ok(response_value) } } -impl<'r> From>> for ResponseValue<'r> { - fn from( - DeriveResponsesAttributes::> { - derive_value, - description, - }: DeriveResponsesAttributes>, - ) -> Self { - if let Some(derive_value) = derive_value { - ResponseValue::from_derive_to_response_value(derive_value, description) - } else { - ResponseValue { - description, - ..Default::default() +impl<'r> ResponseValue<'r> { + const EXPECTED_ATTRIBUTES: &'static str = + "description, body, content_type, headers, example, examples"; + + fn parse_named_attributes(&mut self, input: ParseStream, attribute: &Ident) -> syn::Result<()> { + let attribute_name = &*attribute.to_string(); + + match attribute_name { + "description" => { + self.description = parse::description(input)?; + } + "body" => { + if self.is_content_group { + return Err(Error::new( + attribute.span(), + "cannot set `body` when content(...) is defined in group form", + )); + } + + let schema = parse_utils::parse_next(input, || MediaTypeAttr::parse_schema(input))?; + if let Some(media_type) = self.content.get_mut(0) { + media_type.schema = Schema::Default(schema); + } + } + "content_type" => { + if self.is_content_group { + return Err(Error::new( + attribute.span(), + "cannot set `content_type` when content(...) is defined in group form", + )); + } + let content_type = parse_utils::parse_next(input, || { + parse_utils::LitStrOrExpr::parse(input) + }).map_err(|error| Error::new(error.span(), + format!(r#"invalid content_type, must be literal string or expression, e.g. "application/json", {error} "#) + ))?; + + if let Some(media_type) = self.content.get_mut(0) { + media_type.content_type = Some(content_type); + } + } + "headers" => { + self.headers = header::headers(input)?; + } + "content" => { + self.is_content_group = true; + fn group_parser<'a>(input: ParseStream) -> syn::Result> { + let buf; + syn::parenthesized!(buf in input); + buf.call(MediaTypeAttr::parse) + } + + let content = + parse_utils::parse_comma_separated_within_parethesis_with(input, group_parser)? + .into_iter() + .collect::>(); + + self.content = content; + } + "links" => { + self.links = parse_utils::parse_comma_separated_within_parenthesis(input)?; + } + _ => { + MediaTypeAttr::parse_named_attributes( + self.content.get_mut(0).expect( + "parse named attributes response value must have one media type by default", + ), + input, + attribute, + )?; } } + Ok(()) } -} -#[derive(Default)] -#[cfg_attr(feature = "debug", derive(Debug))] -pub struct ResponseValue<'r> { - description: parse_utils::LitStrOrExpr, - response_type: Option>, - content_type: Option>, - headers: Vec
, - example: Option, - examples: Option>, - content: Punctuated, Comma>, - links: Punctuated, -} + fn from_schema>>(schema: S, description: parse_utils::LitStrOrExpr) -> Self { + let media_type = MediaTypeAttr { + schema: schema.into(), + ..Default::default() + }; -impl<'r> ResponseValue<'r> { - fn from_derive_to_response_value( + Self { + description, + content: vec![media_type], + ..Default::default() + } + } + + fn from_derive_to_response_value>>( derive_value: DeriveToResponseValue, + schema: S, description: parse_utils::LitStrOrExpr, ) -> Self { + let media_type = MediaTypeAttr { + content_type: derive_value.content_type, + schema: schema.into(), + example: derive_value.example.map(|(example, _)| example), + examples: derive_value + .examples + .map(|(examples, _)| examples) + .unwrap_or_default(), + }; + Self { description: if derive_value.description.is_empty_litstr() && !description.is_empty_litstr() @@ -229,17 +364,26 @@ impl<'r> ResponseValue<'r> { derive_value.description }, headers: derive_value.headers, - example: derive_value.example.map(|(example, _)| example), - examples: derive_value.examples.map(|(examples, _)| examples), - content_type: derive_value.content_type, + content: vec![media_type], ..Default::default() } } - fn from_derive_into_responses_value( + fn from_derive_into_responses_value>>( response_value: DeriveIntoResponsesValue, + schema: S, description: parse_utils::LitStrOrExpr, ) -> Self { + let media_type = MediaTypeAttr { + content_type: response_value.content_type, + schema: schema.into(), + example: response_value.example.map(|(example, _)| example), + examples: response_value + .examples + .map(|(examples, _)| examples) + .unwrap_or_default(), + }; + ResponseValue { description: if response_value.description.is_empty_litstr() && !description.is_empty_litstr() @@ -249,24 +393,16 @@ impl<'r> ResponseValue<'r> { 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, + content: vec![media_type], ..Default::default() } } - - fn response_type(mut self, response_type: Option>) -> Self { - self.response_type = response_type; - - self - } } impl ToTokensDiagnostics for ResponseTuple<'_> { fn to_tokens(&self, tokens: &mut TokenStream2) -> Result<(), Diagnostics> { - match self.inner.as_ref().unwrap() { - ResponseTupleInner::Ref(res) => { + match self.inner.as_ref() { + Some(ResponseTupleInner::Ref(res)) => { let path = &res.ty; if res.is_inline { tokens.extend(quote_spanned! {path.span()=> @@ -278,111 +414,30 @@ impl ToTokensDiagnostics for ResponseTuple<'_> { }); } } - ResponseTupleInner::Value(val) => { - let description = &val.description; + Some(ResponseTupleInner::Value(value)) => { + let description = &value.description; tokens.extend(quote! { utoipa::openapi::ResponseBuilder::new().description(#description) }); - let create_content = |path_type: &PathType, - example: &Option, - examples: &Option>| - -> Result { - let content_schema = match path_type { - PathType::Ref(ref_type) => quote! { - utoipa::openapi::schema::Ref::new(#ref_type) - } - .to_token_stream(), - PathType::MediaType(ref path_type) => { - let type_tree = path_type.as_type_tree()?; - - ComponentSchema::new(crate::component::ComponentSchemaProps { - type_tree: &type_tree, - features: vec![Inline::from(path_type.is_inline).into()], - description: None, - container: &Container { - generics: &Generics::default(), - }, - })? - .to_token_stream() - } - PathType::InlineSchema(schema, _) => schema.to_token_stream(), - }; - - let mut content = quote! { utoipa::openapi::ContentBuilder::new().schema(Some(#content_schema)) }; - - if let Some(ref example) = example { - content.extend(quote! { - .example(Some(#example)) - }) - } - if let Some(ref examples) = examples { - let examples = examples - .iter() - .map(|example| { - let name = &example.name; - quote!((#name, #example)) - }) - .collect::>(); - content.extend(quote!( - .examples_from_iter(#examples) - )) - } - - Ok(quote! { - #content.build() - }) - }; + for media_type in value.content.iter().filter(|media_type| { + !matches!(media_type.schema, Schema::Default(DefaultSchema::None)) + }) { + let default_content_type = media_type.schema.get_default_content_type()?; - if let Some(response_type) = &val.response_type { - let content = create_content(response_type, &val.example, &val.examples)?; + let content_type_tokens = media_type + .content_type + .as_ref() + .map(|content_type| content_type.to_token_stream()) + .unwrap_or_else(|| default_content_type.to_token_stream()); + let content_tokens = media_type.try_to_token_stream()?; - if let Some(content_types) = val.content_type.as_ref() { - content_types.iter().for_each(|content_type| { - tokens.extend(quote! { - .content(#content_type, #content) - }) - }) - } else { - match response_type { - PathType::Ref(_) => { - tokens.extend(quote! { - .content("application/json", #content) - }); - } - PathType::MediaType(path_type) => { - let type_tree = path_type.as_type_tree()?; - let default_type = type_tree.get_default_content_type(); - tokens.extend(quote! { - .content(#default_type, #content) - }) - } - PathType::InlineSchema(_, ty) => { - let type_tree = TypeTree::from_type(ty)?; - let default_type = type_tree.get_default_content_type(); - tokens.extend(quote! { - .content(#default_type, #content) - }) - } - } - } - } - - val.content - .iter() - .map(|Content(content_type, body, example, examples)| { - match create_content(body, example, examples) { - Ok(content) => Ok((Cow::Borrowed(&**content_type), content)), - Err(diagnostics) => Err(diagnostics), - } - }) - .collect::, Diagnostics>>()? - .into_iter() - .for_each(|(content_type, content)| { - tokens.extend(quote! { .content(#content_type, #content) }) + tokens.extend(quote! { + .content(#content_type_tokens, #content_tokens) }); + } - for header in &val.headers { + for header in &value.headers { let name = &header.name; let header = crate::as_tokens_or_diagnostics!(header); tokens.extend(quote! { @@ -390,7 +445,7 @@ impl ToTokensDiagnostics for ResponseTuple<'_> { }) } - for LinkTuple(name, link) in &val.links { + for LinkTuple(name, link) in &value.links { tokens.extend(quote! { .link(#name, #link) }) @@ -398,6 +453,9 @@ impl ToTokensDiagnostics for ResponseTuple<'_> { tokens.extend(quote! { .build() }); } + None => tokens.extend(quote! { + utoipa::openapi::ResponseBuilder::new().description("") + }), } Ok(()) @@ -421,7 +479,7 @@ trait DeriveResponseValue: Parse { #[derive(Default)] #[cfg_attr(feature = "debug", derive(Debug))] struct DeriveToResponseValue { - content_type: Option>, + content_type: Option, headers: Vec
, description: parse_utils::LitStrOrExpr, example: Option<(AnyValue, Ident)>, @@ -463,10 +521,11 @@ impl Parse for DeriveToResponseValue { response.description = parse::description(input)?; } "content_type" => { - response.content_type = Some(parse::content_type(input)?); + response.content_type = + Some(parse_utils::parse_next_literal_str_or_expr(input)?); } "headers" => { - response.headers = headers(input)?; + response.headers = header::headers(input)?; } "example" => { response.example = Some((parse::example(input)?, ident)); @@ -494,7 +553,7 @@ impl Parse for DeriveToResponseValue { #[derive(Default)] struct DeriveIntoResponsesValue { status: ResponseStatus, - content_type: Option>, + content_type: Option, headers: Vec
, description: parse_utils::LitStrOrExpr, example: Option<(AnyValue, Ident)>, @@ -558,10 +617,11 @@ impl Parse for DeriveIntoResponsesValue { response.description = parse::description(input)?; } "content_type" => { - response.content_type = Some(parse::content_type(input)?); + response.content_type = + Some(parse_utils::parse_next_literal_str_or_expr(input)?); } "headers" => { - response.headers = headers(input)?; + response.headers = header::headers(input)?; } "example" => { response.example = Some((parse::example(input)?, ident)); @@ -665,63 +725,6 @@ impl ToTokens for ResponseStatus { } } -// content( -// ("application/json" = Response, example = "...", examples(..., ...)), -// ("application/json2" = Response2, example = "...", examples("...", "...")) -// ) -#[cfg_attr(feature = "debug", derive(Debug))] -struct Content<'c>( - String, - PathType<'c>, - Option, - Option>, -); - -impl Parse for Content<'_> { - fn parse(input: ParseStream) -> syn::Result { - let content; - parenthesized!(content in input); - - let content_type = content.parse::()?; - content.parse::()?; - let body = content.parse()?; - content.parse::>()?; - let mut example = None::; - let mut examples = None::>; - - while !content.is_empty() { - let ident = content.parse::()?; - let attribute_name = &*ident.to_string(); - match attribute_name { - "example" => { - example = Some(parse_utils::parse_next(&content, || { - AnyValue::parse_json(&content) - })?) - } - "examples" => { - examples = Some(parse_utils::parse_comma_separated_within_parenthesis( - &content, - )?) - } - _ => { - return Err(Error::new( - ident.span(), - format!( - "unexpected attribute: {ident}, expected one of: example, examples" - ), - )); - } - } - - if !content.is_empty() { - content.parse::()?; - } - } - - Ok(Content(content_type.value(), body, example, examples)) - } -} - pub struct Responses<'a>(pub &'a [Response<'a>]); impl ToTokensDiagnostics for Responses<'_> { @@ -759,159 +762,3 @@ impl ToTokensDiagnostics for Responses<'_> { Ok(()) } } - -/// Parsed representation of response header defined in `#[utoipa::path(..)]` attribute. -/// -/// Supported configuration format is `("x-my-header-name" = type, description = "optional description of header")`. -/// The `= type` and the `description = ".."` are optional configurations thus so the same configuration -/// could be written as follows: `("x-my-header-name")`. -/// -/// The `type` can be any typical type supported as a header argument such as `String, i32, u64, bool` etc. -/// and if not provided it will default to `String`. -/// -/// # Examples -/// -/// Example of 200 success response which does return nothing back in response body, but returns a -/// new csrf token in response headers. -/// ```text -/// #[utoipa::path( -/// ... -/// responses = [ -/// (status = 200, description = "success response", -/// headers = [ -/// ("xrfs-token" = String, description = "New csrf token sent back in response header") -/// ] -/// ), -/// ] -/// )] -/// ``` -/// -/// Example with default values. -/// ```text -/// #[utoipa::path( -/// ... -/// responses = [ -/// (status = 200, description = "success response", -/// headers = [ -/// ("xrfs-token") -/// ] -/// ), -/// ] -/// )] -/// ``` -/// -/// Example with multiple headers with default values. -/// ```text -/// #[utoipa::path( -/// ... -/// responses = [ -/// (status = 200, description = "success response", -/// headers = [ -/// ("xrfs-token"), -/// ("another-header"), -/// ] -/// ), -/// ] -/// )] -/// ``` -#[derive(Default)] -#[cfg_attr(feature = "debug", derive(Debug))] -struct Header { - name: String, - value_type: Option>, - description: Option, -} - -impl Parse for Header { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let mut header = Header { - name: input.parse::()?.value(), - ..Default::default() - }; - - if input.peek(Token![=]) { - input.parse::()?; - - header.value_type = Some(input.parse().map_err(|error| { - Error::new( - error.span(), - format!("unexpected token, expected type such as String, {error}"), - ) - })?); - } - - if !input.is_empty() { - input.parse::()?; - } - - if input.peek(syn::Ident) { - input - .parse::() - .map_err(|error| { - Error::new( - error.span(), - format!("unexpected attribute, expected: description, {error}"), - ) - }) - .and_then(|ident| { - if ident != "description" { - return Err(Error::new( - ident.span(), - "unexpected attribute, expected: description", - )); - } - Ok(ident) - })?; - input.parse::()?; - header.description = Some(input.parse::()?.value()); - } - - Ok(header) - } -} - -impl ToTokensDiagnostics for Header { - fn to_tokens(&self, tokens: &mut TokenStream2) -> Result<(), Diagnostics> { - if let Some(header_type) = &self.value_type { - // header property with custom type - let type_tree = header_type.as_type_tree()?; - - let media_type_schema = ComponentSchema::new(crate::component::ComponentSchemaProps { - type_tree: &type_tree, - features: vec![Inline::from(header_type.is_inline).into()], - description: None, - container: &Container { - generics: &Generics::default(), - }, - })? - .to_token_stream(); - - tokens.extend(quote! { - utoipa::openapi::HeaderBuilder::new().schema(#media_type_schema) - }) - } else { - // default header (string type) - tokens.extend(quote! { - Into::::into(utoipa::openapi::Header::default()) - }) - }; - - if let Some(ref description) = self.description { - tokens.extend(quote! { - .description(Some(#description)) - }) - } - - tokens.extend(quote! { .build() }); - - Ok(()) - } -} - -#[inline] -fn headers(input: ParseStream) -> syn::Result> { - let headers; - syn::parenthesized!(headers in input); - - parse_utils::parse_groups_collect(&headers) -} diff --git a/utoipa-gen/src/path/response/derive.rs b/utoipa-gen/src/path/response/derive.rs index 022584f4..5a04b8cd 100644 --- a/utoipa-gen/src/path/response/derive.rs +++ b/utoipa-gen/src/path/response/derive.rs @@ -14,14 +14,14 @@ use syn::{ use crate::component::schema::{EnumSchema, NamedStructSchema, Root}; use crate::doc_comment::CommentAttributes; -use crate::path::{InlineType, PathType}; +use crate::path::media_type::{DefaultSchema, MediaTypeAttr, ParsedType, Schema}; use crate::{ as_tokens_or_diagnostics, parse_utils, Array, Diagnostics, OptionExt, ToTokensDiagnostics, }; use super::{ - Content, DeriveIntoResponsesValue, DeriveResponseValue, DeriveResponsesAttributes, - DeriveToResponseValue, ResponseTuple, ResponseTupleInner, ResponseValue, + DeriveIntoResponsesValue, DeriveResponseValue, DeriveToResponseValue, ResponseTuple, + ResponseTupleInner, ResponseValue, }; pub struct ToResponse<'r> { @@ -281,23 +281,26 @@ impl<'u> UnnamedStructResponse<'u> { (false, false) => Self( ( status_code, - ResponseValue::from_derive_into_responses_value(derive_value, description) - .response_type(Some(PathType::MediaType(InlineType { + ResponseValue::from_derive_into_responses_value( + derive_value, + ParsedType { ty: Cow::Borrowed(ty), is_inline, - }))), + }, + description, + ), ) .into(), ), (true, false) => Self(ResponseTuple { - inner: Some(ResponseTupleInner::Ref(InlineType { + inner: Some(ResponseTupleInner::Ref(ParsedType { ty: Cow::Borrowed(ty), is_inline: false, })), status_code, }), (false, true) => Self(ResponseTuple { - inner: Some(ResponseTupleInner::Ref(InlineType { + inner: Some(ResponseTupleInner::Ref(ParsedType { ty: Cow::Borrowed(ty), is_inline: true, })), @@ -358,11 +361,14 @@ impl NamedStructResponse<'_> { Ok(Self( ( status_code, - ResponseValue::from_derive_into_responses_value(derive_value, description) - .response_type(Some(PathType::InlineSchema( - inline_schema.to_token_stream(), - ty, - ))), + ResponseValue::from_derive_into_responses_value( + derive_value, + Schema::Default(DefaultSchema::Raw { + tokens: inline_schema.to_token_stream(), + ty: Cow::Owned(ty), + }), + description, + ), ) .into(), )) @@ -393,7 +399,11 @@ impl UnitStructResponse<'_> { Ok(Self( ( status_code, - ResponseValue::from_derive_into_responses_value(derive_value, description), + ResponseValue::from_derive_into_responses_value( + derive_value, + Schema::Default(DefaultSchema::None), + description, + ), ) .into(), )) @@ -437,13 +447,26 @@ impl<'p> ToResponseNamedStructResponse<'p> { fields, Vec::new(), )?; - let response_type = PathType::InlineSchema(inline_schema.to_token_stream(), ty); - let mut response_value: ResponseValue = ResponseValue::from(DeriveResponsesAttributes { - derive_value, - description, - }); - response_value.response_type = Some(response_type); + let response_value = if let Some(derive_value) = derive_value { + ResponseValue::from_derive_to_response_value( + derive_value, + Schema::Default(DefaultSchema::Raw { + tokens: inline_schema.to_token_stream(), + ty: Cow::Owned(ty), + }), + description, + ) + } else { + ResponseValue::from_schema( + Schema::Default(DefaultSchema::Raw { + tokens: inline_schema.to_token_stream(), + ty: Cow::Owned(ty), + }), + description, + ) + }; + // response_value.response_type = Some(response_type); Ok(Self(response_value.into())) } @@ -483,20 +506,31 @@ impl<'u> ToResponseUnnamedStructResponse<'u> { let is_inline = inner_attributes .iter() .any(|attribute| attribute.path().get_ident().unwrap() == "to_schema"); - let mut response_value: ResponseValue = ResponseValue::from(DeriveResponsesAttributes { - description, - derive_value, - }); - response_value.response_type = Some(PathType::MediaType(InlineType { - ty: Cow::Borrowed(ty), - is_inline, - })); + let response_value = if let Some(derive_value) = derive_value { + ResponseValue::from_derive_to_response_value( + derive_value, + ParsedType { + ty: Cow::Borrowed(ty), + is_inline, + }, + description, + ) + } else { + ResponseValue::from_schema( + ParsedType { + ty: Cow::Borrowed(ty), + is_inline, + }, + description, + ) + }; Ok(Self(response_value.into())) } } +#[cfg_attr(feature = "debug", derive(Debug))] struct VariantAttributes<'r> { type_and_content: Option<(&'r Type, String)>, derive_value: Option, @@ -530,13 +564,13 @@ impl<'r> EnumResponse<'r> { parse_utils::LitStrOrExpr::LitStr(LitStr::new(&s, Span::call_site())) }; - let variants_content = variants + let content = variants .into_iter() .map(Self::parse_variant_attributes) .collect::, Diagnostics>>()? .into_iter() - .filter_map(Self::to_content); - let content: Punctuated = Punctuated::from_iter(variants_content); + .filter(|variant| variant.type_and_content.is_some()) + .collect::>(); let derive_value = DeriveToResponseValue::from_attributes(attributes)?; if let Some(derive_value) = &derive_value { @@ -557,27 +591,88 @@ impl<'r> EnumResponse<'r> { } } - let mut response_value: ResponseValue = From::from(DeriveResponsesAttributes { - derive_value, - description, - }); - response_value.response_type = if content.is_empty() { - let generics = Generics::default(); - let parent = &Root { - ident, - attributes, - generics: &generics, + let generics = Generics::default(); + let root = &Root { + ident, + attributes, + generics: &generics, + }; + let inline_schema = EnumSchema::new(root, variants)?; + + let response_value = if content.is_empty() { + if let Some(derive_value) = derive_value { + ResponseValue::from_derive_to_response_value( + derive_value, + Schema::Default(DefaultSchema::None), + description, + ) + } else { + ResponseValue::from_schema( + Schema::Default(DefaultSchema::Raw { + tokens: inline_schema.to_token_stream(), + ty: Cow::Owned(ty), + }), + description, + ) + } + } else { + let content = content + .into_iter() + .map( + |VariantAttributes { + type_and_content, + derive_value, + is_inline, + }| { + let (content_type, schema) = if let Some((ty, content)) = type_and_content { + ( + Some(content.into()), + Some(Schema::Default(DefaultSchema::TypePath(ParsedType { + ty: Cow::Borrowed(ty), + is_inline, + }))), + ) + } else { + (None, None) + }; + let (example, examples) = if let Some(derive_value) = derive_value { + ( + derive_value.example.map(|(example, _)| example), + derive_value.examples.map(|(examples, _)| examples), + ) + } else { + (None, None) + }; + + MediaTypeAttr { + content_type, + schema: schema.unwrap_or_else(|| Schema::Default(DefaultSchema::None)), + example, + examples: examples.unwrap_or_default(), + } + }, + ) + .collect::>(); + + let mut response = if let Some(derive_value) = derive_value { + ResponseValue::from_derive_to_response_value( + derive_value, + Schema::Default(DefaultSchema::None), + description, + ) + } else { + ResponseValue::from_schema( + Schema::Default(DefaultSchema::Raw { + tokens: inline_schema.to_token_stream(), + ty: Cow::Owned(ty), + }), + description, + ) }; - let inline_schema = EnumSchema::new(parent, variants)?; + response.content = content; - Some(PathType::InlineSchema( - inline_schema.into_token_stream(), - ty, - )) - } else { - None + response }; - response_value.content = content; Ok(Self(response_value.into())) } @@ -627,35 +722,6 @@ impl<'r> EnumResponse<'r> { is_inline, }) } - - fn to_content( - VariantAttributes { - type_and_content: field_and_content, - mut derive_value, - is_inline, - }: VariantAttributes, - ) -> Option> { - let (example, examples) = if let Some(variant_derive) = &mut derive_value { - ( - mem::take(&mut variant_derive.example), - mem::take(&mut variant_derive.examples), - ) - } else { - (None, None) - }; - - field_and_content.map(|(ty, content_type)| { - Content( - content_type, - PathType::MediaType(InlineType { - ty: Cow::Borrowed(ty), - is_inline, - }), - example.map(|(example, _)| example), - examples.map(|(examples, _)| examples), - ) - }) - } } struct ToResponseUnitStructResponse<'u>(ResponseTuple<'u>); @@ -676,10 +742,19 @@ impl ToResponseUnitStructResponse<'_> { let s = CommentAttributes::from_attributes(attributes).as_formatted_string(); parse_utils::LitStrOrExpr::LitStr(LitStr::new(&s, Span::call_site())) }; - let response_value: ResponseValue = ResponseValue::from(DeriveResponsesAttributes { - derive_value, - description, - }); + + let response_value = if let Some(derive_value) = derive_value { + ResponseValue::from_derive_to_response_value( + derive_value, + Schema::Default(DefaultSchema::None), + description, + ) + } else { + ResponseValue { + description, + ..Default::default() + } + }; Ok(Self(response_value.into())) } diff --git a/utoipa-gen/src/path/response/header.rs b/utoipa-gen/src/path/response/header.rs new file mode 100644 index 00000000..50b26c0e --- /dev/null +++ b/utoipa-gen/src/path/response/header.rs @@ -0,0 +1,165 @@ +use proc_macro2::TokenStream; +use quote::{quote, ToTokens}; +use syn::parse::{Parse, ParseStream}; +use syn::{Error, Generics, Ident, LitStr, Token}; + +use crate::component::features::attributes::Inline; +use crate::component::{ComponentSchema, Container, TypeTree}; +use crate::path::media_type::ParsedType; +use crate::{parse_utils, Diagnostics, ToTokensDiagnostics}; + +/// Parsed representation of response header defined in `#[utoipa::path(..)]` attribute. +/// +/// Supported configuration format is `("x-my-header-name" = type, description = "optional description of header")`. +/// The `= type` and the `description = ".."` are optional configurations thus so the same configuration +/// could be written as follows: `("x-my-header-name")`. +/// +/// The `type` can be any typical type supported as a header argument such as `String, i32, u64, bool` etc. +/// and if not provided it will default to `String`. +/// +/// # Examples +/// +/// Example of 200 success response which does return nothing back in response body, but returns a +/// new csrf token in response headers. +/// ```text +/// #[utoipa::path( +/// ... +/// responses = [ +/// (status = 200, description = "success response", +/// headers = [ +/// ("xrfs-token" = String, description = "New csrf token sent back in response header") +/// ] +/// ), +/// ] +/// )] +/// ``` +/// +/// Example with default values. +/// ```text +/// #[utoipa::path( +/// ... +/// responses = [ +/// (status = 200, description = "success response", +/// headers = [ +/// ("xrfs-token") +/// ] +/// ), +/// ] +/// )] +/// ``` +/// +/// Example with multiple headers with default values. +/// ```text +/// #[utoipa::path( +/// ... +/// responses = [ +/// (status = 200, description = "success response", +/// headers = [ +/// ("xrfs-token"), +/// ("another-header"), +/// ] +/// ), +/// ] +/// )] +/// ``` +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct Header { + pub name: String, + value_type: Option>, + description: Option, +} + +impl Parse for Header { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let mut header = Header { + name: input.parse::()?.value(), + ..Default::default() + }; + + if input.peek(Token![=]) { + input.parse::()?; + + header.value_type = Some(input.parse().map_err(|error| { + Error::new( + error.span(), + format!("unexpected token, expected type such as String, {error}"), + ) + })?); + } + + if !input.is_empty() { + input.parse::()?; + } + + if input.peek(syn::Ident) { + input + .parse::() + .map_err(|error| { + Error::new( + error.span(), + format!("unexpected attribute, expected: description, {error}"), + ) + }) + .and_then(|ident| { + if ident != "description" { + return Err(Error::new( + ident.span(), + "unexpected attribute, expected: description", + )); + } + Ok(ident) + })?; + input.parse::()?; + header.description = Some(input.parse::()?.value()); + } + + Ok(header) + } +} + +impl ToTokensDiagnostics for Header { + fn to_tokens(&self, tokens: &mut TokenStream) -> Result<(), Diagnostics> { + if let Some(header_type) = &self.value_type { + // header property with custom type + let type_tree = TypeTree::from_type(header_type.ty.as_ref())?; + + let media_type_schema = ComponentSchema::new(crate::component::ComponentSchemaProps { + type_tree: &type_tree, + features: vec![Inline::from(header_type.is_inline).into()], + description: None, + container: &Container { + generics: &Generics::default(), + }, + })? + .to_token_stream(); + + tokens.extend(quote! { + utoipa::openapi::HeaderBuilder::new().schema(#media_type_schema) + }) + } else { + // default header (string type) + tokens.extend(quote! { + Into::::into(utoipa::openapi::Header::default()) + }) + }; + + if let Some(ref description) = self.description { + tokens.extend(quote! { + .description(Some(#description)) + }) + } + + tokens.extend(quote! { .build() }); + + Ok(()) + } +} + +#[inline] +pub fn headers(input: ParseStream) -> syn::Result> { + let headers; + syn::parenthesized!(headers in input); + + parse_utils::parse_groups_collect(&headers) +} diff --git a/utoipa-gen/tests/path_response_derive_test.rs b/utoipa-gen/tests/path_response_derive_test.rs index f9cd10b6..9b65bc11 100644 --- a/utoipa-gen/tests/path_response_derive_test.rs +++ b/utoipa-gen/tests/path_response_derive_test.rs @@ -1,7 +1,7 @@ use assert_json_diff::assert_json_eq; use serde_json::{json, Value}; use utoipa::openapi::{RefOr, Response}; -use utoipa::{OpenApi, ToResponse}; +use utoipa::{OpenApi, Path, ToResponse}; mod common; @@ -269,33 +269,12 @@ fn derive_response_with_json_example_success() { } } -#[test] -fn derive_response_multiple_content_types() { - test_fn! { - module: response_multiple_content_types, - responses: ( - (status = 200, description = "success", body = Foo, content_type = ["text/xml", "application/json"]) - ) - } - - let doc = api_doc!(module: response_multiple_content_types); - - assert_value! {doc=> - "responses.200.description" = r#""success""#, "Response description" - "responses.200.content.application~1json.schema.$ref" = r###""#/components/schemas/Foo""###, "Response content ref" - "responses.200.content.text~1xml.schema.$ref" = r###""#/components/schemas/Foo""###, "Response content ref" - "responses.200.content.application~1json.example" = r###"null"###, "Response content example" - "responses.200.content.text~1xml.example" = r###"null"###, "Response content example" - "responses.200.headers" = r#"null"#, "Response headers" - } -} - #[test] fn derive_response_body_inline_schema_component() { test_fn! { module: response_body_inline_schema, responses: ( - (status = 200, description = "success", body = inline(Foo), content_type = ["application/json"]) + (status = 200, description = "success", body = inline(Foo), content_type = "application/json") ) } @@ -333,7 +312,7 @@ fn derive_response_body_inline_schema_component() { } #[test] -fn derive_path_with_multiple_responses_via_content_attribute() { +fn derive_path_with_multiple_responses_via_content_attribute_auto_collect_responses() { #[derive(serde::Serialize, utoipa::ToSchema)] #[allow(unused)] struct User { @@ -351,8 +330,8 @@ fn derive_path_with_multiple_responses_via_content_attribute() { path = "/foo", responses( (status = 200, content( - ("application/vnd.user.v1+json" = User, example = json!(User {id: "id".to_string()})), - ("application/vnd.user.v2+json" = User2, example = json!(User2 {id: 2})) + (User = "application/vnd.user.v1+json" , example = json!(User {id: "id".to_string()})), + (User2 = "application/vnd.user.v2+json", example = json!(User2 {id: 2})) ) ) ) @@ -361,11 +340,39 @@ fn derive_path_with_multiple_responses_via_content_attribute() { fn get_item() {} #[derive(utoipa::OpenApi)] - #[openapi(paths(get_item), components(schemas(User, User2)))] + #[openapi(paths(get_item))] struct ApiDoc; let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); let responses = doc.pointer("/paths/~1foo/get/responses").unwrap(); + let schemas = doc + .pointer("/components/schemas") + .expect("doc must have schemas"); + + assert_json_eq!( + schemas, + json!({ + "User": { + "properties": { + "id": { + "type": "string", + } + }, + "required": ["id"], + "type": "object", + }, + "User2": { + "properties": { + "id": { + "type": "integer", + "format": "int32", + } + }, + "required": ["id"], + "type": "object", + } + }) + ); assert_json_eq!( responses, @@ -397,7 +404,7 @@ fn derive_path_with_multiple_responses_via_content_attribute() { } #[test] -fn derive_path_with_multiple_examples() { +fn derive_path_with_multiple_examples_auto_collect_schemas() { #[derive(serde::Serialize, utoipa::ToSchema)] #[allow(unused)] struct User { @@ -421,12 +428,29 @@ fn derive_path_with_multiple_examples() { fn get_item() {} #[derive(utoipa::OpenApi)] - #[openapi(paths(get_item), components(schemas(User)))] + #[openapi(paths(get_item))] struct ApiDoc; let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); let responses = doc.pointer("/paths/~1foo/get/responses").unwrap(); + let schemas = doc + .pointer("/components/schemas") + .expect("doc must have schemas"); + assert_json_eq!( + schemas, + json!({ + "User": { + "properties": { + "name": { + "type": "string", + } + }, + "required": ["name"], + "type": "object", + } + }) + ); assert_json_eq!( responses, json!({ @@ -479,13 +503,13 @@ fn derive_path_with_multiple_responses_with_multiple_examples() { path = "/foo", responses( (status = 200, content( - ("application/vnd.user.v1+json" = User, + (User = "application/vnd.user.v1+json", examples( ("StringUser" = (value = json!({"id": "1"}))), ("StringUser2" = (value = json!({"id": "2"}))) ), ), - ("application/vnd.user.v2+json" = User2, + (User2 = "application/vnd.user.v2+json", examples( ("IntUser" = (value = json!({"id": 1}))), ("IntUser2" = (value = json!({"id": 2}))) @@ -636,3 +660,26 @@ fn path_response_with_inline_ref_type() { }) ) } + +#[test] +fn path_response_default_no_value_nor_ref() { + /// Post some secret inner handler + #[utoipa::path(post, path = "/api/inner/secret", responses((status = OK)))] + pub async fn post_secret() {} + + let operation = __path_post_secret::operation(); + let value = serde_json::to_value(operation).expect("operation is JSON serializable"); + + assert_json_eq!( + value, + json!({ + "operationId": "post_secret", + "responses": { + "200": { + "description": "" + } + }, + "summary": "Post some secret inner handler" + }) + ) +} diff --git a/utoipa-gen/tests/response_derive_test.rs b/utoipa-gen/tests/response_derive_test.rs index a566b59c..4780b7d1 100644 --- a/utoipa-gen/tests/response_derive_test.rs +++ b/utoipa-gen/tests/response_derive_test.rs @@ -204,50 +204,6 @@ fn derive_response_with_attributes() { ) } -#[test] -fn derive_response_with_multiple_content_types() { - #[derive(ToSchema, ToResponse)] - #[response(content_type = ["application/json", "text/xml"] )] - #[allow(unused)] - struct Person { - name: String, - } - let (name, v) = ::response(); - let value = serde_json::to_value(v).unwrap(); - - assert_eq!("Person", name); - assert_json_eq!( - value, - json!({ - "content": { - "application/json": { - "schema": { - "properties": { - "name": { - "type": "string" - } - }, - "type": "object", - "required": ["name"] - } - }, - "text/xml": { - "schema": { - "properties": { - "name": { - "type": "string" - } - }, - "type": "object", - "required": ["name"] - } - } - }, - "description": "" - }) - ) -} - #[test] fn derive_response_multiple_examples() { #[derive(ToSchema, ToResponse)]