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)]