diff --git a/src/openapi/content.rs b/src/openapi/content.rs new file mode 100644 index 00000000..e7b7e946 --- /dev/null +++ b/src/openapi/content.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; + +use super::Component; + +#[derive(Serialize, Deserialize, Default)] +#[non_exhaustive] +pub struct Content { + pub schema: Component, +} + +impl Content { + pub fn new>(schema: I) -> Self { + Self { + schema: schema.into(), + } + } +} diff --git a/src/openapi/header.rs b/src/openapi/header.rs new file mode 100644 index 00000000..6fe673f2 --- /dev/null +++ b/src/openapi/header.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; + +use super::{Component, ComponentType, Property}; + +#[non_exhaustive] +#[derive(Serialize, Deserialize)] +pub struct Header { + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + pub schema: Component, +} + +impl Header { + pub fn new>(component: C) -> Self { + Self { + schema: component.into(), + ..Default::default() + } + } + + pub fn with_description>(mut self, description: S) -> Self { + self.description = Some(description.as_ref().to_string()); + + self + } +} + +impl Default for Header { + fn default() -> Self { + Self { + description: Default::default(), + schema: Property::new(ComponentType::String).into(), + } + } +} diff --git a/src/openapi/mod.rs b/src/openapi/mod.rs index 68a7186e..6ef40165 100644 --- a/src/openapi/mod.rs +++ b/src/openapi/mod.rs @@ -6,10 +6,13 @@ use crate::error::Error; pub use self::{ contact::Contact, + content::Content, external_docs::ExternalDocs, + header::Header, info::Info, licence::Licence, path::{PathItem, PathItemType, Paths}, + response::{Response, Responses}, schema::{Array, Component, ComponentFormat, ComponentType, Object, Property, Ref, Schema}, security::Security, server::Server, @@ -17,7 +20,9 @@ pub use self::{ }; pub mod contact; +pub mod content; pub mod external_docs; +pub mod header; pub mod info; pub mod licence; pub mod path; diff --git a/src/openapi/request_body.rs b/src/openapi/request_body.rs index 9c4a604c..020ccc78 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::{Component, Required}; +use super::{Component, Content, Required}; #[non_exhaustive] #[derive(Serialize, Deserialize, Default)] @@ -34,24 +34,16 @@ impl RequestBody { self } - pub fn with_content>(mut self, content_type: S, content: Content) -> Self { - self.content - .insert(content_type.as_ref().to_string(), content); + pub fn with_content, C: Into>( + mut self, + content_type: S, + component: C, + ) -> Self { + self.content.insert( + content_type.as_ref().to_string(), + Content::new(component.into()), + ); self } } - -#[derive(Serialize, Deserialize, Default)] -#[non_exhaustive] -pub struct Content { - pub schema: Component, -} - -impl Content { - pub fn new>(schema: I) -> Self { - Self { - schema: schema.into(), - } - } -} diff --git a/src/openapi/response.rs b/src/openapi/response.rs index bdd2a505..918c1600 100644 --- a/src/openapi/response.rs +++ b/src/openapi/response.rs @@ -1,13 +1,15 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use serde::{Deserialize, Serialize}; +use super::{header::Header, Component, Content}; + #[non_exhaustive] #[derive(Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct Responses { #[serde(flatten)] - pub inner: HashMap, + pub inner: BTreeMap, } impl Responses { @@ -26,14 +28,39 @@ impl Responses { #[derive(Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct Response { - // TODO add missing fields pub description: String, + + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub headers: HashMap, + + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub content: HashMap, } impl Response { pub fn new>(description: S) -> Self { Self { description: description.as_ref().to_string(), + ..Default::default() } } + + pub fn with_content, S: AsRef>( + mut self, + content_type: S, + component: C, + ) -> Self { + self.content.insert( + content_type.as_ref().to_string(), + Content::new(component.into()), + ); + + self + } + + pub fn with_header>(mut self, name: S, header: Header) -> Self { + self.headers.insert(name.as_ref().to_string(), header); + + self + } } diff --git a/tests/path_derive.rs b/tests/path_derive.rs index 0e4d9460..0532bc0c 100644 --- a/tests/path_derive.rs +++ b/tests/path_derive.rs @@ -27,15 +27,15 @@ macro_rules! test_api_fn { mod $module { $( $(#[$meta])* )* #[utoipa::path( - $operation, - $( operation_id = $operation_id, )* - path = $path, - responses = [ - (200, "success", String), - ], - $( params = $params, )* - $( tag = $tag, )* - )] + $operation, + $( operation_id = $operation_id, )* + path = $path, + responses = [ + (status = 200, description = "success response") + ], + $( params = $params, )* + $( tag = $tag, )* + )] #[allow(unused)] async fn $name() -> String { "foo".to_string() @@ -153,7 +153,7 @@ fn derive_path_with_defaults_success() { get, path = "/foo/{id}", responses = [ - (200, "success", String) + (status = 200, description = "success response") ], params = [ ("id" = u64, description = "Foo database id"), diff --git a/tests/path_derive_actix.rs b/tests/path_derive_actix.rs index eb0c6bce..3a38b839 100644 --- a/tests/path_derive_actix.rs +++ b/tests/path_derive_actix.rs @@ -14,7 +14,7 @@ mod mod_derive_path_actix { /// Get foo by id long description #[utoipa::path( responses = [ - (200, "success", String), + (status = 200, description = "success response") ], params = [ ("id", description = "Foo id"), @@ -57,7 +57,7 @@ mod mod_derive_path_unnamed_regex_actix { /// Get foo by id long description #[utoipa::path( responses = [ - (200, "success", String), + (status = 200, description = "success"), ], params = [ ("arg0", description = "Foo path unnamed regex tail") @@ -100,7 +100,7 @@ mod mod_derive_path_named_regex_actix { /// Get foo by id long description #[utoipa::path( responses = [ - (200, "success", String), + (status = 200, description = "success response") ], params = [ ("tail", description = "Foo path named regex tail") @@ -143,7 +143,7 @@ macro_rules! test_derive_path_operations { #[utoipa::path( responses = [ - (200, "success", String), + (status = 200, description = "success response") ] )] #[$operation("/foo")] diff --git a/tests/path_parameter_derive_actix.rs b/tests/path_parameter_derive_actix.rs index 25874aaa..9dca2fbb 100644 --- a/tests/path_parameter_derive_actix.rs +++ b/tests/path_parameter_derive_actix.rs @@ -15,7 +15,7 @@ mod derive_params_multiple_actix { get, path = "/foo/{id}/{digest}", responses = [ - (200, "success", String), + (status = 200, description = "success response") ], params = [ ("id", description = "Foo id"), @@ -68,7 +68,7 @@ mod derive_parameters_multiple_no_matching_names_actix { get, path = "/foo/{id}/{digest}", responses = [ - (200, "success", String), + (status = 200, description = "success response") ], params = [ ("id" = i32, description = "Foo id"), @@ -119,7 +119,7 @@ mod derive_params_from_method_args_actix { get, path = "/foo/{id}/{digest}", responses = [ - (200, "success", String), + (status = 200, description = "success response") ], )] #[allow(unused)] diff --git a/tests/path_parameter_derive_test.rs b/tests/path_parameter_derive_test.rs index 02533601..19e3e16c 100644 --- a/tests/path_parameter_derive_test.rs +++ b/tests/path_parameter_derive_test.rs @@ -14,7 +14,7 @@ mod derive_params_all_options { get, path = "/foo/{id}", responses = [ - (200, "success", String), + (status = 200, description = "success"), ], params = [ ("id" = i32, path, required, deprecated, description = "Search foos by ids"), @@ -55,7 +55,7 @@ mod derive_params_minimal { get, path = "/foo/{id}", responses = [ - (200, "success", String), + (status = 200, description = "success"), ], params = [ ("id" = i32, description = "Search foos by ids"), @@ -96,7 +96,7 @@ mod derive_params_multiple { get, path = "/foo/{id}/{digest}", responses = [ - (200, "success", String), + (status = 200, description = "success"), ], params = [ ("id" = i32, description = "Foo id"), @@ -146,7 +146,7 @@ mod mod_derive_parameters_all_types { get, path = "/foo/{id}", responses = [ - (200, "success", String), + (status = 200, description = "success"), ], params = [ ("id" = i32, path, description = "Foo id"), @@ -222,7 +222,7 @@ mod derive_params_without_args { get, path = "/foo/{id}", responses = [ - (200, "success", String), + (status = 200, description = "success"), ], params = [ ("id" = i32, path, description = "Foo id"), diff --git a/tests/path_response_derive_test.rs b/tests/path_response_derive_test.rs new file mode 100644 index 00000000..e1e7046a --- /dev/null +++ b/tests/path_response_derive_test.rs @@ -0,0 +1,175 @@ +mod common; + +macro_rules! test_fn { + ( module: $name:ident, responses: $responses:expr ) => { + #[allow(unused)] + mod $name { + #[utoipa::path(get,path = "/foo",responses = $responses)] + fn get_foo() {} + } + }; +} + +macro_rules! api_doc { + ( module: $module:expr ) => {{ + use utoipa::OpenApi; + #[derive(OpenApi, Default)] + #[openapi(handler_files = [], handlers = [$module::get_foo])] + struct ApiDoc; + + let doc = serde_json::to_value(&ApiDoc::openapi()).unwrap(); + common::get_json_path(&doc, "paths./foo.get").clone() + }}; +} + +test_fn! { + module: simple_success_response, + responses: [ + (status = 200, description = "success") + ] +} + +#[test] +fn derive_path_with_simple_success_response() { + let doc = api_doc!(module: simple_success_response); + + assert_value! {doc=> + "responses.200.description" = r#""success""#, "Response description" + "responses.200.content" = r#"null"#, "Response content" + "responses.200.headers" = r#"null"#, "Response headers" + } +} + +test_fn! { + module: multiple_simple_responses, + responses: [ + (status = 200, description = "success"), + (status = 401, description = "unauthorized"), + (status = 404, description = "not found"), + (status = 500, description = "server error") + ] +} + +#[test] +fn derive_path_with_multiple_simple_responses() { + let doc = api_doc!(module: multiple_simple_responses); + + assert_value! {doc=> + "responses.200.description" = r#""success""#, "Response description" + "responses.200.content" = r#"null"#, "Response content" + "responses.200.headers" = r#"null"#, "Response headers" + "responses.401.description" = r#""unauthorized""#, "Response description" + "responses.401.content" = r#"null"#, "Response content" + "responses.401.headers" = r#"null"#, "Response headers" + "responses.404.description" = r#""not found""#, "Response description" + "responses.404.content" = r#"null"#, "Response content" + "responses.404.headers" = r#"null"#, "Response headers" + "responses.500.description" = r#""server error""#, "Response description" + "responses.500.content" = r#"null"#, "Response content" + "responses.500.headers" = r#"null"#, "Response headers" + } +} + +macro_rules! test_response_types { + ( $( $name:ident=> $(body: $expected:expr,)? $( $content_type:literal, )? $( headers: $headers:expr, )? + assert: $( $path:literal = $expection:literal, $comment:literal )* )* ) => { + $( + paste::paste! { + test_fn! { + module: [], + responses: [ + (status = 200, description = "success", + $(body = $expected ,)* + $(content_type = $content_type,)* + $(headers = $headers)*), + ] + } + } + + #[test] + fn $name() { + paste::paste! { + let doc = api_doc!(module: []); + } + + assert_value! {doc=> + "responses.200.description" = r#""success""#, "Response description" + $($path = $expection, $comment)* + } + } + )* + }; +} + +#[allow(unused)] +struct Foo { + name: String, +} + +test_response_types! { +primitive_string_body => body: String, assert: + "responses.200.content.text/plain.schema.type" = r#""string""#, "Response content type" + "responses.200.headers" = r###"null"###, "Response headers" +primitive_string_sclice_body => body: [String], assert: + "responses.200.content.text/plain.schema.items.type" = r#""string""#, "Response content items type" + "responses.200.content.text/plain.schema.type" = r#""array""#, "Response content type" + "responses.200.headers" = r###"null"###, "Response headers" +primitive_integer_slice_body => body: [i32], assert: + "responses.200.content.text/plain.schema.items.type" = r#""integer""#, "Response content items type" + "responses.200.content.text/plain.schema.items.format" = r#""int32""#, "Response content items format" + "responses.200.content.text/plain.schema.type" = r#""array""#, "Response content type" + "responses.200.headers" = r###"null"###, "Response headers" +primitive_integer_body => body: i64, assert: + "responses.200.content.text/plain.schema.type" = r#""integer""#, "Response content type" + "responses.200.content.text/plain.schema.format" = r#""int64""#, "Response content format" + "responses.200.headers" = r###"null"###, "Response headers" +primitive_big_integer_body => body: u128, assert: + "responses.200.content.text/plain.schema.type" = r#""integer""#, "Response content type" + "responses.200.content.text/plain.schema.format" = r#"null"#, "Response content format" + "responses.200.headers" = r###"null"###, "Response headers" +primitive_bool_body => body: bool, assert: + "responses.200.content.text/plain.schema.type" = r#""boolean""#, "Response content type" + "responses.200.headers" = r###"null"###, "Response headers" +object_body => body: Foo, assert: + "responses.200.content.application/json.schema.$ref" = r###""#/components/schemas/Foo""###, "Response content type" + "responses.200.headers" = r###"null"###, "Response headers" +object_slice_body => body: [Foo], assert: + "responses.200.content.application/json.schema.type" = r###""array""###, "Response content type" + "responses.200.content.application/json.schema.items.$ref" = r###""#/components/schemas/Foo""###, "Response content items type" + "responses.200.headers" = r###"null"###, "Response headers" +object_body_override_content_type_to_xml => body: Foo, "text/xml", assert: + "responses.200.content.application/json.schema.$ref" = r###"null"###, "Response content type" + "responses.200.content.text/xml.schema.$ref" = r###""#/components/schemas/Foo""###, "Response content type" + "responses.200.headers" = r###"null"###, "Response headers" +object_body_with_simple_header => body: Foo, headers: [ + ("xsrf-token") +], assert: + "responses.200.content.application/json.schema.$ref" = r###""#/components/schemas/Foo""###, "Response content type" + "responses.200.headers.xsrf-token.schema.type" = r###""string""###, "xsrf-token header type" + "responses.200.headers.xsrf-token.description" = r###"null"###, "xsrf-token header description" +object_body_with_multiple_headers => body: Foo, headers: [ + ("xsrf-token"), + ("another-header") +], assert: + "responses.200.content.application/json.schema.$ref" = r###""#/components/schemas/Foo""###, "Response content type" + "responses.200.headers.xsrf-token.schema.type" = r###""string""###, "xsrf-token header type" + "responses.200.headers.xsrf-token.description" = r###"null"###, "xsrf-token header description" + "responses.200.headers.another-header.schema.type" = r###""string""###, "another-header header type" + "responses.200.headers.another-header.description" = r###"null"###, "another-header header description" +object_body_with_header_with_type => body: Foo, headers: [ + ("random-digits" = [u64]), +], assert: + "responses.200.content.application/json.schema.$ref" = r###""#/components/schemas/Foo""###, "Response content type" + "responses.200.headers.random-digits.schema.type" = r###""array""###, "random-digits header type" + "responses.200.headers.random-digits.description" = r###"null"###, "random-digits header description" + "responses.200.headers.random-digits.schema.items.type" = r###""integer""###, "random-digits header items type" + "responses.200.headers.random-digits.schema.items.format" = r###""int64""###, "random-digits header items format" +response_no_body_with_complex_header_with_description => headers: [ + ("random-digits" = [u64], description = "Random digits response header"), +], assert: + "responses.200.content" = r###"null"###, "Response content type" + "responses.200.headers.random-digits.description" = r###""Random digits response header""###, "random-digits header description" + "responses.200.headers.random-digits.schema.type" = r###""array""###, "random-digits header type" + "responses.200.headers.random-digits.schema.items.type" = r###""integer""###, "random-digits header items type" + "responses.200.headers.random-digits.schema.items.format" = r###""int64""###, "random-digits header items format" +} diff --git a/tests/request_body_derive_test.rs b/tests/request_body_derive_test.rs index 714921b0..660c9625 100644 --- a/tests/request_body_derive_test.rs +++ b/tests/request_body_derive_test.rs @@ -11,13 +11,13 @@ macro_rules! test_fn { name: String, } #[utoipa::path( - post, - path = "/foo", - request_body = $body, - responses = [ - (200, "success", String), - ] - )] + post, + path = "/foo", + request_body = $body, + responses = [ + (status = 200, description = "success response") + ] + )] fn post_foo() {} } }; diff --git a/tests/utoipa_gen_test.rs b/tests/utoipa_gen_test.rs index 9409c4aa..dc9ea383 100644 --- a/tests/utoipa_gen_test.rs +++ b/tests/utoipa_gen_test.rs @@ -19,10 +19,10 @@ struct Foo { #[utoipa::path( request_body = (content = Foo, required, description = "foobar", content_type = "text/xml"), responses = [ - (200, "success", String), - (400, "my bad error", u64), - (404, "vault not found"), - (500, "internal server error") + (status = 200, description = "success response", body = [Foo], headers = [("xrfs-token" = u64)]) + // (400, "my bad error", u64), + // (404, "vault not found"), + // (500, "internal server error") ], params = [ ("ids" = [i32], query, description = "Search foos by ids"), diff --git a/utoipa-gen/src/component.rs b/utoipa-gen/src/component.rs index 1f6119dd..51284a4e 100644 --- a/utoipa-gen/src/component.rs +++ b/utoipa-gen/src/component.rs @@ -1,16 +1,17 @@ use std::{ops::Deref, rc::Rc}; -use proc_macro2::{Group, Ident, Punct, TokenStream as TokenStream2}; +use proc_macro2::{Ident, TokenStream as TokenStream2}; use proc_macro_error::{abort, abort_call_site, emit_error}; -use quote::{quote, ToTokens, TokenStreamExt}; +use quote::{quote, ToTokens}; use syn::{ - punctuated::Punctuated, Attribute, Fields, FieldsNamed, FieldsUnnamed, GenericArgument, - PathArguments, PathSegment, Type, TypePath, Variant, + Attribute, Fields, FieldsNamed, FieldsUnnamed, GenericArgument, PathArguments, PathSegment, + Type, TypePath, Variant, }; use crate::{ attribute::{parse_component_attribute, AttributeType, CommentAttributes, ComponentAttribute}, component_type::{ComponentFormat, ComponentType}, + ValueArray, }; pub(crate) fn impl_component(data: syn::Data, attrs: Vec) -> TokenStream2 { @@ -184,7 +185,7 @@ impl<'a> ComponentVariant<'a> { .iter() .filter(|variant| matches!(variant.fields, Fields::Unit)) .map(|variant| variant.ident.to_string()) - .collect::(); + .collect::>(); tokens.extend(quote! { utoipa::openapi::Property::new(ComponentType::String) @@ -212,37 +213,6 @@ impl<'a> ComponentVariant<'a> { } } -/// Tokenizes slice reference (`&[...]`) correctly to OpenAPI JSON. -struct EnumValues(Vec); - -impl FromIterator for EnumValues { - fn from_iter>(iter: T) -> Self { - Self { - 0: iter.into_iter().collect::>(), - } - } -} - -impl ToTokens for EnumValues { - fn to_tokens(&self, tokens: &mut TokenStream2) { - tokens.append(Punct::new('&', proc_macro2::Spacing::Joint)); - let items = self - .0 - .iter() - .fold(Punctuated::new(), |mut punctuated, item| { - punctuated.push_value(item); - punctuated.push_punct(Punct::new(',', proc_macro2::Spacing::Alone)); - - punctuated - }); - - tokens.append(Group::new( - proc_macro2::Delimiter::Bracket, - items.to_token_stream(), - )); - } -} - fn append_attributes>( token_stream: &mut TokenStream2, component_attribute: I, diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs index fe20ed32..180e4015 100644 --- a/utoipa-gen/src/lib.rs +++ b/utoipa-gen/src/lib.rs @@ -8,9 +8,9 @@ use ext::actix::update_parameters_from_arguments; use ext::{ArgumentResolver, PathOperationResolver, PathOperations, PathResolver}; use proc_macro::TokenStream; -use quote::{format_ident, quote, quote_spanned, ToTokens}; +use quote::{format_ident, quote, quote_spanned, ToTokens, TokenStreamExt}; -use proc_macro2::{Ident, TokenStream as TokenStream2}; +use proc_macro2::{Group, Ident, Punct, TokenStream as TokenStream2}; use syn::{ bracketed, parse::{Parse, ParseStream}, @@ -25,7 +25,9 @@ mod component_type; mod ext; mod info; mod path; +mod property; mod request_body; +mod response; use proc_macro_error::*; @@ -334,6 +336,44 @@ fn impl_paths>( ) } +/// Tokenizes slice or Vec of tokenizable items as slice reference (`&[...]`) correctly to OpenAPI JSON. +struct ValueArray(Vec) +where + V: Sized + ToTokens; + +impl FromIterator for ValueArray +where + V: Sized + ToTokens, +{ + fn from_iter>(iter: T) -> Self { + Self { + 0: iter.into_iter().collect::>(), + } + } +} + +impl ToTokens for ValueArray +where + T: Sized + ToTokens, +{ + fn to_tokens(&self, tokens: &mut TokenStream2) { + tokens.append(Punct::new('&', proc_macro2::Spacing::Joint)); + + tokens.append(Group::new( + proc_macro2::Delimiter::Bracket, + self.0 + .iter() + .fold(Punctuated::new(), |mut punctuated, item| { + punctuated.push_value(item); + punctuated.push_punct(Punct::new(',', proc_macro2::Spacing::Alone)); + + punctuated + }) + .to_token_stream(), + )); + } +} + enum Deprecated { True, False, diff --git a/utoipa-gen/src/path.rs b/utoipa-gen/src/path.rs index 80cad06e..1e86d118 100644 --- a/utoipa-gen/src/path.rs +++ b/utoipa-gen/src/path.rs @@ -8,82 +8,61 @@ use syn::{ parse::{Parse, ParseStream}, parse2, punctuated::Punctuated, - token::{Bracket, Token}, - LitInt, LitStr, Token, + token::Bracket, + LitBool, LitInt, LitStr, Token, }; use crate::{ component_type::{ComponentFormat, ComponentType}, request_body::RequestBodyAttr, + response::{Response, Responses}, Deprecated, Required, }; const PATH_STRUCT_PREFIX: &str = "__path_"; -// #[utoipa::path(delete, -// 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"), -// (status = 500, description = "internal server error", -// body = String, content_type = "text/plain") -// (400, "my bad error", u64), -// (404, "vault not found"), -// (status = 500, description = "internal server error", body = String, content_type = "text/plain") -// ], -// params = [ -// ("myval" = String, description = "this is description"), -// ("myval", description = "this is description"), -// ("myval" = String, path, required, deprecated, description = "this is description"), -// ] -// )] - -// #[utoipa::response( -// status = 200, -// description = "success response", -// body = String, -// content_type = "text/plain" -// )] -// #[utoipa::response( -// status = 400, -// description = "this is bad request", -// body = String, -// content_type = "application/json" -// )] -// #[utoipa::response( -// status = 500, -// description = "internal server error", -// body = Error, -// content_type = "text/plain" -// )] -// #[utoipa::response( -// status = 404, -// description = "item not found", -// body = i32 // because body type is primitive the content_type is not necessary -// )] -// implementation should make assumptions based on response body type. If response body type is primitive type -// content_type is set to text/pain by default - -/// PathAttr is parsed #[path(...)] proc macro and its attributes. +/// PathAttr is parsed `#[utoipa::path(...)]` proc macro and its attributes. /// Parsed attributes can be used to override or append OpenAPI Path /// options. +/// +/// # Example +/// ```text +/// #[utoipa::path(delete, +/// operation_id = "custom_operation_id", +/// path = "/custom/path/{id}/{digest}", +/// tag = "groupping_tag" +/// request_body = [Foo] +/// responses = [ +/// (status = 200, description = "success update Foos", body = [Foo], content_type = "application/json", +/// headers = [ +/// ("fooo-bar" = String, description = "custom header value") +/// ] +/// ), +/// (status = 500, description = "internal server error", body = String, content_type = "text/plain", +/// headers = [ +/// ("fooo-bar" = String, description = "custom header value") +/// ] +/// ), +/// ], +/// params = [ +/// ("id" = u64, description = "Id of Foo"), +/// ("digest", description = "Foos message digest of last updated"), +/// ("x-csrf-token", header, required, deprecated), +/// ] +/// )] +/// ``` #[derive(Default)] #[cfg_attr(feature = "debug", derive(Debug))] pub struct PathAttr { path_operation: Option, request_body: Option, - responses: Vec, + responses: Vec, path: Option, operation_id: Option, tag: Option, pub params: Option>, } -/// Parse implementation for PathAttr will parse arguments -/// exhaustively. impl Parse for PathAttr { fn parse(input: syn::parse::ParseStream) -> syn::Result { let mut path_attr = PathAttr::default(); @@ -139,8 +118,8 @@ impl Parse for PathAttr { .expect_or_abort("expected responses to be group separated by comma (,)"); path_attr.responses = groups - .iter() - .map(|group| parse2::(group.stream()).unwrap_or_abort()) + .into_iter() + .map(|group| syn::parse2::(group.stream()).unwrap_or_abort()) .collect::>(); } "params" => { @@ -262,52 +241,6 @@ impl ToTokens for PathOperation { } } -/// Parsed representation of response argument within `#[path(...)]` macro attribute. -/// Response is typically formed from group such like (200, "success", String) where -/// * 200 number represents http status code -/// * "success" stands for response description included in documentation -/// * String represents type of response body -#[derive(Default)] -#[cfg_attr(feature = "debug", derive(Debug))] -struct PathResponse { - status_code: i32, - message: String, - response_type: Option, -} - -impl Parse for PathResponse { - fn parse(input: ParseStream) -> syn::Result { - let mut response = PathResponse::default(); - - loop { - let next_type = input.lookahead1(); - if next_type.peek(LitInt) { - response.status_code = input - .parse::() - .unwrap() - .base10_parse() - .unwrap_or_abort(); - } else if next_type.peek(LitStr) { - response.message = input.parse::().unwrap().value(); - } else if next_type.peek(syn::Ident) { - response.response_type = Some(input.parse::().unwrap()); - } else { - return Err(next_type.error()); - } - - if input.peek(Token![,]) { - input.parse::().unwrap(); - } - - if input.is_empty() { - break; - } - } - - Ok(response) - } -} - /// Parameter of request suchs as in path, header, query or cookie /// /// For example path `/users/{id}` the path parameter is used to define @@ -376,6 +309,18 @@ impl Parse for Parameter { input.parse::().unwrap(); } + let parse_bool = |input: ParseStream| { + // support assign form as: required = bool + if input.peek(Token![=]) && input.peek2(LitBool) { + input.parse::().unwrap(); + + input.parse::().unwrap().value() + } else { + // quick form as: required + true + } + }; + loop { let ident = input.parse::().unwrap(); let name = &*ident.to_string(); @@ -386,8 +331,8 @@ impl Parse for Parameter { parameter.required = true; // all path parameters are required by default } } - "required" => parameter.required = true, - "deprecated" => parameter.deprecated = true, + "required" => parameter.required = parse_bool(input), + "deprecated" => parameter.deprecated = parse_bool(input), "description" => { if input.peek(Token![=]) { input.parse::().unwrap(); @@ -584,6 +529,7 @@ impl ToTokens for Path { description: self.doc_comments.as_ref(), parameters: self.path_attr.params.as_ref(), request_body: self.path_attr.request_body.as_ref(), + responses: self.path_attr.responses.as_ref(), }; tokens.extend(quote! { @@ -620,6 +566,7 @@ struct Operation<'a> { deprecated: &'a Option, parameters: Option<&'a Vec>, request_body: Option<&'a RequestBodyAttr>, + responses: &'a Vec, } impl ToTokens for Operation<'_> { @@ -632,12 +579,9 @@ impl ToTokens for Operation<'_> { }) } - // impl dummy responses + let responses = Responses(self.responses); tokens.extend(quote! { - .with_response( - "200", // TODO resolve this status - utoipa::openapi::response::Response::new("this is response message") - ) + .with_responses(#responses) }); // // .with_security() let path_struct = format_ident!("{}{}", PATH_STRUCT_PREFIX, self.fn_name); diff --git a/utoipa-gen/src/property.rs b/utoipa-gen/src/property.rs new file mode 100644 index 00000000..ae66256a --- /dev/null +++ b/utoipa-gen/src/property.rs @@ -0,0 +1,54 @@ +use proc_macro2::Ident; +use quote::{quote, ToTokens}; + +use crate::component_type::{ComponentFormat, ComponentType}; + +/// Tokenizable object property. It is used as a object property for components or as property +/// of request or response body or response header. +pub(crate) struct Property<'a> { + pub(crate) is_array: bool, + pub(crate) component_type: ComponentType<'a>, +} + +impl<'a> Property<'a> { + pub fn new(is_array: bool, ident: &'a Ident) -> Self { + Self { + is_array, + component_type: ComponentType(ident), + } + } +} + +impl ToTokens for Property<'_> { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + if self.component_type.is_primitive() { + let component_type = &self.component_type; + let mut component = quote! { + utoipa::openapi::Property::new( + #component_type + ) + }; + + let format = ComponentFormat(self.component_type.0); + if format.is_known_format() { + component.extend(quote! { + .with_format(#format) + }) + } + + tokens.extend(component); + } else { + let name = &*self.component_type.0.to_string(); + + tokens.extend(quote! { + utoipa::openapi::Ref::from_component_name(#name) + }) + }; + + if self.is_array { + tokens.extend(quote! { + .to_array() + }); + } + } +} diff --git a/utoipa-gen/src/request_body.rs b/utoipa-gen/src/request_body.rs index c0452b8e..3a9bb232 100644 --- a/utoipa-gen/src/request_body.rs +++ b/utoipa-gen/src/request_body.rs @@ -7,10 +7,7 @@ use syn::{ LitBool, LitStr, Token, }; -use crate::{ - component_type::{ComponentFormat, ComponentType}, - MediaType, Required, -}; +use crate::{property::Property, MediaType, Required}; /// Parsed information related to requst body of path. /// @@ -128,42 +125,11 @@ impl Parse for RequestBodyAttr { 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 property = Property::new(self.content.is_array, self.content.ty.as_ref().unwrap()); let content_type = if let Some(ref content_type) = self.content_type { content_type - } else if component_type.is_primitive() { + } else if property.component_type.is_primitive() { "text/plain" } else { "application/json" @@ -171,7 +137,7 @@ impl ToTokens for RequestBodyAttr { tokens.extend(quote! { utoipa::openapi::request_body::RequestBody::new() - .with_content(#content_type, utoipa::openapi::request_body::Content::new(#component)) + .with_content(#content_type, #property) }); if let Some(required) = self.required { diff --git a/utoipa-gen/src/response.rs b/utoipa-gen/src/response.rs new file mode 100644 index 00000000..c347e4dc --- /dev/null +++ b/utoipa-gen/src/response.rs @@ -0,0 +1,332 @@ +use proc_macro2::{Group, Ident, TokenStream as TokenStream2}; +use proc_macro_error::ResultExt; +use quote::{quote, ToTokens}; +use syn::{ + bracketed, + parse::{Parse, ParseStream}, + punctuated::Punctuated, + token::Comma, + LitInt, LitStr, Token, +}; + +use crate::{property::Property, MediaType}; + +/// Parsed representation of response attributes from `#[utoipa::path]` attribute. +/// +/// Configuration options: +/// * **status** Http status code of the response e.g. `200` +/// * **description** Description of the response +/// * **body** Optional response body type. Can be primitive, struct or enum type and slice types are supported +/// by wrapping the type with brackets e.g. `[Foo]` +/// * **content_type** Optional content type of the response e.g. `"text/plain"` +/// * **headers** Optional response headers. See [`Header`] for detailed description and usage +/// +/// Only status and description are mandatory for describing response. Responses which does not +/// define `body = type` are treated as they would not return any response back. Content type of +/// responses will be if not provided determined automatically suggesting that any primitive type such as +/// integer, string or boolean are treated as `"text/plain"` and struct types are treated as `"application/json"`. +/// +/// # Examples +/// +/// Minimal example example providing responses. +/// ```text +/// #[utoipa::path( +/// ... +/// responses = [ +/// (status = 200, description = "success response"), +/// ] +/// )] +/// ``` +/// +/// Example with all supported configuration. +/// ```text +/// #[utoipa::path( +/// ... +/// responses = [ +/// (status = 200, description = "success response", body = [Foo], content_type = "text/xml", +/// headers = [ +/// ("xrfs-token" = String, description = "New csrf token sent back in response header") +/// ] +/// ), +/// ] +/// )] +/// ``` +/// +/// Example with multiple responses. +/// ```text +/// #[utoipa::path( +/// ... +/// responses = [ +/// (status = 200, description = "success response", body = [Foo]), +/// (status = 401, description = "unauthorized to access", body = UnautorizedError), +/// (status = 404, description = "foo not found", body = NotFoundError), +/// ] +/// )] +/// ``` +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct Response { + status_code: i32, + description: String, + response_type: Option, + content_type: Option, + headers: Vec
, +} + +impl Parse for Response { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let mut response = Response::default(); + + loop { + let ident = input + .parse::() + .expect_or_abort("unparseable response expected to find Ident"); + let name = &*ident.to_string(); + + match name { + "status" => { + response.status_code = parse_next(&input, || { + input + .parse::() + .unwrap() + .base10_parse() + .unwrap_or_abort() + }); + } + "description" => { + response.description = + parse_next(&input, || input.parse::().unwrap_or_abort().value()); + } + "body" => { + response.response_type = Some(parse_next(&input, || { + input.parse::().unwrap_or_abort() + })); + } + "content_type" => { + response.content_type = Some(parse_next(&input, || { + input.parse::().unwrap_or_abort().value() + })); + } + "headers" => { + let groups = parse_next(&input, || { + let content; + bracketed!(content in input); + Punctuated::::parse_terminated(&content) + }) + .expect_or_abort("expected headers in brackets [..]"); + + response.headers = groups + .into_iter() + .map(|group| syn::parse2::
(group.stream()).unwrap_or_abort()) + .collect::>(); + } + _ => { + let error_msg = format!( + "unexpected attribute: {}, + expected values: status, description, body, content_type, headers", + &name + ); + return Err(input.error(error_msg)); + } + } + + if input.peek(Token![,]) { + input.parse::().unwrap_or_abort(); + } + if input.is_empty() { + break; + } + } + + Ok(response) + } +} + +#[inline] +fn parse_next(input: &ParseStream, next: impl FnOnce() -> T) -> T { + input + .parse::() + .expect_or_abort("expected euqals sign token (=)"); + next() +} + +pub struct Responses<'a>(pub &'a [Response]); + +impl ToTokens for Responses<'_> { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + tokens.extend(quote! { utoipa::openapi::Responses::new() }); + + self.0.iter().for_each(|response| { + let status = response.status_code.to_string(); + let description = &response.description; + + let mut response_tokens = quote! { + utoipa::openapi::Response::new(#description) + }; + + if let Some(ref response_body_type) = response.response_type { + let body_type = response_body_type.ty.as_ref().unwrap(); + + let component = Property::new(response_body_type.is_array, body_type); + let content_type = if let Some(ref content_type) = response.content_type { + content_type + } else if component.component_type.is_primitive() { + "text/plain" + } else { + "application/json" + }; + + response_tokens.extend(quote! { + .with_content(#content_type, #component) + }) + } + + response.headers.iter().for_each(|header| { + let name = &header.name; + let header_tokens = new_header_tokens(header); + + response_tokens.extend(quote! { + .with_header(#name, #header_tokens) + }) + }); + + tokens.extend(quote! { + .with_response(#status, #response_tokens) + }); + }) + } +} + +#[inline] +fn new_header_tokens(header: &Header) -> TokenStream2 { + let mut header_tokens = if let Some(ref header_type) = header.media_type { + // header property with custom type + let header_type = Property::new(header_type.is_array, header_type.ty.as_ref().unwrap()); + + quote! { + utoipa::openapi::Header::new(#header_type) + } + } else { + // default header (string type) + quote! { + utoipa::openapi::Header::default() + } + }; + + if let Some(ref description) = header.description { + header_tokens.extend(quote! { + .with_description(#description) + }) + } + + header_tokens +} + +/// 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 `descripiton = ".."` 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 multiplea 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, + media_type: Option, + description: Option, +} + +impl Parse for Header { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let mut header = Header { + name: input + .parse::() + .expect_or_abort("unexpected attribute for Header name, expected LitStr") + .value(), + ..Default::default() + }; + + if input.peek(Token![=]) { + input.parse::().unwrap_or_abort(); + + header.media_type = Some(input.parse::().unwrap_or_abort()); + } + + if input.peek(Token![,]) { + input.parse::().unwrap_or_abort(); + } + + if input.peek(syn::Ident) { + let description = input + .parse::() + .expect_or_abort("unexpected attribute for Header description, expected Ident"); + + if description == "description" { + if input.peek(Token![=]) { + input.parse::().unwrap_or_abort(); + } + + let description = input.parse::().unwrap_or_abort().value(); + header.description = Some(description); + } else { + return Err(syn::Error::new( + description.span(), + format!( + "unexpected attribute: {}, expected: description", + description + ), + )); + } + } + + Ok(header) + } +}