From 79bc7dbd9b96621328b53a47b8b292a7eba98886 Mon Sep 17 00:00:00 2001 From: Juha Kukkonen Date: Sun, 11 Dec 2022 22:15:57 +0200 Subject: [PATCH] Add external ref(...) attribute Add external ref(...) attribute for response body and reqeust body. This allows to use external json file schema for request body or reponse body. This PR will not add any support for giving guarantees of accessibility of the externally referenced json value. Those guaratees are deemed to be done by the user. This commit will add support for following syntax. ```rust // external ref on request body #[utoipa::path(get, path = "/item", request_body(content = ref("./MyUser.json")))] #[allow(dead_code)] fn get_item() {} // external ref on response body #[utoipa::path( get, path = "/foo", responses( (status = 200, body = ref("./MyUser.json")) ) )] #[allow(unused)] fn get_item() {} ``` --- utoipa-gen/src/lib.rs | 32 +++++--- utoipa-gen/src/path.rs | 39 ++++++++-- utoipa-gen/src/path/parameter.rs | 4 +- utoipa-gen/src/path/request_body.rs | 56 +++++++++----- utoipa-gen/src/path/response.rs | 75 ++++++++++++------- utoipa-gen/tests/path_response_derive_test.rs | 37 +++++++++ utoipa-gen/tests/request_body_derive_test.rs | 27 +++++++ 7 files changed, 204 insertions(+), 66 deletions(-) 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" + } + } + }) + ) +}