From 1dfcc44be410a0d7b18ce366b155ff1b66c2fb7e Mon Sep 17 00:00:00 2001 From: Troy Benson Date: Mon, 26 Feb 2024 22:23:51 +0000 Subject: [PATCH 1/3] feat: allow for multiple req body content_type This change adds the ability to specify multiple accepted request body content-types. --- utoipa-gen/src/path.rs | 43 +++++++++++++ utoipa-gen/src/path/request_body.rs | 30 ++++++---- utoipa-gen/src/path/response.rs | 63 +++----------------- utoipa-gen/tests/request_body_derive_test.rs | 37 ++++++++++++ 4 files changed, 108 insertions(+), 65 deletions(-) diff --git a/utoipa-gen/src/path.rs b/utoipa-gen/src/path.rs index 054dd57b..b6aa10ba 100644 --- a/utoipa-gen/src/path.rs +++ b/utoipa-gen/src/path.rs @@ -654,3 +654,46 @@ impl PathTypeTree for TypeTree<'_> { } } } + +mod parse { + use syn::parse::ParseStream; + use syn::punctuated::Punctuated; + use syn::token::{Bracket, Comma}; + use syn::{bracketed, Result}; + + use crate::path::example::Example; + use crate::{parse_utils, AnyValue}; + + #[inline] + pub(super) fn description(input: ParseStream) -> Result { + 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)) + } + + #[inline] + pub(super) fn examples(input: ParseStream) -> Result> { + parse_utils::parse_punctuated_within_parenthesis(input) + } +} diff --git a/utoipa-gen/src/path/request_body.rs b/utoipa-gen/src/path/request_body.rs index 22e044b4..eae5df58 100644 --- a/utoipa-gen/src/path/request_body.rs +++ b/utoipa-gen/src/path/request_body.rs @@ -9,7 +9,7 @@ use crate::component::ComponentSchema; use crate::{impl_to_tokens_diagnostics, parse_utils, AnyValue, Array, Diagnostics, Required}; use super::example::Example; -use super::{PathType, PathTypeTree}; +use super::{PathType, PathTypeTree, parse}; #[cfg_attr(feature = "debug", derive(Debug))] pub enum RequestBody<'r> { @@ -77,7 +77,7 @@ impl ToTokens for RequestBody<'_> { #[cfg_attr(feature = "debug", derive(Debug))] pub struct RequestBodyAttr<'r> { content: Option>, - content_type: Option, + content_type: Vec, description: Option, example: Option, examples: Option>, @@ -114,8 +114,7 @@ impl Parse for RequestBodyAttr<'_> { ); } "content_type" => { - request_body_attr.content_type = - Some(parse_utils::parse_next_literal_str_or_expr(&group)?) + request_body_attr.content_type = parse::content_type(&group)?; } "description" => { request_body_attr.description = @@ -210,18 +209,27 @@ impl RequestBodyAttr<'_> { PathType::MediaType(body_type) => { let type_tree = body_type.as_type_tree()?; let required: Required = (!type_tree.is_option()).into(); - let content_type = match &self.content_type { - Some(content_type) => content_type.to_token_stream(), - None => { - let content_type = type_tree.get_default_content_type(); - quote!(#content_type) - } + let content_types = if self.content_type.is_empty() { + let content_type = type_tree.get_default_content_type(); + vec![ + quote!(#content_type), + ] + } else { + self.content_type.iter().map(|content_type| { + content_type.to_token_stream() + }).collect() }; + tokens.extend(quote! { utoipa::openapi::request_body::RequestBodyBuilder::new() - .content(#content_type, #content.build()) .required(Some(#required)) }); + + for content_type in content_types { + tokens.extend(quote! { + .content(#content_type, #content.build()) + }); + } } PathType::InlineSchema(_, _) => { unreachable!("PathType::InlineSchema is not implemented for RequestBodyAttr"); diff --git a/utoipa-gen/src/path/response.rs b/utoipa-gen/src/path/response.rs index b013d754..10a889f7 100644 --- a/utoipa-gen/src/path/response.rs +++ b/utoipa-gen/src/path/response.rs @@ -18,7 +18,7 @@ use crate::{ impl_to_tokens_diagnostics, parse_utils, AnyValue, Array, Diagnostics, }; -use super::{example::Example, status::STATUS_CODES, InlineType, PathType, PathTypeTree}; +use super::{example::Example, status::STATUS_CODES, InlineType, PathType, PathTypeTree, parse}; pub mod derive; @@ -117,7 +117,7 @@ impl Parse for ResponseTuple<'_> { Some(parse::content_type(input)?); } "headers" => { - response.as_value(input.span())?.headers = parse::headers(input)?; + response.as_value(input.span())?.headers = headers(input)?; } "example" => { response.as_value(input.span())?.example = Some(parse::example(input)?); @@ -458,7 +458,7 @@ impl Parse for DeriveToResponseValue { response.content_type = Some(parse::content_type(input)?); } "headers" => { - response.headers = parse::headers(input)?; + response.headers = headers(input)?; } "example" => { response.example = Some((parse::example(input)?, ident)); @@ -553,7 +553,7 @@ impl Parse for DeriveIntoResponsesValue { response.content_type = Some(parse::content_type(input)?); } "headers" => { - response.headers = parse::headers(input)?; + response.headers = headers(input)?; } "example" => { response.example = Some((parse::example(input)?, ident)); @@ -895,55 +895,10 @@ impl_to_tokens_diagnostics! { } } -mod parse { - use syn::parse::ParseStream; - use syn::punctuated::Punctuated; - use syn::token::{Bracket, Comma}; - use syn::{bracketed, parenthesized, Result}; +#[inline] +fn headers(input: ParseStream) -> syn::Result> { + let headers; + syn::parenthesized!(headers in input); - use crate::path::example::Example; - use crate::{parse_utils, AnyValue}; - - use super::Header; - - #[inline] - pub(super) fn description(input: ParseStream) -> Result { - 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 headers(input: ParseStream) -> Result> { - let headers; - parenthesized!(headers in input); - - parse_utils::parse_groups(&headers) - } - - #[inline] - pub(super) fn example(input: ParseStream) -> Result { - parse_utils::parse_next(input, || AnyValue::parse_lit_str_or_json(input)) - } - - #[inline] - pub(super) fn examples(input: ParseStream) -> Result> { - parse_utils::parse_punctuated_within_parenthesis(input) - } + parse_utils::parse_groups(&headers) } diff --git a/utoipa-gen/tests/request_body_derive_test.rs b/utoipa-gen/tests/request_body_derive_test.rs index 514d40c7..d4d484ee 100644 --- a/utoipa-gen/tests/request_body_derive_test.rs +++ b/utoipa-gen/tests/request_body_derive_test.rs @@ -193,6 +193,43 @@ fn derive_request_body_complex_success() { ); } +test_fn! { + module: derive_request_body_complex_multi_content_type, + body: (content = Foo, description = "Create new Foo", content_type = ["text/xml", "application/json"]) +} + +#[test] +fn derive_request_body_complex_multi_content_type_success() { + #[derive(OpenApi, Default)] + #[openapi(paths(derive_request_body_complex_multi_content_type::post_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(&ApiDoc::openapi()).unwrap(); + + let request_body: &Value = doc.pointer("/paths/~1foo/post/requestBody").unwrap(); + + assert_json_eq!( + request_body, + json!({ + "content": { + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Foo" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Foo" + } + } + }, + "description": "Create new Foo", + "required": true + }) + ); +} + + test_fn! { module: derive_request_body_complex_inline, body: (content = inline(Foo), description = "Create new Foo", content_type = "text/xml") From 06c1af2a1f81082a16762f3b8512918ec40d46fd Mon Sep 17 00:00:00 2001 From: Troy Benson Date: Mon, 26 Feb 2024 22:30:25 +0000 Subject: [PATCH 2/3] fix: use general parse functions over custom impl --- utoipa-gen/src/path/request_body.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/utoipa-gen/src/path/request_body.rs b/utoipa-gen/src/path/request_body.rs index eae5df58..fa562ae1 100644 --- a/utoipa-gen/src/path/request_body.rs +++ b/utoipa-gen/src/path/request_body.rs @@ -117,17 +117,13 @@ impl Parse for RequestBodyAttr<'_> { request_body_attr.content_type = parse::content_type(&group)?; } "description" => { - request_body_attr.description = - Some(parse_utils::parse_next_literal_str_or_expr(&group)?) + request_body_attr.description = Some(parse::description(&group)?); } "example" => { - request_body_attr.example = Some(parse_utils::parse_next(&group, || { - AnyValue::parse_any(&group) - })?) + request_body_attr.example = Some(parse::example(&group)?); } "examples" => { - request_body_attr.examples = - Some(parse_utils::parse_punctuated_within_parenthesis(&group)?) + request_body_attr.examples = Some(parse::examples(&group)?); } _ => return Err(Error::new(ident.span(), EXPECTED_ATTRIBUTE_MESSAGE)), } From d1b2f17fff99cf818f1a60062c3336821daeb3cd Mon Sep 17 00:00:00 2001 From: Juha Kukkonen Date: Tue, 14 May 2024 18:55:49 +0300 Subject: [PATCH 3/3] Format and add docs --- utoipa-gen/src/lib.rs | 19 ++++++++++--------- utoipa-gen/src/path/request_body.rs | 13 ++++++------- utoipa-gen/src/path/response.rs | 2 +- utoipa-gen/tests/request_body_derive_test.rs | 1 - 4 files changed, 17 insertions(+), 18 deletions(-) diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs index 8c0892ea..501530ab 100644 --- a/utoipa-gen/src/lib.rs +++ b/utoipa-gen/src/lib.rs @@ -362,7 +362,7 @@ use self::{ /// where super type declares common code for type aliases. /// /// In this example we have common `Status` type which accepts one generic type. It is then defined -/// with `#[aliases(...)]` that it is going to be used with [`String`](std::string::String) and [`i32`] values. +/// with `#[aliases(...)]` that it is going to be used with [`String`] and [`i32`] values. /// The generic argument could also be another [`ToSchema`][to_schema] as well. /// ```rust /// # use utoipa::{ToSchema, OpenApi}; @@ -728,11 +728,12 @@ pub fn derive_to_schema(input: TokenStream) -> TokenStream { /// /// * `description = "..."` Define the description for the request body object as str. /// -/// * `content_type = "..."` Can be used to override the default behavior of auto resolving the content type -/// from the `content` attribute. If defined the value should be valid content type such as -/// _`application/json`_. By default the content type is _`text/plain`_ for -/// [primitive Rust types][primitive], `application/octet-stream` for _`[u8]`_ and -/// _`application/json`_ for struct and complex enum types. +/// * `content_type = "..."` or `content_type = [...]` Can be used to override the default behavior +/// of auto resolving the content type from the `content` attribute. If defined the value should be valid +/// content type such as _`application/json`_ or a slice of content types within brackets e.g. +/// _`content_type = ["application/json", "text/html"]`_. By default the content type is _`text/plain`_ +/// for [primitive Rust types][primitive], `application/octet-stream` for _`[u8]`_ and _`application/json`_ +/// for struct and complex enum types. /// /// * `example = ...` Can be _`json!(...)`_. _`json!(...)`_ should be something that /// _`serde_json::json!`_ can parse as a _`serde_json::Value`_. @@ -766,7 +767,7 @@ 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 = "..." | content_type = [...]` Can be used to override the default behavior of auto resolving the content type +/// * `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 @@ -905,7 +906,7 @@ pub fn derive_to_schema(input: TokenStream) -> TokenStream { /// The given _`Type`_ can be any Rust type that is JSON parseable. It can be Option, Vec or Map etc. /// With _`inline(...)`_ the schema will be inlined instead of a referenced which is the default for /// [`ToSchema`][to_schema] types. Parameter type is placed after `name` with -/// equals sign E.g. _`"id" = String`_ +/// equals sign E.g. _`"id" = string`_ /// /// * `in` _**Must be placed after name or parameter_type**_. Define the place of the parameter. /// This must be one of the variants of [`openapi::path::ParameterIn`][in_enum]. @@ -1643,7 +1644,7 @@ pub fn openapi(input: TokenStream) -> TokenStream { /// /// Typically path parameters need to be defined within [`#[utoipa::path(...params(...))]`][path_params] section /// for the endpoint. But this trait eliminates the need for that when [`struct`][struct]s are used to define parameters. -/// Still [`std::primitive`] and [`String`](std::string::String) path parameters or [`tuple`] style path parameters need to be defined +/// Still [`std::primitive`] and [`String`] path parameters or [`tuple`] style path parameters need to be defined /// within `params(...)` section if description or other than default configuration need to be given. /// /// You can use the Rust's own `#[deprecated]` attribute on field to mark it as diff --git a/utoipa-gen/src/path/request_body.rs b/utoipa-gen/src/path/request_body.rs index fa562ae1..d77509b5 100644 --- a/utoipa-gen/src/path/request_body.rs +++ b/utoipa-gen/src/path/request_body.rs @@ -9,7 +9,7 @@ use crate::component::ComponentSchema; use crate::{impl_to_tokens_diagnostics, parse_utils, AnyValue, Array, Diagnostics, Required}; use super::example::Example; -use super::{PathType, PathTypeTree, parse}; +use super::{parse, PathType, PathTypeTree}; #[cfg_attr(feature = "debug", derive(Debug))] pub enum RequestBody<'r> { @@ -207,13 +207,12 @@ impl RequestBodyAttr<'_> { let required: Required = (!type_tree.is_option()).into(); let content_types = if self.content_type.is_empty() { let content_type = type_tree.get_default_content_type(); - vec![ - quote!(#content_type), - ] + vec![quote!(#content_type)] } else { - self.content_type.iter().map(|content_type| { - content_type.to_token_stream() - }).collect() + self.content_type + .iter() + .map(|content_type| content_type.to_token_stream()) + .collect() }; tokens.extend(quote! { diff --git a/utoipa-gen/src/path/response.rs b/utoipa-gen/src/path/response.rs index 10a889f7..69a97d42 100644 --- a/utoipa-gen/src/path/response.rs +++ b/utoipa-gen/src/path/response.rs @@ -18,7 +18,7 @@ use crate::{ impl_to_tokens_diagnostics, parse_utils, AnyValue, Array, Diagnostics, }; -use super::{example::Example, status::STATUS_CODES, InlineType, PathType, PathTypeTree, parse}; +use super::{example::Example, parse, status::STATUS_CODES, InlineType, PathType, PathTypeTree}; pub mod derive; diff --git a/utoipa-gen/tests/request_body_derive_test.rs b/utoipa-gen/tests/request_body_derive_test.rs index d4d484ee..c728593b 100644 --- a/utoipa-gen/tests/request_body_derive_test.rs +++ b/utoipa-gen/tests/request_body_derive_test.rs @@ -229,7 +229,6 @@ fn derive_request_body_complex_multi_content_type_success() { ); } - test_fn! { module: derive_request_body_complex_inline, body: (content = inline(Foo), description = "Create new Foo", content_type = "text/xml")