diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs index d9c52047..9ea81b69 100644 --- a/utoipa-gen/src/lib.rs +++ b/utoipa-gen/src/lib.rs @@ -588,16 +588,21 @@ pub fn derive_to_schema(input: TokenStream) -> TokenStream { /// # Request Body Attributes /// /// **Simple format definition by `request_body = ...`** -/// * `request_body = Type` or `request_body = inline(Type)`. The given _`Type`_ can be any -/// Rust type that is JSON parseable. It can be Option, Vec or Map etc. With _`inline(...)`_ -/// the schema will be inlined instead of a referenced which is the default for -/// [`ToSchema`][to_schema] types. +/// * _`request_body = Type`_, _`request_body = inline(Type)`_ or _`request_body = ref("...")`_. +/// The given _`Type`_ can be any Rust type that is JSON parseable. It can be Option, Vec or Map etc. +/// With _`inline(...)`_ the schema will be inlined instead of a referenced which is the default for +/// [`ToSchema`][to_schema] types. _`ref("./external.json")`_ can be used to reference external +/// json file for body schema. **Note!** Utoipa does **not** guarantee that free form _`ref`_ is accessbile via +/// OpenAPI doc or Swagger UI, users are eligible to make these guarantees. /// /// **Advanced format definition by `request_body(...)`** -/// * `content = ...` Can be `content = Type` or `content = inline(Type)`. The given _`Type`_ can be any -/// Rust type that is JSON parseable. It can be Option, Vec or Map etc. With _`inline(...)`_ -/// the schema will be inlined instead of a referenced which is the default for -/// [`ToSchema`][to_schema] types. +/// * `content = ...` Can be _`content = Type`_, _`content = inline(Type)`_ or _`content = ref("...")`_. The +/// given _`Type`_ can be any Rust type that is JSON parseable. It can be Option, Vec +/// or Map etc. With _`inline(...)`_ the schema will be inlined instead of a referenced +/// which is the default for [`ToSchema`][to_schema] types. _`ref("./external.json")`_ +/// can be used to reference external json file for body schema. **Note!** Utoipa does **not** guarantee +/// that free form _`ref`_ is accessbile via OpenAPI doc or Swagger UI, users are eligible +/// to make these guarantees. /// /// * `description = "..."` Define the description for the request body object as str. /// @@ -631,10 +636,13 @@ pub fn derive_to_schema(input: TokenStream) -> TokenStream { /// * `description = "..."` Define description for the response as str. /// /// * `body = ...` Optional response body object type. When left empty response does not expect to send any -/// response body. Can be `body = Type` or `body = inline(Type)`. The given _`Type`_ can be any -/// Rust type that is JSON parseable. It can be Option, Vec or Map etc. With _`inline(...)`_ -/// the schema will be inlined instead of a referenced which is the default for -/// [`ToSchema`][to_schema] types. +/// response body. Can be _`body = Type`_, _`body = inline(Type)`_, or _`body = ref("...")`_. +/// The given _`Type`_ can be any Rust type that is JSON parseable. It can be Option, Vec or Map etc. +/// With _`inline(...)`_ the schema will be inlined instead of a referenced which is the default for +/// [`ToSchema`][to_schema] types. _`ref("./external.json")`_ +/// can be used to reference external json file for body schema. **Note!** Utoipa does **not** guarantee +/// that free form _`ref`_ is accessbile via OpenAPI doc or Swagger UI, users are eligible +/// to make these guarantees. /// /// * `content_type = "..." | content_type = [...]` Can be used to override the default behavior of auto resolving the content type /// from the `body` attribute. If defined the value should be valid content type such as diff --git a/utoipa-gen/src/path.rs b/utoipa-gen/src/path.rs index aedf50d5..d2e8c1bc 100644 --- a/utoipa-gen/src/path.rs +++ b/utoipa-gen/src/path.rs @@ -6,8 +6,8 @@ use proc_macro_error::abort; use quote::{format_ident, quote, ToTokens}; use syn::punctuated::Punctuated; use syn::token::Paren; -use syn::Type; use syn::{parenthesized, parse::Parse, Token}; +use syn::{LitStr, Type}; use crate::component::{GenericType, TypeTree}; use crate::{parse_utils, Deprecated}; @@ -532,21 +532,48 @@ impl ToTokens for Operation<'_> { } } +/// Represents either `ref("...")` or `Type` that can be optionally inlined with `inline(Type)`. +#[cfg_attr(feature = "debug", derive(Debug))] +enum PathType { + Ref(String), + Type(InlineType), +} + +impl Parse for PathType { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let fork = input.fork(); + let is_ref = if (fork.parse::>()?).is_some() { + fork.peek(Paren) + } else { + false + }; + + if is_ref { + input.parse::()?; + let ref_stream; + parenthesized!(ref_stream in input); + Ok(Self::Ref(ref_stream.parse::()?.value())) + } else { + Ok(Self::Type(input.parse()?)) + } + } +} + // inline(syn::Type) | syn::Type #[cfg_attr(feature = "debug", derive(Debug))] -struct InlineableType { +struct InlineType { ty: Type, is_inline: bool, } -impl InlineableType { +impl InlineType { /// Get's the underlying [`syn::Type`] as [`TypeTree`]. fn as_type_tree(&self) -> TypeTree { TypeTree::from_type(&self.ty) } } -impl Parse for InlineableType { +impl Parse for InlineType { fn parse(input: syn::parse::ParseStream) -> syn::Result { let fork = input.fork(); let is_inline = if let Some(ident) = fork.parse::>()? { @@ -565,7 +592,7 @@ impl Parse for InlineableType { input.parse::()? }; - Ok(InlineableType { ty, is_inline }) + Ok(InlineType { ty, is_inline }) } } @@ -582,7 +609,7 @@ pub trait PathTypeTree { impl PathTypeTree for TypeTree<'_> { /// Resolve default content type based on curren [`Type`]. - fn get_default_content_type(&self) -> &str { + fn get_default_content_type(&self) -> &'static str { if self.is_array() && self .children diff --git a/utoipa-gen/src/path/parameter.rs b/utoipa-gen/src/path/parameter.rs index 333a4532..538c8145 100644 --- a/utoipa-gen/src/path/parameter.rs +++ b/utoipa-gen/src/path/parameter.rs @@ -16,7 +16,7 @@ use syn::{ use crate::ext::{ArgumentIn, ValueArgument}; use crate::{component::TypeTree, parse_utils, AnyValue, Deprecated, Required}; -use super::{media_type::MediaTypeSchema, InlineableType, PathTypeTree}; +use super::{media_type::MediaTypeSchema, InlineType, PathTypeTree}; /// Parameter of request suchs as in path, header, query or cookie /// @@ -104,7 +104,7 @@ pub struct ValueParameter<'a> { parameter_ext: Option, /// Type only when value parameter is parsed - parsed_type: Option, + parsed_type: Option, } impl<'p> ValueParameter<'p> { diff --git a/utoipa-gen/src/path/request_body.rs b/utoipa-gen/src/path/request_body.rs index 25a6cf79..a304a72d 100644 --- a/utoipa-gen/src/path/request_body.rs +++ b/utoipa-gen/src/path/request_body.rs @@ -8,7 +8,7 @@ use crate::{parse_utils, AnyValue, Array, Required}; use super::example::Example; use super::media_type::MediaTypeSchema; -use super::{InlineableType, PathTypeTree}; +use super::{PathType, PathTypeTree}; /// Parsed information related to requst body of path. /// @@ -50,7 +50,7 @@ use super::{InlineableType, PathTypeTree}; #[derive(Default)] #[cfg_attr(feature = "debug", derive(Debug))] pub struct RequestBodyAttr { - content: Option, + content: Option, content_type: Option, description: Option, example: Option, @@ -135,16 +135,23 @@ impl Parse for RequestBodyAttr { impl ToTokens for RequestBodyAttr { fn to_tokens(&self, tokens: &mut TokenStream2) { if let Some(body_type) = &self.content { - let type_tree = body_type.as_type_tree(); - let media_type_schema = MediaTypeSchema { - type_tree: &type_tree, - is_inline: body_type.is_inline, + let media_type_schema = match body_type { + PathType::Ref(ref_type) => quote! { + utoipa::openapi::schema::Ref::new(#ref_type) + }, + PathType::Type(body_type) => { + let type_tree = body_type.as_type_tree(); + MediaTypeSchema { + type_tree: &type_tree, + is_inline: body_type.is_inline, + } + .to_token_stream() + } + }; + let mut content = quote! { + utoipa::openapi::content::ContentBuilder::new() + .schema(#media_type_schema) }; - let content_type = self - .content_type - .as_deref() - .unwrap_or_else(|| type_tree.get_default_content_type()); - let mut content = quote! { utoipa::openapi::content::ContentBuilder::new().schema(#media_type_schema) }; if let Some(ref example) = self.example { content.extend(quote! { @@ -164,12 +171,27 @@ impl ToTokens for RequestBodyAttr { )) } - let required: Required = (!type_tree.is_option()).into(); - tokens.extend(quote! { - utoipa::openapi::request_body::RequestBodyBuilder::new() - .content(#content_type, #content.build()) - .required(Some(#required)) - }); + match body_type { + PathType::Ref(_) => { + tokens.extend(quote! { + utoipa::openapi::request_body::RequestBodyBuilder::new() + .content("application/json", #content.build()) + }); + } + PathType::Type(body_type) => { + let type_tree = body_type.as_type_tree(); + let content_type = self + .content_type + .as_deref() + .unwrap_or_else(|| type_tree.get_default_content_type()); + let required: Required = (!type_tree.is_option()).into(); + tokens.extend(quote! { + utoipa::openapi::request_body::RequestBodyBuilder::new() + .content(#content_type, #content.build()) + .required(Some(#required)) + }); + } + } } if let Some(ref description) = self.description { diff --git a/utoipa-gen/src/path/response.rs b/utoipa-gen/src/path/response.rs index 041f096a..eeaee413 100644 --- a/utoipa-gen/src/path/response.rs +++ b/utoipa-gen/src/path/response.rs @@ -11,10 +11,11 @@ use syn::{ Error, ExprPath, LitInt, LitStr, Token, }; -use crate::{component::TypeTree, parse_utils, AnyValue, Array}; +use crate::{parse_utils, AnyValue, Array}; use super::{ - example::Example, media_type::MediaTypeSchema, status::STATUS_CODES, InlineableType, PathTypeTree, + example::Example, media_type::MediaTypeSchema, status::STATUS_CODES, InlineType, PathType, + PathTypeTree, }; #[cfg_attr(feature = "debug", derive(Debug))] @@ -62,7 +63,7 @@ impl ResponseTuple { } // Use with the `response` attribute, this will fail if an incompatible attribute has already been set - fn set_ref_type(&mut self, span: Span, ty: InlineableType) -> syn::Result<()> { + fn set_ref_type(&mut self, span: Span, ty: InlineType) -> syn::Result<()> { match &mut self.inner { None => self.inner = Some(ResponseTupleInner::Ref(ty)), Some(ResponseTupleInner::Ref(r)) => *r = ty, @@ -77,14 +78,14 @@ impl ResponseTuple { #[cfg_attr(feature = "debug", derive(Debug))] enum ResponseTupleInner { Value(ResponseValue), - Ref(InlineableType), + Ref(InlineType), } #[derive(Default)] #[cfg_attr(feature = "debug", derive(Debug))] pub struct ResponseValue { description: String, - response_type: Option, + response_type: Option, content_type: Option>, headers: Vec
, example: Option, @@ -197,16 +198,27 @@ impl ToTokens for ResponseTuple { utoipa::openapi::ResponseBuilder::new().description(#description) }); - let create_content = |type_tree: &TypeTree, - is_inline: bool, + let create_content = |path_type: &PathType, example: &Option, examples: &Option>| -> TokenStream2 { - let media_schema_type = MediaTypeSchema { - type_tree, - is_inline, + let content_schema = match path_type { + PathType::Ref(ref_type) => quote! { + utoipa::openapi::schema::Ref::new(#ref_type) + } + .to_token_stream(), + PathType::Type(ref path_type) => { + let type_tree = path_type.as_type_tree(); + MediaTypeSchema { + type_tree: &type_tree, + is_inline: path_type.is_inline, + } + .to_token_stream() + } }; - let mut content = quote! { utoipa::openapi::ContentBuilder::new().schema(#media_schema_type) }; + + let mut content = + quote! { utoipa::openapi::ContentBuilder::new().schema(#content_schema) }; if let Some(ref example) = example { content.extend(quote! { @@ -226,41 +238,46 @@ impl ToTokens for ResponseTuple { )) } - content + quote! { + #content.build() + } }; if let Some(response_type) = &val.response_type { - let type_tree = response_type.as_type_tree(); - let content = create_content( - &type_tree, - response_type.is_inline, - &val.example, - &val.examples, - ); + let content = create_content(response_type, &val.example, &val.examples); if let Some(content_types) = val.content_type.as_ref() { content_types.iter().for_each(|content_type| { tokens.extend(quote! { - .content(#content_type, #content.build()) + .content(#content_type, #content) }) }) } else { - let default_type = type_tree.get_default_content_type(); - tokens.extend(quote! { - .content(#default_type, #content.build()) - }); + match response_type { + PathType::Ref(_) => { + tokens.extend(quote! { + .content("application/json", #content) + }); + } + PathType::Type(path_type) => { + let type_tree = path_type.as_type_tree(); + let default_type = type_tree.get_default_content_type(); + tokens.extend(quote! { + .content(#default_type, #content) + }) + } + } } } val.content .iter() .map(|Content(content_type, body, example, examples)| { - let type_tree = body.as_type_tree(); - let content = create_content(&type_tree, body.is_inline, example, examples); + let content = create_content(body, example, examples); (Cow::Borrowed(&**content_type), content) }) .for_each(|(content_type, content)| { - tokens.extend(quote! { .content(#content_type, #content.build()) }) + tokens.extend(quote! { .content(#content_type, #content) }) }); val.headers.iter().for_each(|header| { @@ -362,7 +379,7 @@ impl ToTokens for ResponseStatus { #[cfg_attr(feature = "debug", derive(Debug))] struct Content( String, - InlineableType, + PathType, Option, Option>, ); @@ -496,7 +513,7 @@ impl ToTokens for Responses<'_> { #[cfg_attr(feature = "debug", derive(Debug))] struct Header { name: String, - value_type: Option, + value_type: Option, description: Option, } diff --git a/utoipa-gen/tests/path_response_derive_test.rs b/utoipa-gen/tests/path_response_derive_test.rs index 5606bf40..1d4a5b0e 100644 --- a/utoipa-gen/tests/path_response_derive_test.rs +++ b/utoipa-gen/tests/path_response_derive_test.rs @@ -552,3 +552,40 @@ fn derive_path_with_mutliple_resposnes_with_multiple_examples() { }) ) } + +#[test] +fn path_response_with_external_ref() { + #[utoipa::path( + get, + path = "/foo", + responses( + (status = 200, body = ref("./MyUser.json")) + ) + )] + #[allow(unused)] + fn get_item() {} + + #[derive(utoipa::OpenApi)] + #[openapi(paths(get_item))] + struct ApiDoc; + + let doc = serde_json::to_value(&ApiDoc::openapi()).unwrap(); + let resopnses = doc.pointer("/paths/~1foo/get/responses").unwrap(); + + assert_json_eq!( + resopnses, + json!({ + "200": { + "content": { + "application/json": { + "schema": { + "$ref": + "./MyUser.json", + }, + }, + }, + "description": "", + }, + }) + ) +} diff --git a/utoipa-gen/tests/request_body_derive_test.rs b/utoipa-gen/tests/request_body_derive_test.rs index 05502fd9..2702a035 100644 --- a/utoipa-gen/tests/request_body_derive_test.rs +++ b/utoipa-gen/tests/request_body_derive_test.rs @@ -540,3 +540,30 @@ fn request_body_with_binary() { }) ) } + +#[test] +fn request_body_with_external_ref() { + #[utoipa::path(get, path = "/item", request_body(content = ref("./MyUser.json")))] + #[allow(dead_code)] + fn get_item() {} + + #[derive(utoipa::OpenApi)] + #[openapi(paths(get_item))] + struct ApiDoc; + + let doc = serde_json::to_value(&ApiDoc::openapi()).unwrap(); + + let content = doc + .pointer("/paths/~1item/get/requestBody/content") + .unwrap(); + assert_json_eq!( + content, + json!( + {"application/json": { + "schema": { + "$ref": "./MyUser.json" + } + } + }) + ) +}