diff --git a/tests/path_derive.rs b/tests/path_derive.rs index 2402a8fa..8b520611 100644 --- a/tests/path_derive.rs +++ b/tests/path_derive.rs @@ -157,7 +157,7 @@ fn derive_path_with_defaults_success() { ], params = [ ("id" = u64, description = "Foo database id"), - ("since" = String, query, description = "Datetime since foo is updated") + ("since" = Option, query, description = "Datetime since foo is updated") ] )] #[allow(unused)] diff --git a/tests/path_parameter_derive_test.rs b/tests/path_parameter_derive_test.rs index 83ba6ebd..cec8c5db 100644 --- a/tests/path_parameter_derive_test.rs +++ b/tests/path_parameter_derive_test.rs @@ -1,7 +1,5 @@ #![cfg(not(feature = "actix_extras"))] -use std::println; -use serde_json::Value; use utoipa::OpenApi; mod common; @@ -17,7 +15,7 @@ mod derive_params_all_options { (status = 200, description = "success"), ], params = [ - ("id" = i32, path, required, deprecated, description = "Search foos by ids"), + ("id" = i32, path, deprecated, description = "Search foos by ids"), ] )] #[allow(unused)] @@ -150,10 +148,10 @@ mod mod_derive_parameters_all_types { ], params = [ ("id" = i32, path, description = "Foo id"), - ("since" = String, deprecated, required, query, description = "Datetime since"), - ("numbers" = [u64], query, description = "Foo numbers list"), - ("token" = String, header, deprecated, required, description = "Token of foo"), - ("cookieval" = String, cookie, deprecated, required, description = "Foo cookie"), + ("since" = String, deprecated, query, description = "Datetime since"), + ("numbers" = Option<[u64]>, query, description = "Foo numbers list"), + ("token" = String, header, deprecated, description = "Token of foo"), + ("cookieval" = String, cookie, deprecated, description = "Foo cookie"), ] )] #[allow(unused)] diff --git a/tests/request_body_derive_test.rs b/tests/request_body_derive_test.rs index 96b0026d..aea6d526 100644 --- a/tests/request_body_derive_test.rs +++ b/tests/request_body_derive_test.rs @@ -3,7 +3,7 @@ use utoipa::OpenApi; mod common; macro_rules! test_fn { - ( module: $name:ident, body: $body:expr ) => { + ( module: $name:ident, body: $($body:tt)* ) => { #[allow(unused)] mod $name { @@ -13,7 +13,7 @@ macro_rules! test_fn { #[utoipa::path( post, path = "/foo", - request_body = $body, + request_body = $($body)*, responses = [ (status = 200, description = "success response") ] @@ -39,7 +39,7 @@ fn derive_path_request_body_simple_success() { assert_value! {doc=> "paths./foo.post.requestBody.content.application/json.schema.$ref" = r###""#/components/schemas/Foo""###, "Request body content object type" "paths./foo.post.requestBody.content.text/plain" = r###"null"###, "Request body content object type not text/plain" - "paths./foo.post.requestBody.required" = r###"null"###, "Request body required" + "paths./foo.post.requestBody.required" = r###"true"###, "Request body required" "paths./foo.post.requestBody.description" = r###"null"###, "Request body description" } } @@ -62,11 +62,33 @@ fn derive_path_request_body_simple_array_success() { "paths./foo.post.requestBody.content.application/json.schema.items.$ref" = r###""#/components/schemas/Foo""###, "Request body content items object type" "paths./foo.post.requestBody.content.application/json.schema.type" = r###""array""###, "Request body content items type" "paths./foo.post.requestBody.content.text/plain" = r###"null"###, "Request body content object type not text/plain" - "paths./foo.post.requestBody.required" = r###"null"###, "Request body required" + "paths./foo.post.requestBody.required" = r###"true"###, "Request body required" "paths./foo.post.requestBody.description" = r###"null"###, "Request body description" } } +test_fn! { + module: derive_request_body_option_array, + body: Option<[Foo]> +} + +#[test] +fn derive_request_body_option_array_success() { + #[derive(OpenApi, Default)] + #[openapi(handlers = [derive_request_body_option_array::post_foo])] + struct ApiDoc; + + let doc = serde_json::to_value(&ApiDoc::openapi()).unwrap(); + + assert_value! {doc=> + "paths./foo.post.requestBody.content.application/json.schema.$ref" = r###"null"###, "Request body content object type" + "paths./foo.post.requestBody.content.application/json.schema.items.$ref" = r###""#/components/schemas/Foo""###, "Request body content items object type" + "paths./foo.post.requestBody.content.application/json.schema.type" = r###""array""###, "Request body content items type" + "paths./foo.post.requestBody.content.text/plain" = r###"null"###, "Request body content object type not text/plain" + "paths./foo.post.requestBody.required" = r###"false"###, "Request body required" + "paths./foo.post.requestBody.description" = r###"null"###, "Request body description" + } +} test_fn! { module: derive_request_body_primitive_simple, body: String @@ -85,7 +107,7 @@ fn derive_request_body_primitive_simple_success() { "paths./foo.post.requestBody.content.application/json.schema.items.$ref" = r###"null"###, "Request body content items object type" "paths./foo.post.requestBody.content.application/json.schema.type" = r###"null"###, "Request body content items type" "paths./foo.post.requestBody.content.text/plain.schema.type" = r###""string""###, "Request body content object type" - "paths./foo.post.requestBody.required" = r###"null"###, "Request body required" + "paths./foo.post.requestBody.required" = r###"true"###, "Request body required" "paths./foo.post.requestBody.description" = r###"null"###, "Request body description" } } @@ -108,14 +130,14 @@ fn derive_request_body_primitive_array_success() { "paths./foo.post.requestBody.content.text/plain.schema.type" = r###""array""###, "Request body content object item type" "paths./foo.post.requestBody.content.text/plain.schema.items.type" = r###""integer""###, "Request body content items object type" "paths./foo.post.requestBody.content.text/plain.schema.items.format" = r###""int64""###, "Request body content items object format" - "paths./foo.post.requestBody.required" = r###"null"###, "Request body required" + "paths./foo.post.requestBody.required" = r###"true"###, "Request body required" "paths./foo.post.requestBody.description" = r###"null"###, "Request body description" } } test_fn! { module: derive_request_body_complex, - body: (content = Foo, required, description = "Create new Foo", content_type = "text/xml") + body: (content = Foo, description = "Create new Foo", content_type = "text/xml") } #[test] @@ -138,7 +160,7 @@ fn derive_request_body_complex_success() { test_fn! { module: derive_request_body_complex_required_explisit, - body: (content = Foo, required = false, description = "Create new Foo", content_type = "text/xml") + body: (content = Option, description = "Create new Foo", content_type = "text/xml") } #[test] @@ -177,7 +199,7 @@ fn derive_request_body_complex_primitive_array_success() { "paths./foo.post.requestBody.content.text/plain.schema.type" = r###""array""###, "Request body content object item type" "paths./foo.post.requestBody.content.text/plain.schema.items.type" = r###""integer""###, "Request body content items object type" "paths./foo.post.requestBody.content.text/plain.schema.items.format" = r###""int32""###, "Request body content items object format" - "paths./foo.post.requestBody.required" = r###"null"###, "Request body required" + "paths./foo.post.requestBody.required" = r###"true"###, "Request body required" "paths./foo.post.requestBody.description" = r###""Create new foo references""###, "Request body description" } } diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs index b5ca6349..4b45720f 100644 --- a/utoipa-gen/src/lib.rs +++ b/utoipa-gen/src/lib.rs @@ -17,7 +17,8 @@ use syn::{ bracketed, parse::{Parse, ParseBuffer, ParseStream}, punctuated::Punctuated, - DeriveInput, Error, ItemFn, + token::Bracket, + DeriveInput, ItemFn, Token, }; mod component; @@ -190,50 +191,70 @@ impl ToTokens for Required { } } -/// Media type is wrapper around type and information is type an array -// #[derive(Default)] +/// Parses a type information in uotapi macro parameters. +/// +/// Supports formats: +/// * `type` type is just a simple type identifier +/// * `[type]` type is an array of types +/// * `Option` type is option of type +/// * `Option<[type]>` type is an option of array of types #[cfg_attr(feature = "debug", derive(Debug))] -struct MediaType { +struct Type { ty: Ident, is_array: bool, + is_option: bool, } -impl MediaType { +impl Type { pub fn new(ident: Ident) -> Self { Self { ty: ident, is_array: false, + is_option: false, } } } -impl Parse for MediaType { +impl Parse for Type { fn parse(input: ParseStream) -> syn::Result { let mut is_array = false; + let mut is_option = false; - let parse_ident = |group: &ParseBuffer, error_msg: &str| { - if group.peek(syn::Ident) { - group.parse::() - } else { - Err(Error::new(input.span(), error_msg)) - } + let mut parse_array = |input: &ParseBuffer| { + is_array = true; + let group; + bracketed!(group in input); + group.parse::() }; let ty = if input.peek(syn::Ident) { - parse_ident(input, "unparseable MediaType, expected identifer") - } else { - is_array = true; + let mut ident: Ident = input.parse().unwrap(); - let group; - bracketed!(group in input); + // is option of type or [type] + if (ident == "Option" && input.peek(Token![<])) + && (input.peek2(syn::Ident) || input.peek2(Bracket)) + { + is_option = true; + + input.parse::().unwrap(); - parse_ident( - &group, - "unparseable MediaType, expected identifer within brackets", - ) + if input.peek(syn::Ident) { + ident = input.parse::().unwrap(); + } else { + ident = parse_array(input)?; + } + input.parse::]>()?; + } + Ok(ident) + } else { + parse_array(input) }?; - Ok(MediaType { ty, is_array }) + Ok(Type { + ty, + is_array, + is_option, + }) } } diff --git a/utoipa-gen/src/path/parameter.rs b/utoipa-gen/src/path/parameter.rs index 0c290753..c2f6b25c 100644 --- a/utoipa-gen/src/path/parameter.rs +++ b/utoipa-gen/src/path/parameter.rs @@ -8,7 +8,7 @@ use syn::{ Error, LitStr, Token, }; -use crate::{parse_utils, Deprecated, MediaType, Required}; +use crate::{parse_utils, Deprecated, Required, Type}; use super::property::Property; @@ -19,8 +19,8 @@ use super::property::Property; /// /// Parse is executed for following formats: /// -/// * ("id" = String, path, required, deprecated, description = "Users database id"), -/// * ("id", path, required, deprecated, description = "Users database id"), +/// * ("id" = String, path, deprecated, description = "Users database id"), +/// * ("id", path, deprecated, description = "Users database id"), /// /// The `= String` type statement is optional if automatic resolvation is supported. #[derive(Default)] @@ -28,27 +28,23 @@ use super::property::Property; pub struct Parameter { pub name: String, parameter_in: ParameterIn, - required: bool, deprecated: bool, description: Option, - parameter_type: Option, + parameter_type: Option, } impl Parameter { pub fn new>(name: S, parameter_type: &Ident, parameter_in: ParameterIn) -> Self { - let required = parameter_in == ParameterIn::Path; - Self { name: name.as_ref().to_string(), - parameter_type: Some(MediaType::new(parameter_type.clone())), + parameter_type: Some(Type::new(parameter_type.clone())), parameter_in, - required, ..Default::default() } } pub fn update_parameter_type(&mut self, ident: &Ident) { - self.parameter_type = Some(MediaType::new(ident.clone())); + self.parameter_type = Some(Type::new(ident.clone())); } } @@ -85,11 +81,7 @@ impl Parse for Parameter { match name { "path" | "query" | "header" | "cookie" => { parameter.parameter_in = name.parse::().unwrap_or_abort(); - if parameter.parameter_in == ParameterIn::Path { - parameter.required = true; // all path parameters are required by default - } } - "required" => parameter.required = parse_utils::parse_bool_or_true(input), "deprecated" => parameter.deprecated = parse_utils::parse_bool_or_true(input), "description" => { parameter.description = parse_utils::parse_next(input, || { @@ -105,7 +97,7 @@ impl Parse for Parameter { return Err(Error::new( ident.span(), format!( - "unexpected identifier: {}, expected any of: path, query, header, cookie, required, deprecated, description", + "unexpected identifier: {}, expected any of: path, query, header, cookie, deprecated, description", name ), )) @@ -130,9 +122,6 @@ impl ToTokens for Parameter { let parameter_in = &self.parameter_in; tokens.extend(quote! { .with_in(#parameter_in) }); - let required: Required = self.required.into(); - tokens.extend(quote! { .with_required(#required) }); - let deprecated: Deprecated = self.deprecated.into(); tokens.extend(quote! { .with_deprecated(#deprecated) }); @@ -142,8 +131,9 @@ impl ToTokens for Parameter { if let Some(ref parameter_type) = self.parameter_type { let property = Property::new(parameter_type.is_array, ¶meter_type.ty); + let required: Required = (!parameter_type.is_option).into(); - tokens.extend(quote! { .with_schema(#property) }); + tokens.extend(quote! { .with_schema(#property).with_required(#required) }); } } } diff --git a/utoipa-gen/src/path/request_body.rs b/utoipa-gen/src/path/request_body.rs index 864354da..5952fd6d 100644 --- a/utoipa-gen/src/path/request_body.rs +++ b/utoipa-gen/src/path/request_body.rs @@ -8,7 +8,7 @@ use syn::{ Error, Token, }; -use crate::{parse_utils, MediaType, Required}; +use crate::{parse_utils, Required, Type}; use super::{property::Property, ContentTypeResolver}; @@ -16,8 +16,6 @@ use super::{property::Property, ContentTypeResolver}; /// /// Supported configuration options: /// * **content** Request body content object type. Can also be array e.g. `content = [String]`. -/// * **required** Defines is request body mandatory. Supports also short form e.g. `required` -/// without the `= bool` suffix. /// * **content_type** Defines the actual content mime type of a request body such as `application/json`. /// If not provided really rough guess logic is used. Basically all primitive types are treated as `text/plain` /// and Object types are expected to be `application/json` by default. @@ -28,17 +26,9 @@ use super::{property::Property, ContentTypeResolver}; /// to be xml. /// ```text /// #[utoipa::path( -/// request_body = (content = String, required = true, description = "foobar", content_type = "text/xml"), +/// request_body = (content = String, description = "foobar", content_type = "text/xml"), /// )] /// -/// ``` -/// The `required` attribute could be rewritten like so without the `= bool` suffix. -///```text -/// #[utoipa::path( -/// request_body = (content = String, required, description = "foobar", content_type = "text/xml"), -/// )] -/// ``` -/// /// It is also possible to provide the request body type simply by providing only the content object type. /// ```text /// #[utoipa::path( @@ -52,12 +42,18 @@ use super::{property::Property, ContentTypeResolver}; /// request_body = [Foo], /// )] /// ``` +/// +/// To define optional request body just wrap the type in `Option`. +/// ```text +/// #[utoipa::path( +/// request_body = Option<[Foo]>, +/// )] +/// ``` #[derive(Default)] #[cfg_attr(feature = "debug", derive(Debug))] pub struct RequestBodyAttr { - content: Option, + content: Option, content_type: Option, - required: Option, description: Option, } @@ -79,7 +75,7 @@ impl Parse for RequestBodyAttr { match name { "content" => { request_body_attr.content = Some(parse_utils::parse_next(&group, || { - group.parse::().unwrap() + group.parse::().unwrap() })); } "content_type" => { @@ -88,9 +84,6 @@ impl Parse for RequestBodyAttr { "unparseable content_type, expected literal string", )) } - "required" => { - request_body_attr.required = Some(parse_utils::parse_bool_or_true(&group)); - } "description" => { request_body_attr.description = Some(parse_utils::parse_next_lit_str( &group, @@ -101,7 +94,7 @@ impl Parse for RequestBodyAttr { return Err(Error::new( ident.span(), format!( - "unexpected identifer: {}, expected any of: content, content_type, required, description", + "unexpected identifer: {}, expected any of: content, content_type, description", &name ), )) @@ -122,7 +115,6 @@ impl Parse for RequestBodyAttr { content: Some(input.parse().unwrap()), content_type: None, description: None, - required: None, }) } else { Err(lookahead.error()) @@ -139,20 +131,15 @@ impl ToTokens for RequestBodyAttr { let content_type = self.resolve_content_type(self.content_type.as_ref(), &property.component_type); + let required: Required = (!body_type.is_option).into(); tokens.extend(quote! { utoipa::openapi::request_body::RequestBody::new() .with_content(#content_type, #property) + .with_required(#required) }); } - if let Some(required) = self.required { - let required: Required = required.into(); - tokens.extend(quote! { - .with_required(#required) - }) - } - if let Some(ref description) = self.description { tokens.extend(quote! { .with_description(#description) diff --git a/utoipa-gen/src/path/response.rs b/utoipa-gen/src/path/response.rs index 004f9176..63241e1a 100644 --- a/utoipa-gen/src/path/response.rs +++ b/utoipa-gen/src/path/response.rs @@ -5,7 +5,7 @@ use syn::{ bracketed, parse::Parse, punctuated::Punctuated, token::Comma, Error, LitInt, LitStr, Token, }; -use crate::{parse_utils, MediaType}; +use crate::{parse_utils, Type}; use super::{property::Property, ContentTypeResolver}; @@ -66,7 +66,7 @@ use super::{property::Property, ContentTypeResolver}; pub struct Response { status_code: i32, description: String, - response_type: Option, + response_type: Option, content_type: Option, headers: Vec
, } @@ -99,7 +99,7 @@ impl Parse for Response { } "body" => { response.response_type = Some(parse_utils::parse_next(input, || { - input.parse::().unwrap_or_abort() + input.parse::().unwrap_or_abort() })); } "content_type" => { @@ -192,7 +192,7 @@ impl ToTokens for Responses<'_> { #[inline] fn new_header_tokens(header: &Header) -> TokenStream2 { - let mut header_tokens = if let Some(ref header_type) = header.media_type { + let mut header_tokens = if let Some(ref header_type) = header.value_type { // header property with custom type let header_type = Property::new(header_type.is_array, &header_type.ty); @@ -273,7 +273,7 @@ fn new_header_tokens(header: &Header) -> TokenStream2 { #[cfg_attr(feature = "debug", derive(Debug))] struct Header { name: String, - media_type: Option, + value_type: Option, description: Option, } @@ -290,9 +290,9 @@ impl Parse for Header { if input.peek(Token![=]) { input.parse::().unwrap_or_abort(); - header.media_type = Some( + header.value_type = Some( input - .parse::() + .parse::() .expect_or_abort("unparseable Header type, expected identifer"), ); }