Repository: juhaku/utoipa
Pull request #45 (2022-03-17 19:13:36)

Improve api
* Improve utoipa-swagger-ui remove unnecessary clone
* Improve utoipa-gen -> implement Response and Header ToTokens traits
* Add support for multiple content types for response body
* Add external_docs for OpenApi


Patch:
diff --git a/src/openapi/mod.rs b/src/openapi/mod.rs
index 7a8e592..8c9c951 100644
--- a/src/openapi/mod.rs
+++ b/src/openapi/mod.rs
@@ -113,6 +113,12 @@ impl OpenApi {
         self
     }
 
+    pub fn with_external_docs(mut self, external_docs: ExternalDocs) -> Self {
+        self.external_docs = Some(external_docs);
+
+        self
+    }
+
     #[cfg(feature = "serde_json")]
     pub fn to_json(&self) -> Result<String, serde_json::Error> {
         serde_json::to_string(self)
diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs
index 7aa8e21..462d92f 100644
--- a/utoipa-gen/src/lib.rs
+++ b/utoipa-gen/src/lib.rs
@@ -265,6 +265,9 @@ pub fn derive_component(input: TokenStream) -> TokenStream {
 ///   from the `body` attribute. If defined the value should be valid content type such as
 ///   _`application/json`_. By default the content type is _`text/plain`_ for
 ///   [primitive Rust types][primitive] and _`application/json`_ for struct and complex enum types.
+///   Content type can also be slice of **content_type** values if the endpoint support returning multiple
+///  response content types. E.g _`["application/json", "text/xml"]`_ would indicate that endpoint can return both
+///  _`json`_ and _`xml`_ formats.
 /// * **headers** Slice of response headers that are returned back to a caller.
 /// * **example** Can be either `json!(...)` or literal str that can be parsed to json. `json!`
 ///   should be something that `serde_json::json!` can parse as a `serde_json::Value`. [^json]
@@ -282,6 +285,11 @@ pub fn derive_component(input: TokenStream) -> TokenStream {
 /// )
 /// ```
 ///
+/// **Response with multiple response content types:**
+/// ```text
+/// (status = 200, description = "Success response", body = Pet, content_type = ["application/json", "text/xml"])
+/// ```
+///
 /// # Response Header Attributes
 ///
 /// * **name** Name of the header. E.g. _`x-csrf-token`_
@@ -504,6 +512,8 @@ pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream {
 ///   the tag is derived from path given to **handlers** list or if undefined then `crate` is used by default.
 ///   Alternatively the tag name can be given to path operation via [`#[utoipa::path(...)]`][path] macro.
 ///   Tag can be used to define extra information for the api to produce richer documentation.
+/// * **external_docs** Can be used to reference external resource to the OpenAPI doc for extended documentation.
+///   External docs can be in [`OpenApi`][openapi_struct] or in [`Tag`][tags] level.
 ///
 /// OpenApi derive macro will also derive [`Info`][info] for OpenApi specification using Cargo
 /// environment variables.
@@ -556,12 +566,14 @@ pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream {
 ///     tags(
 ///         (name = "pets::api", description = "All about pets",
 ///             external_docs(url = "http://more.about.pets.api", description = "Find out more"))
-///     )
+///     ),
+///     external_docs(url = "http://more.about.our.apis", description = "More about our APIs")
 /// )]
 /// struct ApiDoc;
 /// ```
 ///
 /// [openapi]: trait.OpenApi.html
+/// [openapi_struct]: openapi/struct.OpenApi.html
 /// [component]: derive.Component.html
 /// [path]: attr.path.html
 /// [modify]: trait.Modify.html
diff --git a/utoipa-gen/src/openapi.rs b/utoipa-gen/src/openapi.rs
index eac8032..cef23ea 100644
--- a/utoipa-gen/src/openapi.rs
+++ b/utoipa-gen/src/openapi.rs
@@ -26,6 +26,7 @@ pub struct OpenApiAttr {
     modifiers: Punctuated<Modifier, Comma>,
     security: Option<Array<SecurityRequirementAttr>>,
     tags: Option<Array<Tag>>,
+    external_docs: Option<ExternalDocs>,
 }
 
 pub fn parse_openapi_attrs(attrs: &[Attribute]) -> Option<OpenApiAttr> {
@@ -38,7 +39,7 @@ pub fn parse_openapi_attrs(attrs: &[Attribute]) -> Option<OpenApiAttr> {
 impl Parse for OpenApiAttr {
     fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
         const EXPECTED_ATTRIBUTE: &str =
-            "unexpected attribute, expected any of: handlers, components, modifiers, security, tags";
+            "unexpected attribute, expected any of: handlers, components, modifiers, security, tags, external_docs";
         let mut openapi = OpenApiAttr::default();
 
         while !input.is_empty() {
@@ -67,6 +68,11 @@ impl Parse for OpenApiAttr {
                     parenthesized!(tags in input);
                     openapi.tags = Some(parse_utils::parse_groups(&tags)?);
                 }
+                "external_docs" => {
+                    let external_docs;
+                    parenthesized!(external_docs in input);
+                    openapi.external_docs = Some(external_docs.parse()?);
+                }
                 _ => {
                     return Err(Error::new(ident.span(), EXPECTED_ATTRIBUTE));
                 }
@@ -256,6 +262,11 @@ impl ToTokens for OpenApi {
                 .with_tags(#tags)
             }
         });
+        let external_docs = attributes.external_docs.as_ref().map(|external_docs| {
+            quote! {
+                .with_external_docs(#external_docs)
+            }
+        });
 
         tokens.extend(quote! {
             impl utoipa::OpenApi for #ident {
@@ -264,7 +275,8 @@ impl ToTokens for OpenApi {
                     let mut openapi = utoipa::openapi::OpenApi::new(#info, #path_items)
                         .with_components(#components)
                         #securities
-                        #tags;
+                        #tags
+                        #external_docs;
 
                     let _mods: [&dyn utoipa::Modify; #modifiers_len] = [#modifiers];
                     _mods.iter().for_each(|modifier| modifier.modify(&mut openapi));
diff --git a/utoipa-gen/src/path/response.rs b/utoipa-gen/src/path/response.rs
index 349a981..3482ba1 100644
--- a/utoipa-gen/src/path/response.rs
+++ b/utoipa-gen/src/path/response.rs
@@ -1,70 +1,25 @@
 use proc_macro2::{Ident, TokenStream as TokenStream2};
 use quote::{quote, ToTokens};
-use syn::{parenthesized, parse::Parse, Error, LitInt, LitStr, Token};
+use syn::{
+    bracketed, parenthesized,
+    parse::Parse,
+    punctuated::Punctuated,
+    token::{Bracket, Comma},
+    Error, LitInt, LitStr, Token,
+};
 
 use crate::{parse_utils, Example, Type};
 
 use super::{property::Property, ContentTypeResolver};
 
 /// 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<Type>,
-    content_type: Option<String>,
+    content_type: Option<Vec<String>>,
     headers: Vec<Header>,
     example: Option<Example>,
 }
@@ -108,7 +63,23 @@ impl Parse for Response {
                     );
                 }
                 "content_type" => {
-                    response.content_type = Some(parse_utils::parse_next_literal_str(input)?);
+                    response.content_type = Some(parse_utils::parse_next(input, || {
+                        let look_content_type = input.lookahead1();
+                        if look_content_type.peek(LitStr) {
+                            Ok(vec![input.parse::<LitStr>()?.value()])
+                        } else if look_content_type.peek(Bracket) {
+                            let content_types;
+                            bracketed!(content_types in input);
+                            Ok(
+                                Punctuated::<LitStr, Comma>::parse_terminated(&content_types)?
+                                    .into_iter()
+                                    .map(|lit| lit.value())
+                                    .collect(),
+                            )
+                        } else {
+                            Err(look_content_type.error())
+                        }
+                    })?);
                 }
                 "headers" => {
                     let headers;
@@ -133,85 +104,65 @@ impl Parse for Response {
     }
 }
 
-pub struct Responses<'a>(pub &'a [Response]);
+impl ToTokens for Response {
+    fn to_tokens(&self, tokens: &mut TokenStream2) {
+        let description = &self.description;
+        tokens.extend(quote! {
+            utoipa::openapi::Response::new(#description)
+        });
 
-impl ContentTypeResolver for Responses<'_> {}
+        if let Some(ref body_type) = self.response_type {
+            let body_ty = &body_type.ty;
 
-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)
+            let component = Property::new(body_type.is_array, body_ty);
+            let mut content = quote! {
+                utoipa::openapi::Content::new(#component)
             };
 
-            if let Some(ref response_body_type) = response.response_type {
-                let body_type = &response_body_type.ty;
-
-                let component = Property::new(response_body_type.is_array, body_type);
-
-                let content_type = self.resolve_content_type(
-                    response.content_type.as_ref(),
-                    &component.component_type,
-                );
-
-                let mut content = quote! {
-                    utoipa::openapi::Content::new(#component)
-                };
-                if let Some(ref example) = response.example {
-                    content.extend(quote! {
-                        .with_example(#example)
-                    })
-                }
-
-                response_tokens.extend(quote! {
-                    .with_content(#content_type, #content)
+            if let Some(ref example) = self.example {
+                content.extend(quote! {
+                    .with_example(#example)
                 })
             }
 
-            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)
+            if let Some(content_types) = self.content_type.as_ref() {
+                content_types.iter().for_each(|content_type| {
+                    tokens.extend(quote! {
+                        .with_content(#content_type, #content)
+                    })
                 })
-            });
+            } else {
+                let default_type = self.resolve_content_type(None, &component.component_type);
+                tokens.extend(quote! {
+                    .with_content(#default_type, #content)
+                });
+            }
+        }
 
+        self.headers.iter().for_each(|header| {
+            let name = &header.name;
             tokens.extend(quote! {
-                .with_response(#status, #response_tokens)
-            });
-        })
+                .with_header(#name, #header)
+            })
+        });
     }
 }
 
-#[inline]
-fn new_header_tokens(header: &Header) -> TokenStream2 {
-    let mut header_tokens = if let Some(ref header_type) = header.value_type {
-        // header property with custom type
-        let header_type = Property::new(header_type.is_array, &header_type.ty);
+impl ContentTypeResolver for Response {}
 
-        quote! {
-            utoipa::openapi::Header::new(#header_type)
-        }
-    } else {
-        // default header (string type)
-        quote! {
-            utoipa::openapi::Header::default()
-        }
-    };
+pub struct Responses<'a>(pub &'a [Response]);
 
-    if let Some(ref description) = header.description {
-        header_tokens.extend(quote! {
-            .with_description(#description)
+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();
+            tokens.extend(quote! {
+                .with_response(#status, #response)
+            });
         })
     }
-
-    header_tokens
 }
 
 /// Parsed representation of response header defined in `#[utoipa::path(..)]` attribute.
@@ -323,3 +274,27 @@ impl Parse for Header {
         Ok(header)
     }
 }
+
+impl ToTokens for Header {
+    fn to_tokens(&self, tokens: &mut TokenStream2) {
+        if let Some(ref header_type) = self.value_type {
+            // header property with custom type
+            let header_type = Property::new(header_type.is_array, &header_type.ty);
+
+            tokens.extend(quote! {
+                utoipa::openapi::Header::new(#header_type)
+            })
+        } else {
+            // default header (string type)
+            tokens.extend(quote! {
+                utoipa::openapi::Header::default()
+            })
+        };
+
+        if let Some(ref description) = self.description {
+            tokens.extend(quote! {
+                .with_description(#description)
+            })
+        }
+    }
+}
diff --git a/utoipa-swagger-ui/src/lib.rs b/utoipa-swagger-ui/src/lib.rs
index 4442565..a2b3c33 100644
--- a/utoipa-swagger-ui/src/lib.rs
+++ b/utoipa-swagger-ui/src/lib.rs
@@ -170,8 +170,9 @@ impl HttpServiceFactory for SwaggerUi {
             .urls
             .into_iter()
             .map(|url| {
-                register_api_doc_url_resource(&url, config);
-                url.0
+                let (url, openapi) = url;
+                register_api_doc_url_resource((url.url.as_ref(), openapi), config);
+                url
             })
             .collect::<Vec<_>>();
 
@@ -185,14 +186,14 @@ impl HttpServiceFactory for SwaggerUi {
 }
 
 #[cfg(feature = "actix-web")]
-fn register_api_doc_url_resource(url: &(Url, OpenApi), config: &mut actix_web::dev::AppService) {
+fn register_api_doc_url_resource(url: (&str, OpenApi), config: &mut actix_web::dev::AppService) {
     pub async fn get_api_doc(api_doc: web::Data<OpenApi>) -> impl Responder {
         HttpResponse::Ok().json(api_doc.as_ref())
     }
 
-    let url_resource = Resource::new(url.0.url.as_ref())
+    let url_resource = Resource::new(url.0)
         .guard(Get())
-        .data(url.1.clone())
+        .data(url.1)
         .to(get_api_doc);
     HttpServiceFactory::register(url_resource, config);
 }


Test patch:
diff --git a/tests/openapi_derive.rs b/tests/openapi_derive.rs
index a23ba2d..51f5a4c 100644
--- a/tests/openapi_derive.rs
+++ b/tests/openapi_derive.rs
@@ -47,3 +47,34 @@ fn derive_openapi_tags() {
         "tags.[1].externalDocs.description" = r###""Find more about pets""###, "Tags pets_api external docs description"
     }
 }
+
+#[test]
+fn derive_openapi_with_external_docs() {
+    #[derive(OpenApi)]
+    #[openapi(external_docs(
+        url = "http://localhost.more.about.api",
+        description = "Find out more"
+    ))]
+    struct ApiDoc;
+
+    let doc = serde_json::to_value(&ApiDoc::openapi()).unwrap();
+
+    assert_value! {doc=>
+        "externalDocs.url" = r###""http://localhost.more.about.api""###, "External docs url"
+        "externalDocs.description" = r###""Find out more""###, "External docs description"
+    }
+}
+
+#[test]
+fn derive_openapi_with_external_docs_only_url() {
+    #[derive(OpenApi)]
+    #[openapi(external_docs(url = "http://localhost.more.about.api"))]
+    struct ApiDoc;
+
+    let doc = serde_json::to_value(&ApiDoc::openapi()).unwrap();
+
+    assert_value! {doc=>
+        "externalDocs.url" = r###""http://localhost.more.about.api""###, "External docs url"
+        "externalDocs.description" = r###"null"###, "External docs description"
+    }
+}
diff --git a/tests/path_response_derive_test.rs b/tests/path_response_derive_test.rs
index e9e0359..df030c8 100644
--- a/tests/path_response_derive_test.rs
+++ b/tests/path_response_derive_test.rs
@@ -194,3 +194,24 @@ fn derive_response_with_json_example_success() {
         "responses.200.headers" = r#"null"#, "Response headers"
     }
 }
+
+#[test]
+fn derive_reponse_multiple_content_types() {
+    test_fn! {
+        module: response_multiple_content_types,
+        responses: (
+            (status = 200, description = "success", body = Foo, content_type = ["text/xml", "application/json"])
+        )
+    }
+
+    let doc = api_doc!(module: response_multiple_content_types);
+
+    assert_value! {doc=>
+        "responses.200.description" = r#""success""#, "Response description"
+        "responses.200.content.application/json.schema.$ref" = r###""#/components/schemas/Foo""###, "Response content ref"
+        "responses.200.content.text/xml.schema.$ref" = r###""#/components/schemas/Foo""###, "Response content ref"
+        "responses.200.content.application/json.example" = r###"null"###, "Response content example"
+        "responses.200.content.text/xml.example" = r###"null"###, "Response content example"
+        "responses.200.headers" = r#"null"#, "Response headers"
+    }
+}