Repository: juhaku/utoipa
Pull request #9 (2022-01-13 20:15:25)

Add request body parsing
* Add feature request body parsing
* Add tests for all scenarios


Patch:
diff --git a/src/openapi/request_body.rs b/src/openapi/request_body.rs
index 786809d..9c4a604 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<I: Into<Component>>(schema: I) -> Self {
+        Self {
+            schema: schema.into(),
+        }
+    }
 }
diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs
index 48234a9..fe20ed3 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<I: IntoIterator<Item = ExprPath>>(
         },
     )
 }
+
+enum Deprecated {
+    True,
+    False,
+}
+
+impl From<bool> 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<bool> 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<Ident>,
+    is_array: bool,
+}
+
+impl Parse for MediaType {
+    fn parse(input: ParseStream) -> syn::Result<Self> {
+        let mut is_array = false;
+        let ty = if input.peek(Bracket) {
+            is_array = true;
+            let group;
+            bracketed!(group in input);
+            group.parse::<Ident>().unwrap()
+        } else {
+            input.parse::<Ident>().unwrap()
+        };
+
+        Ok(MediaType {
+            ty: Some(ty),
+            is_array,
+        })
+    }
+}
diff --git a/utoipa-gen/src/path.rs b/utoipa-gen/src/path.rs
index b8fe965..80cad06 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<PathOperation>,
+    request_body: Option<RequestBodyAttr>,
     responses: Vec<PathResponse>,
     path: Option<String>,
     operation_id: Option<String>,
@@ -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::<Token![=]>().unwrap();
+                    }
+                    path_attr.request_body =
+                        Some(input.parse::<RequestBodyAttr>().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<String>>,
     deprecated: &'a Option<bool>,
     parameters: Option<&'a Vec<Parameter>>,
+    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<bool> 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<bool> 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 0000000..c0452b8
--- /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<String>,
+    required: Option<bool>,
+    description: Option<String>,
+}
+
+impl Parse for RequestBodyAttr {
+    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
+        let parse_lit_str = |group: &ParseBuffer| -> String {
+            if group.peek(Token![=]) {
+                group.parse::<Token![=]>().unwrap();
+            }
+            group.parse::<LitStr>().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::<Ident>().unwrap();
+                let name = &*ident.to_string();
+
+                match name {
+                    "content" => {
+                        if group.peek(Token![=]) {
+                            group.parse::<Token![=]>().unwrap();
+                        }
+
+                        request_body_attr.content = group.parse::<MediaType>().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::<Token![=]>().unwrap();
+
+                            request_body_attr.required = Some(group.parse::<LitBool>().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::<Token![,]>().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)
+            })
+        }
+    }
+}


Test patch:
diff --git a/tests/request_body_derive_test.rs b/tests/request_body_derive_test.rs
new file mode 100644
index 0000000..714921b
--- /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 2d0280a..9409c4a 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());
 }