From c44a55643c2380d9b7f5ca76592220ad9c34619a Mon Sep 17 00:00:00 2001 From: Juha Kukkonen Date: Thu, 13 Jan 2022 22:14:39 +0200 Subject: [PATCH] Add request body parsing --- src/openapi/request_body.rs | 14 ++- tests/request_body_derive_test.rs | 183 ++++++++++++++++++++++++++++ tests/utoipa_gen_test.rs | 3 +- utoipa-gen/src/lib.rs | 85 ++++++++++++- utoipa-gen/src/path.rs | 74 ++++-------- utoipa-gen/src/request_body.rs | 190 ++++++++++++++++++++++++++++++ 6 files changed, 491 insertions(+), 58 deletions(-) create mode 100644 tests/request_body_derive_test.rs create mode 100644 utoipa-gen/src/request_body.rs diff --git a/src/openapi/request_body.rs b/src/openapi/request_body.rs index 786809da..9c4a604c 100644 --- a/src/openapi/request_body.rs +++ b/src/openapi/request_body.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use super::Required; +use super::{Component, Required}; #[non_exhaustive] #[derive(Serialize, Deserialize, Default)] @@ -43,7 +43,15 @@ impl RequestBody { } #[derive(Serialize, Deserialize, Default)] +#[non_exhaustive] pub struct Content { - // TODO implement schema somehow - pub schema: String, + pub schema: Component, +} + +impl Content { + pub fn new>(schema: I) -> Self { + Self { + schema: schema.into(), + } + } } diff --git a/tests/request_body_derive_test.rs b/tests/request_body_derive_test.rs new file mode 100644 index 00000000..714921b0 --- /dev/null +++ b/tests/request_body_derive_test.rs @@ -0,0 +1,183 @@ +use utoipa::OpenApi; + +mod common; + +macro_rules! test_fn { + ( module: $name:ident, body: $body:expr ) => { + #[allow(unused)] + mod $name { + + struct Foo { + name: String, + } + #[utoipa::path( + post, + path = "/foo", + request_body = $body, + responses = [ + (200, "success", String), + ] + )] + fn post_foo() {} + } + }; +} + +test_fn! { + module: derive_request_body_simple, + body: Foo +} + +#[test] +fn derive_path_request_body_simple_success() { + #[derive(OpenApi, Default)] + #[openapi(handler_files = [], handlers = [derive_request_body_simple::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###""#/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.description" = r###"null"###, "Request body description" + } +} + +test_fn! { + module: derive_request_body_simple_array, + body: [Foo] +} + +#[test] +fn derive_path_request_body_simple_array_success() { + #[derive(OpenApi, Default)] + #[openapi(handler_files = [], handlers = [derive_request_body_simple_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###"null"###, "Request body required" + "paths./foo.post.requestBody.description" = r###"null"###, "Request body description" + } +} + +test_fn! { + module: derive_request_body_primitive_simple, + body: String +} + +#[test] +fn derive_request_body_primitive_simple_success() { + #[derive(OpenApi, Default)] + #[openapi(handler_files = [], handlers = [derive_request_body_primitive_simple::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 not application/json" + "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.description" = r###"null"###, "Request body description" + } +} + +test_fn! { + module: derive_request_body_primitive_simple_array, + body: [u64] +} + +#[test] +fn derive_request_body_primitive_array_success() { + #[derive(OpenApi, Default)] + #[openapi(handler_files = [], handlers = [derive_request_body_primitive_simple_array::post_foo])] + struct ApiDoc; + + let doc = serde_json::to_value(&ApiDoc::openapi()).unwrap(); + + assert_value! {doc=> + "paths./foo.post.requestBody.content.application/json" = r###"null"###, "Request body content object type not application/json" + "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.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") +} + +#[test] +fn derive_request_body_complex_success() { + #[derive(OpenApi, Default)] + #[openapi(handler_files = [], handlers = [derive_request_body_complex::post_foo])] + struct ApiDoc; + + let doc = serde_json::to_value(&ApiDoc::openapi()).unwrap(); + + assert_value! {doc=> + "paths./foo.post.requestBody.content.application/json" = r###"null"###, "Request body content object type not application/json" + "paths./foo.post.requestBody.content.text/xml.schema.$ref" = r###""#/components/schemas/Foo""###, "Request body content object type" + "paths./foo.post.requestBody.content.text/plain.schema.type" = r###"null"###, "Request body content object item type" + "paths./foo.post.requestBody.content.text/plain.schema.items.type" = r###"null"###, "Request body content items object type" + "paths./foo.post.requestBody.required" = r###"true"###, "Request body required" + "paths./foo.post.requestBody.description" = r###""Create new Foo""###, "Request body description" + } +} + +test_fn! { + module: derive_request_body_complex_required_explisit, + body: (content = Foo, required = false, description = "Create new Foo", content_type = "text/xml") +} + +#[test] +fn derive_request_body_complex_required_explisit_false_success() { + #[derive(OpenApi, Default)] + #[openapi(handler_files = [], handlers = [derive_request_body_complex_required_explisit::post_foo])] + struct ApiDoc; + + let doc = serde_json::to_value(&ApiDoc::openapi()).unwrap(); + + assert_value! {doc=> + "paths./foo.post.requestBody.content.application/json" = r###"null"###, "Request body content object type not application/json" + "paths./foo.post.requestBody.content.text/xml.schema.$ref" = r###""#/components/schemas/Foo""###, "Request body content object type" + "paths./foo.post.requestBody.content.text/plain.schema.type" = r###"null"###, "Request body content object item type" + "paths./foo.post.requestBody.content.text/plain.schema.items.type" = r###"null"###, "Request body content items object type" + "paths./foo.post.requestBody.required" = r###"false"###, "Request body required" + "paths./foo.post.requestBody.description" = r###""Create new Foo""###, "Request body description" + } +} + +test_fn! { + module: derive_request_body_complex_primitive_array, + body: (content = [u32], description = "Create new foo references") +} + +#[test] +fn derive_request_body_complex_primitive_array_success() { + #[derive(OpenApi, Default)] + #[openapi(handler_files = [], handlers = [derive_request_body_complex_primitive_array::post_foo])] + struct ApiDoc; + + let doc = serde_json::to_value(&ApiDoc::openapi()).unwrap(); + + assert_value! {doc=> + "paths./foo.post.requestBody.content.application/json" = r###"null"###, "Request body content object type not application/json" + "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.description" = r###""Create new foo references""###, "Request body description" + } +} diff --git a/tests/utoipa_gen_test.rs b/tests/utoipa_gen_test.rs index 2d0280ab..9409c4aa 100644 --- a/tests/utoipa_gen_test.rs +++ b/tests/utoipa_gen_test.rs @@ -17,6 +17,7 @@ struct Foo { /// /// Delete foo entity by what #[utoipa::path( + request_body = (content = Foo, required, description = "foobar", content_type = "text/xml"), responses = [ (200, "success", String), (400, "my bad error", u64), @@ -44,5 +45,5 @@ fn derive_openapi() { #[openapi(handler_files = [], handlers = [foo_delete])] struct ApiDoc; - println!("{:?}", ApiDoc::openapi().to_json()) + println!("{}", ApiDoc::openapi().to_pretty_json().unwrap()); } diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs index 48234a9d..fe20ed32 100644 --- a/utoipa-gen/src/lib.rs +++ b/utoipa-gen/src/lib.rs @@ -8,12 +8,15 @@ use ext::actix::update_parameters_from_arguments; use ext::{ArgumentResolver, PathOperationResolver, PathOperations, PathResolver}; use proc_macro::TokenStream; -use quote::{format_ident, quote, quote_spanned}; +use quote::{format_ident, quote, quote_spanned, ToTokens}; use proc_macro2::{Ident, TokenStream as TokenStream2}; use syn::{ - bracketed, parse::Parse, punctuated::Punctuated, Attribute, DeriveInput, ExprPath, LitStr, - Token, + bracketed, + parse::{Parse, ParseStream}, + punctuated::Punctuated, + token::Bracket, + Attribute, DeriveInput, ExprPath, LitStr, Token, }; mod attribute; @@ -22,6 +25,7 @@ mod component_type; mod ext; mod info; mod path; +mod request_body; use proc_macro_error::*; @@ -329,3 +333,78 @@ fn impl_paths>( }, ) } + +enum Deprecated { + True, + False, +} + +impl From for Deprecated { + fn from(bool: bool) -> Self { + if bool { + Self::True + } else { + Self::False + } + } +} + +impl ToTokens for Deprecated { + fn to_tokens(&self, tokens: &mut TokenStream2) { + tokens.extend(match self { + Self::False => quote! { utoipa::openapi::Deprecated::False }, + Self::True => quote! { utoipa::openapi::Deprecated::True }, + }) + } +} + +enum Required { + True, + False, +} + +impl From for Required { + fn from(bool: bool) -> Self { + if bool { + Self::True + } else { + Self::False + } + } +} + +impl ToTokens for Required { + fn to_tokens(&self, tokens: &mut TokenStream2) { + tokens.extend(match self { + Self::False => quote! { utoipa::openapi::Required::False }, + Self::True => quote! { utoipa::openapi::Required::True }, + }) + } +} + +/// Media type is wrapper around type and information is type an array +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +struct MediaType { + ty: Option, + is_array: bool, +} + +impl Parse for MediaType { + fn parse(input: ParseStream) -> syn::Result { + let mut is_array = false; + let ty = if input.peek(Bracket) { + is_array = true; + let group; + bracketed!(group in input); + group.parse::().unwrap() + } else { + input.parse::().unwrap() + }; + + Ok(MediaType { + ty: Some(ty), + is_array, + }) + } +} diff --git a/utoipa-gen/src/path.rs b/utoipa-gen/src/path.rs index b8fe965a..80cad06e 100644 --- a/utoipa-gen/src/path.rs +++ b/utoipa-gen/src/path.rs @@ -8,11 +8,15 @@ use syn::{ parse::{Parse, ParseStream}, parse2, punctuated::Punctuated, - token::Bracket, + token::{Bracket, Token}, LitInt, LitStr, Token, }; -use crate::component_type::{ComponentFormat, ComponentType}; +use crate::{ + component_type::{ComponentFormat, ComponentType}, + request_body::RequestBodyAttr, + Deprecated, Required, +}; const PATH_STRUCT_PREFIX: &str = "__path_"; @@ -20,6 +24,7 @@ const PATH_STRUCT_PREFIX: &str = "__path_"; // operation_id = "custom_operation_id", // path = "/custom/path/{id}/{digest}", // tag = "groupping_tag" +// request_body = [Foo] // responses = [ // (status = 200, description = "delete foo entity successful", // body = String, content_type = "text/plain"), @@ -69,6 +74,7 @@ const PATH_STRUCT_PREFIX: &str = "__path_"; #[cfg_attr(feature = "debug", derive(Debug))] pub struct PathAttr { path_operation: Option, + request_body: Option, responses: Vec, path: Option, operation_id: Option, @@ -121,6 +127,13 @@ impl Parse for PathAttr { path_attr.path = Some(parse_lit_str(&input, "expected literal string for path")); } + "request_body" => { + if input.peek(Token![=]) { + input.parse::().unwrap(); + } + path_attr.request_body = + Some(input.parse::().unwrap_or_abort()); + } "responses" => { let groups = parse_groups(&input) .expect_or_abort("expected responses to be group separated by comma (,)"); @@ -570,6 +583,7 @@ impl ToTokens for Path { .and_then(|comments| comments.iter().next()), description: self.doc_comments.as_ref(), parameters: self.path_attr.params.as_ref(), + request_body: self.path_attr.request_body.as_ref(), }; tokens.extend(quote! { @@ -605,12 +619,19 @@ struct Operation<'a> { description: Option<&'a Vec>, deprecated: &'a Option, parameters: Option<&'a Vec>, + request_body: Option<&'a RequestBodyAttr>, } impl ToTokens for Operation<'_> { fn to_tokens(&self, tokens: &mut TokenStream2) { tokens.extend(quote! { utoipa::openapi::path::Operation::new() }); + if let Some(request_body) = self.request_body { + tokens.extend(quote! { + .with_request_body(#request_body) + }) + } + // impl dummy responses tokens.extend(quote! { .with_response( @@ -618,7 +639,6 @@ impl ToTokens for Operation<'_> { utoipa::openapi::response::Response::new("this is response message") ) }); - // // .with_request_body() // // .with_security() let path_struct = format_ident!("{}{}", PATH_STRUCT_PREFIX, self.fn_name); let operation_id = self.operation_id; @@ -668,51 +688,3 @@ impl ToTokens for Operation<'_> { } } } - -pub enum Deprecated { - True, - False, -} - -impl From for Deprecated { - fn from(bool: bool) -> Self { - if bool { - Self::True - } else { - Self::False - } - } -} - -impl ToTokens for Deprecated { - fn to_tokens(&self, tokens: &mut TokenStream2) { - tokens.extend(match self { - Self::False => quote! { utoipa::openapi::Deprecated::False }, - Self::True => quote! { utoipa::openapi::Deprecated::True }, - }) - } -} - -pub enum Required { - True, - False, -} - -impl From for Required { - fn from(bool: bool) -> Self { - if bool { - Self::True - } else { - Self::False - } - } -} - -impl ToTokens for Required { - fn to_tokens(&self, tokens: &mut TokenStream2) { - tokens.extend(match self { - Self::False => quote! { utoipa::openapi::Required::False }, - Self::True => quote! { utoipa::openapi::Required::True }, - }) - } -} diff --git a/utoipa-gen/src/request_body.rs b/utoipa-gen/src/request_body.rs new file mode 100644 index 00000000..c0452b8e --- /dev/null +++ b/utoipa-gen/src/request_body.rs @@ -0,0 +1,190 @@ +use proc_macro2::{Ident, TokenStream as TokenStream2}; +use quote::{quote, ToTokens}; +use syn::{ + parenthesized, + parse::{Parse, ParseBuffer}, + token::{Bracket, Paren}, + LitBool, LitStr, Token, +}; + +use crate::{ + component_type::{ComponentFormat, ComponentType}, + MediaType, Required, +}; + +/// Parsed information related to requst body of path. +/// +/// 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. +/// * **description** Additional description for request body content type. +/// # Examples +/// +/// Request body in path with all supported info. Where content type is treated as a String and expected +/// to be xml. +/// ```text +/// #[utoipa::path( +/// request_body = (content = String, required = true, 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( +/// request_body = Foo, +/// )] +/// ``` +/// +/// Or the request body content can also be an array as well by surrounding it with brackets `[..]`. +/// ```text +/// #[utoipa::path( +/// request_body = [Foo], +/// )] +/// ``` +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct RequestBodyAttr { + content: MediaType, + content_type: Option, + required: Option, + description: Option, +} + +impl Parse for RequestBodyAttr { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let parse_lit_str = |group: &ParseBuffer| -> String { + if group.peek(Token![=]) { + group.parse::().unwrap(); + } + group.parse::().unwrap().value() + }; + + let lookahead = input.lookahead1(); + if lookahead.peek(Paren) { + let group; + parenthesized!(group in input); + + let mut request_body_attr = RequestBodyAttr::default(); + loop { + let ident = group.parse::().unwrap(); + let name = &*ident.to_string(); + + match name { + "content" => { + if group.peek(Token![=]) { + group.parse::().unwrap(); + } + + request_body_attr.content = group.parse::().unwrap(); + } + "content_type" => request_body_attr.content_type = Some(parse_lit_str(&group)), + "required" => { + // support assign form as: required = bool + if group.peek(Token![=]) && group.peek2(LitBool) { + group.parse::().unwrap(); + + request_body_attr.required = Some(group.parse::().unwrap().value()); + } else { + // quick form as: required + request_body_attr.required = Some(true); + } + } + "description" => request_body_attr.description = Some(parse_lit_str(&group)), + _ => return Err(group.error(format!("unexpedted attribute: {}, expected values: content, content_type, required, description", &name))) + } + + if group.peek(Token![,]) { + group.parse::().unwrap(); + } + if group.is_empty() { + break; + } + } + + Ok(request_body_attr) + } else if lookahead.peek(Bracket) || lookahead.peek(syn::Ident) { + Ok(RequestBodyAttr { + content: input.parse().unwrap(), + content_type: None, + description: None, + required: None, + }) + } else { + Err(lookahead.error()) + } + } +} + +impl ToTokens for RequestBodyAttr { + fn to_tokens(&self, tokens: &mut TokenStream2) { + // TODO refactor component type & format to its own type + let body_type = self.content.ty.as_ref().unwrap(); + let component_type = ComponentType(body_type); + + let mut component = if component_type.is_primitive() { + let mut component = quote! { + utoipa::openapi::Property::new( + #component_type + ) + }; + + let format = ComponentFormat(body_type); + if format.is_known_format() { + component.extend(quote! { + .with_format(#format) + }) + } + + component + } else { + let name = &*body_type.to_string(); + + quote! { + utoipa::openapi::Ref::from_component_name(#name) + } + }; + + if self.content.is_array { + component.extend(quote! { + .to_array() + }); + } + + let content_type = if let Some(ref content_type) = self.content_type { + content_type + } else if component_type.is_primitive() { + "text/plain" + } else { + "application/json" + }; + + tokens.extend(quote! { + utoipa::openapi::request_body::RequestBody::new() + .with_content(#content_type, utoipa::openapi::request_body::Content::new(#component)) + }); + + 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) + }) + } + } +}