Repository: juhaku/utoipa
Pull request #43 (2022-03-15 21:16:59)

Add derive tags to openapi
* Add derive tags to OpenApi trait with external docs
* Add unit test
* Add documentation


Patch:
diff --git a/src/openapi/mod.rs b/src/openapi/mod.rs
index 57fd905..7a8e592 100644
--- a/src/openapi/mod.rs
+++ b/src/openapi/mod.rs
@@ -101,6 +101,18 @@ impl OpenApi {
         self
     }
 
+    /// Add list of [`Tag`]s to [`OpenApi`].
+    ///
+    /// This operation consumes self and is expected to be chained after [`OpenApi::new`].
+    /// It accepts one argument with anything that implements [`IntoIterator`] for [`Tag`].
+    ///
+    /// Method returns self for chaining more operations.
+    pub fn with_tags<I: IntoIterator<Item = Tag>>(mut self, tags: I) -> Self {
+        self.tags = Some(tags.into_iter().collect());
+
+        self
+    }
+
     #[cfg(feature = "serde_json")]
     pub fn to_json(&self) -> Result<String, serde_json::Error> {
         serde_json::to_string(self)
diff --git a/src/openapi/tag.rs b/src/openapi/tag.rs
index 081f7ee..d598c96 100644
--- a/src/openapi/tag.rs
+++ b/src/openapi/tag.rs
@@ -2,6 +2,11 @@ use serde::{Deserialize, Serialize};
 
 use super::external_docs::ExternalDocs;
 
+/// Implements [OpenAPI Tag Object][tag].
+///
+/// Tag can be used to provide additional metadata for tags used by path operations.
+///
+/// [tag]: https://spec.openapis.org/oas/latest.html#tag-object
 #[non_exhaustive]
 #[derive(Serialize, Deserialize, Default, Clone)]
 #[serde(rename_all = "camelCase")]
diff --git a/utoipa-gen/src/lib.rs b/utoipa-gen/src/lib.rs
index 92d95ca..58180e7 100644
--- a/utoipa-gen/src/lib.rs
+++ b/utoipa-gen/src/lib.rs
@@ -500,6 +500,10 @@ pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream {
 ///   See the [trait documentation][modify] for more details.
 /// * **security** List of [`SecurityRequirement`][security]s global to all operations.
 ///   See more details in [`#[utoipa::path(...)]`][path] [attribute macro security options][path_security].
+/// * **tags** List of [`Tag`][tags] which must match the tag _**path operation**_. By default
+///   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.
 ///
 /// OpenApi derive macro will also derive [`Info`][info] for OpenApi specification using Cargo
 /// environment variables.
@@ -508,7 +512,7 @@ pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream {
 /// * env `CARGO_PKG_VERSION` map to info `version`
 /// * env `CARGO_PKG_DESCRIPTION` map info `description`
 /// * env `CARGO_PKG_AUTHORS` map to contact `name` and `email` **only first author will be used**
-/// * env `CARGO_PKG_LICENSE` map to info `licence`
+/// * env `CARGO_PKG_LICENSE` map to info `license`
 ///
 /// # Examples
 ///
@@ -548,7 +552,11 @@ pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream {
 ///         (),
 ///         ("my_auth" = ["read:items", "edit:items"]),
 ///         ("token_jwt" = [])
-///     ]
+///     ],
+///     tags(
+///         (name = "pets::api", description = "All about pets",
+///             external_docs(url = "http://more.about.pets.api", description = "Find out more"))
+///     )
 /// )]
 /// struct ApiDoc;
 /// ```
@@ -560,6 +568,7 @@ pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream {
 /// [info]: openapi/info/struct.Info.html
 /// [security]: openapi/security/struct.SecurityRequirement.html
 /// [path_security]: attr.path.html#security-requirement-attributes
+/// [tags]: openapi/tag/struct.Tag.html
 pub fn openapi(input: TokenStream) -> TokenStream {
     let DeriveInput { attrs, ident, .. } = syn::parse_macro_input!(input);
 
@@ -577,6 +586,7 @@ pub fn openapi(input: TokenStream) -> TokenStream {
 
 /// Tokenizes slice or Vec of tokenizable items as array either with reference (`&[...]`)
 /// or without correctly to OpenAPI JSON.
+#[cfg_attr(feature = "debug", derive(Debug))]
 enum Array<T>
 where
     T: Sized + ToTokens,
@@ -751,6 +761,60 @@ impl Parse for Type {
     }
 }
 
+#[derive(Default)]
+#[cfg_attr(feature = "debug", derive(Debug))]
+struct ExternalDocs {
+    url: String,
+    description: Option<String>,
+}
+
+impl Parse for ExternalDocs {
+    fn parse(input: ParseStream) -> syn::Result<Self> {
+        const EXPECTED_ATTRIBUTE: &str =
+            "unexpected attribute, expected one of: url or description";
+
+        let mut external_docs = ExternalDocs::default();
+
+        while !input.is_empty() {
+            let ident = input.parse::<Ident>().map_err(|error| {
+                syn::Error::new(error.span(), &format!("{}, {}", EXPECTED_ATTRIBUTE, error))
+            })?;
+            let attribute_name = &*ident.to_string();
+
+            match attribute_name {
+                "url" => {
+                    external_docs.url = parse_utils::parse_next_literal_str(input)?;
+                }
+                "description" => {
+                    external_docs.description = Some(parse_utils::parse_next_literal_str(input)?);
+                }
+                _ => return Err(syn::Error::new(ident.span(), EXPECTED_ATTRIBUTE)),
+            }
+
+            if input.peek(Token![,]) {
+                input.parse::<Token![,]>().unwrap();
+            }
+        }
+
+        Ok(external_docs)
+    }
+}
+
+impl ToTokens for ExternalDocs {
+    fn to_tokens(&self, tokens: &mut TokenStream2) {
+        let url = &self.url;
+        tokens.extend(quote! {
+            utoipa::openapi::external_docs::ExternalDocs::new(#url)
+        });
+
+        if let Some(ref description) = self.description {
+            tokens.extend(quote! {
+                .with_description(#description)
+            });
+        }
+    }
+}
+
 #[cfg_attr(feature = "debug", derive(Debug))]
 enum Example {
     String(TokenStream2),
@@ -773,23 +837,37 @@ mod parse_utils {
     use proc_macro2::{Group, Ident, TokenStream};
     use proc_macro_error::{abort, ResultExt};
     use quote::ToTokens;
-    use syn::{parse::ParseStream, Error, LitBool, LitStr, Token};
+    use syn::{
+        parse::{Parse, ParseStream},
+        punctuated::Punctuated,
+        token::Comma,
+        Error, LitBool, LitStr, Token,
+    };
 
     use crate::Example;
 
     pub fn parse_next<T: Sized>(input: ParseStream, next: impl FnOnce() -> T) -> T {
         input
             .parse::<Token![=]>()
-            .expect_or_abort("expected equals token (=) before value assigment");
+            .expect_or_abort("expected equals token before value assigment");
         next()
     }
 
-    pub fn parse_next_lit_str(input: ParseStream, error_message: &str) -> String {
-        parse_next(input, || {
-            input
-                .parse::<LitStr>()
-                .expect_or_abort(error_message)
-                .value()
+    pub fn parse_next_literal_str(input: ParseStream) -> Result<String, Error> {
+        Ok(parse_next(input, || input.parse::<LitStr>())?.value())
+    }
+
+    pub fn parse_group<T, R>(input: ParseStream) -> Result<R, Error>
+    where
+        T: Sized,
+        T: Parse,
+        R: FromIterator<T>,
+    {
+        Punctuated::<Group, Comma>::parse_terminated(input).and_then(|groups| {
+            groups
+                .into_iter()
+                .map(|group| syn::parse2::<T>(group.stream()))
+                .collect::<Result<R, Error>>()
         })
     }
 
diff --git a/utoipa-gen/src/openapi.rs b/utoipa-gen/src/openapi.rs
index a68f773..c730bd8 100644
--- a/utoipa-gen/src/openapi.rs
+++ b/utoipa-gen/src/openapi.rs
@@ -1,7 +1,7 @@
 use proc_macro2::Ident;
 use proc_macro_error::ResultExt;
 use syn::{
-    bracketed,
+    bracketed, parenthesized,
     parse::{Parse, ParseStream},
     punctuated::Punctuated,
     token::{And, Comma},
@@ -14,6 +14,7 @@ use quote::{format_ident, quote, quote_spanned, ToTokens};
 use crate::{
     parse_utils,
     security_requirement::{self, SecurityRequirementAttr},
+    Array, ExternalDocs,
 };
 
 mod info;
@@ -27,6 +28,7 @@ pub struct OpenApiAttr {
     components: Vec<Component>,
     modifiers: Punctuated<Modifier, Comma>,
     security: Option<Vec<SecurityRequirementAttr>>,
+    tags: Option<Array<Tag>>,
 }
 
 #[cfg_attr(feature = "debug", derive(Debug))]
@@ -35,6 +37,15 @@ struct Component {
     generics: Generics,
 }
 
+impl Component {
+    fn has_lifetime_generics(&self) -> bool {
+        self.generics
+            .params
+            .iter()
+            .any(|generic| matches!(generic, GenericParam::Lifetime(_)))
+    }
+}
+
 #[cfg_attr(feature = "debug", derive(Debug))]
 struct Modifier {
     and: And,
@@ -60,12 +71,67 @@ impl Parse for Modifier {
     }
 }
 
-impl Component {
-    fn has_lifetime_generics(&self) -> bool {
-        self.generics
-            .params
-            .iter()
-            .any(|generic| matches!(generic, GenericParam::Lifetime(_)))
+#[derive(Default)]
+#[cfg_attr(feature = "debug", derive(Debug))]
+struct Tag {
+    name: String,
+    description: Option<String>,
+    external_docs: Option<ExternalDocs>,
+}
+
+impl Parse for Tag {
+    fn parse(input: ParseStream) -> syn::Result<Self> {
+        const EXPECTED_ATTRIBUTE: &str =
+            "unexpected token, expected one of: name, description or external_docs";
+
+        let mut tag = Tag::default();
+
+        while !input.is_empty() {
+            let ident = input.parse::<Ident>().map_err(|error| {
+                syn::Error::new(error.span(), &format!("{}, {}", EXPECTED_ATTRIBUTE, error))
+            })?;
+            let attribute_name = &*ident.to_string();
+
+            match attribute_name {
+                "name" => tag.name = parse_utils::parse_next_literal_str(input)?,
+                "description" => {
+                    tag.description = Some(parse_utils::parse_next_literal_str(input)?)
+                }
+                "external_docs" => {
+                    let content;
+                    parenthesized!(content in input);
+                    tag.external_docs = Some(content.parse::<ExternalDocs>()?);
+                }
+                _ => return Err(syn::Error::new(ident.span(), EXPECTED_ATTRIBUTE)),
+            }
+
+            if input.peek(Token![,]) {
+                input.parse::<Token![,]>().unwrap();
+            }
+        }
+
+        Ok(tag)
+    }
+}
+
+impl ToTokens for Tag {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        let name = &self.name;
+        tokens.extend(quote! {
+            utoipa::openapi::tag::Tag::new(#name)
+        });
+
+        if let Some(ref description) = self.description {
+            tokens.extend(quote! {
+                .with_description(#description)
+            });
+        }
+
+        if let Some(ref external_docs) = self.external_docs {
+            tokens.extend(quote! {
+                .with_external_docs(#external_docs)
+            });
+        }
     }
 }
 
@@ -78,12 +144,14 @@ pub fn parse_openapi_attributes_from_attributes(attrs: &[Attribute]) -> Option<O
 
 impl Parse for OpenApiAttr {
     fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
+        const EXPECTED_ATTRIBUTE: &str =
+            "unexpected attribute, expected one of: handlers, components, modifiers, security or tags";
         let mut openapi = OpenApiAttr::default();
 
         loop {
-            let ident = input
-                .parse::<Ident>()
-                .expect_or_abort("unaparseable OpenApi, expected Ident");
+            let ident = input.parse::<Ident>().map_err(|error| {
+                Error::new(error.span(), &format!("{}, {}", EXPECTED_ATTRIBUTE, error))
+            })?;
             let attribute = &*ident.to_string();
 
             match attribute {
@@ -103,14 +171,13 @@ impl Parse for OpenApiAttr {
                             .collect::<Vec<_>>(),
                     )
                 }
+                "tags" => {
+                    let tags;
+                    parenthesized!(tags in input);
+                    openapi.tags = Some(parse_utils::parse_group::<Tag, Array<Tag>>(&tags)?);
+                }
                 _ => {
-                    return Err(Error::new(
-                        ident.span(),
-                        format!(
-                            "unexpected attribute: {}, expected: handlers, components, modifiers, security",
-                            ident
-                        ),
-                    ));
+                    return Err(Error::new(ident.span(), EXPECTED_ATTRIBUTE));
                 }
             }
 
@@ -245,13 +312,20 @@ impl ToTokens for OpenApi {
             None
         };
 
+        let tags = self.0.tags.as_ref().map(|tags| {
+            quote! {
+                .with_tags(#tags)
+            }
+        });
+
         tokens.extend(quote! {
             impl utoipa::OpenApi for #ident {
                 fn openapi() -> utoipa::openapi::OpenApi {
                     use utoipa::{Component, Path};
                     let mut openapi = utoipa::openapi::OpenApi::new(#info, #path_items)
                         .with_components(#components)
-                        #securities;
+                        #securities
+                        #tags;
 
                     let _mods: [&dyn utoipa::Modify; #modifiers_len] = [#modifiers];
                     _mods.iter().for_each(|modifier| modifier.modify(&mut openapi));
diff --git a/utoipa-gen/src/path.rs b/utoipa-gen/src/path.rs
index eb6d0ea..3bea24f 100644
--- a/utoipa-gen/src/path.rs
+++ b/utoipa-gen/src/path.rs
@@ -149,16 +149,10 @@ impl Parse for PathAttr {
 
             match attribute {
                 "operation_id" => {
-                    path_attr.operation_id = Some(parse_utils::parse_next_lit_str(
-                        input,
-                        "unparseable operation_id, expected literal string",
-                    ));
+                    path_attr.operation_id = Some(parse_utils::parse_next_literal_str(input)?);
                 }
                 "path" => {
-                    path_attr.path = Some(parse_utils::parse_next_lit_str(
-                        input,
-                        "unparseable path, expected literal string",
-                    ));
+                    path_attr.path = Some(parse_utils::parse_next_literal_str(input)?);
                 }
                 "request_body" => {
                     if input.peek(Token![=]) {
@@ -189,10 +183,7 @@ impl Parse for PathAttr {
                     )
                 }
                 "tag" => {
-                    path_attr.tag = Some(parse_utils::parse_next_lit_str(
-                        input,
-                        "unparseable tag, expected literal string",
-                    ));
+                    path_attr.tag = Some(parse_utils::parse_next_literal_str(input)?);
                 }
                 "security" => {
                     path_attr.security = Some(
diff --git a/utoipa-gen/src/path/request_body.rs b/utoipa-gen/src/path/request_body.rs
index a1b11f5..03b55ea 100644
--- a/utoipa-gen/src/path/request_body.rs
+++ b/utoipa-gen/src/path/request_body.rs
@@ -79,16 +79,14 @@ impl Parse for RequestBodyAttr {
                         }));
                     }
                     "content_type" => {
-                        request_body_attr.content_type = Some(parse_utils::parse_next_lit_str(
+                        request_body_attr.content_type = Some(parse_utils::parse_next_literal_str(
                             &group,
-                            "unparseable content_type, expected literal string",
-                        ))
+                        )?)
                     }
                     "description" => {
-                        request_body_attr.description = Some(parse_utils::parse_next_lit_str(
+                        request_body_attr.description = Some(parse_utils::parse_next_literal_str(
                             &group,
-                            "unparseable description, expected literal string",
-                        ))
+                        )?)
                     }
                     _ => {
                         return Err(Error::new(
diff --git a/utoipa-gen/src/path/response.rs b/utoipa-gen/src/path/response.rs
index b37f817..a90813e 100644
--- a/utoipa-gen/src/path/response.rs
+++ b/utoipa-gen/src/path/response.rs
@@ -93,10 +93,7 @@ impl Parse for Response {
                     });
                 }
                 "description" => {
-                    response.description = parse_utils::parse_next_lit_str(
-                        input,
-                        "unparseable description, expected literal string",
-                    );
+                    response.description = parse_utils::parse_next_literal_str(input)?;
                 }
                 "body" => {
                     response.response_type = Some(parse_utils::parse_next(input, || {
@@ -104,10 +101,7 @@ impl Parse for Response {
                     }));
                 }
                 "content_type" => {
-                    response.content_type = Some(parse_utils::parse_next_lit_str(
-                        input,
-                        "unparseable content_type, expected literal string",
-                    ));
+                    response.content_type = Some(parse_utils::parse_next_literal_str(input)?);
                 }
                 "headers" => {
                     let groups = parse_utils::parse_next(input, || {


Test patch:
diff --git a/tests/openapi_derive.rs b/tests/openapi_derive.rs
index 6073186..9076fe9 100644
--- a/tests/openapi_derive.rs
+++ b/tests/openapi_derive.rs
@@ -23,3 +23,27 @@ fn derive_openapi_with_security_requirement() {
         "security.[2].token_jwt" = "[]", "jwt_token auth scopes"
     }
 }
+
+#[test]
+fn derive_openapi_tags() {
+    #[derive(OpenApi)]
+    #[openapi(tags(
+        (name = "random::api", description = "this is random api description"),
+        (name = "pets::api", description = "api all about pets", external_docs(
+            url = "http://localhost", description = "Find more about pets")
+        )
+    ))]
+    struct ApiDoc;
+
+    let doc = serde_json::to_value(&ApiDoc::openapi()).unwrap();
+
+    assert_value! {doc=>
+        "tags.[0].name" = r###""random::api""###, "Tags random_api name"
+        "tags.[0].description" = r###""this is random api description""###, "Tags random_api description"
+        "tags.[0].externalDocs" = r###"null"###, "Tags random_api external docs"
+        "tags.[1].name" = r###""pets::api""###, "Tags pets_api name"
+        "tags.[1].description" = r###""api all about pets""###, "Tags pets_api description"
+        "tags.[1].externalDocs.url" = r###""http://localhost""###, "Tags pets_api external docs url"
+        "tags.[1].externalDocs.description" = r###""Find more about pets""###, "Tags pets_api external docs description"
+    }
+}