Repository: wafflespeanut/paperclip
Pull request #219 (2020-09-16 16:01:31)

Actix operation macros to support tags and info
Built on top of #217 this change allows to configure list of tags, info and other Api specification defaults when using actix plugin. I believe this closes the loop to be able to build a nice looking spec, e.g. allowing to render APIs grouped by categories:

<img width="774" alt="Screenshot 2020-09-16 at 18 54 40" src="https://user-images.githubusercontent.com/1453344/93362718-fd5ba280-f84e-11ea-925b-a00c12a3811c.png">

To configure default I initially thought to add methods to App, but it is safer to configure defaults right when Api wrapper is created. Hence I've extended trait with additional method. This should provide full back compatibility.:

```
            let mut spec = DefaultApiRaw::default();
            spec.tags = vec![
              Tag {
                  name: "Dogs".to_string(),
                  description: Some("Images of dogs".to_string()),
                  external_docs: None,
              },
              Tag {
                  name: "Cats".to_string(),
                  description: Some("Images of cats".to_string()),
                  external_docs: None,
              },
              Tag {
                  name: "Cars".to_string(),
                  description: Some("Images of nice cars".to_string()),
                  external_docs: None,
              },
          ];
          spec.info = Info {
            version: "0.1".into(), 
            title: "Image server".into(), 
            ..Default::default()
          };

            App::new()
                .wrap_api_with_spec(spec)
                .with_json_spec_at("/api/spec")
                .service(web::resource("/images/pets").route(web::get().to(some_pets_images)))
                .build()
```

Tags supported by Operation and api_v2_operation macros:
```
    #[api_v2_operation(tags(Cats,Dogs))]
    fn some_pets_images() -> impl Future<Output = web::Json<Vec<Image>>> {
        ready(web::Json(Vec::new()))
    }
```

wafflespeanut: Hey, is there anything blocking this? Is it ready for review?
dunnock: > Hey, is there anything blocking this? Is it ready for review?

Hi, yeah, it's ready for review. Sorry was quite busy this week and did not toggle that.
dunnock: Ah I see, I need to resolve few conflicts
wafflespeanut: No worries! Take your time. I was just wondering :sweat_smile: 
dunnock: @wafflespeanut this PR is ready for review
wafflespeanut: Hey @dunnock I couldn't find a way to message you personally, so I'll just put it here. I see you've been contributing to paperclip for some time now, so I've invited you as a collaborator. It's just additional privileges for you to review and merge stuff (if and whenever you have time and on the off-chance I take long time to respond). This is completely voluntary and is in no way an obligation. Thank you for all your work!
dunnock: hi @wafflespeanut thank you for your trust, I have accepted that. It seems paperclip has got into spotlight recently. On this ticket - it was a busy week for me, I will finish this ticket today.
dunnock: BTW, this PR would allow to generate "schemes" on the Api level, might help with https://github.com/wafflespeanut/paperclip/issues/248

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 88828c95..a1331aa3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,10 +6,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 ### Added
-- Actix plugin: `#[api_v2_operation]` macro now supports specifying `consumes`, `produces`, `summary`, `description`
+- Actix plugin: `#[api_v2_operation]` macro now supports specifying `consumes`, `produces`, `summary`, `description`, `tags`
 and `operation_id` in macro.
 - Actix plugin: Support for actix-web 3.0 (default).
 - Actix plugin: Support for `actix-session` types in handlers.
+- Actix plugin: `App::wrap_api_with_spec` allows to provide default specification with `info` and other custom settings
+- Actix plugin: support tags in api_v2_operation macros
 
 ### Changed
 - Actix plugin: Internals of `#[api_v2_operation]` proc macro (long-outstanding technical debt). This now generates operation metadata (on the fly) for each handler, which enables us to tie custom changes to operations easily.
diff --git a/book/actix-plugin.md b/book/actix-plugin.md
index dae24148..8f7277f6 100644
--- a/book/actix-plugin.md
+++ b/book/actix-plugin.md
@@ -199,6 +199,7 @@ This can be overridden by explicitly specifying `summary` and `description` in t
   operation_id = "my_handler",
   consumes = "application/yaml, application/json",
   produces = "application/yaml, application/json",
+  tags(Cats, Dogs),
 )]
 async fn my_handler() -> Json<Foo> { /* */ }
 ```
@@ -296,6 +297,43 @@ struct OAuth2Access;
 struct PetScopeAccess;
 ```
 
+#### Defining application level schema defaults
+
+It is possible to define default initial values for the schema, which might be useful to define "info",
+"schemes", "tags" and other top level schema fields which are not inherited from handlers.
+
+```rust
+let mut spec = DefaultApiRaw::default();
+spec.tags = vec![
+    Tag {
+        name: "Dogs".to_string(),
+        description: Some("Images of dogs".to_string()),
+        external_docs: None,
+    },
+    Tag {
+        name: "Cats".to_string(),
+        description: Some("Images of cats".to_string()),
+        external_docs: None,
+    },
+    Tag {
+        name: "Cars".to_string(),
+        description: Some("Images of nice cars".to_string()),
+        external_docs: None,
+    },
+];
+spec.info = Info {
+    version: "0.1".into(),
+    title: "Image server".into(),
+    ..Default::default()
+};
+App::new()
+    .wrap_api_with_spec(spec)
+    .with_json_spec_at("/api/spec")
+    .service(web::resource("/images/pets").route(web::get().to(some_pets_images)))
+    .build()
+```
+
+
 #### Known limitations
 
 - **Enums:** OpenAPI (v2) itself supports using simple enums (i.e., with unit variants), but Rust and serde has support for variants with fields and tuples. I still haven't looked deep enough either to say whether this can/cannot be done in OpenAPI or find an elegant way to represent this in OpenAPI.
diff --git a/core/src/v2/models.rs b/core/src/v2/models.rs
index 382edd18..4fa8fb5e 100644
--- a/core/src/v2/models.rs
+++ b/core/src/v2/models.rs
@@ -625,6 +625,8 @@ pub struct Operation<P, R> {
     pub parameters: Vec<Either<Reference, P>>,
     #[serde(default, skip_serializing_if = "is_false")]
     pub deprecated: bool,
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub tags: Vec<String>,
 }
 
 impl<S> Operation<Parameter<S>, Response<S>> {
diff --git a/macros/src/actix.rs b/macros/src/actix.rs
index 8c9aa054..3783b884 100644
--- a/macros/src/actix.rs
+++ b/macros/src/actix.rs
@@ -11,8 +11,8 @@ use syn::spanned::Spanned;
 use syn::{
     punctuated::{Pair, Punctuated},
     Attribute, Data, DataEnum, DeriveInput, Field, Fields, FieldsNamed, FieldsUnnamed, FnArg,
-    Generics, Ident, ItemFn, Lit, Meta, MetaNameValue, NestedMeta, PathArguments, ReturnType,
-    Token, TraitBound, Type, TypeTraitObject,
+    Generics, Ident, ItemFn, Lit, Meta, MetaList, MetaNameValue, NestedMeta, Path, PathArguments,
+    ReturnType, Token, TraitBound, Type, TypeTraitObject,
 };
 
 use std::collections::HashMap;
@@ -287,6 +287,30 @@ fn parse_operation_attrs(attrs: TokenStream) -> (Vec<Ident>, Vec<proc_macro2::To
                     path
                 )
             }
+        } else if let NestedMeta::Meta(Meta::List(MetaList { path, nested, .. })) = &attr {
+            if let Some(ident) = path.get_ident() {
+                match ident.to_string().as_str() {
+                    "tags" => {
+                        let mut tags = Vec::new();
+                        for meta in nested.pairs().map(|pair| pair.into_value()) {
+                            if let NestedMeta::Meta(Meta::Path(Path { segments, .. })) = meta {
+                                tags.push(segments[0].ident.to_string());
+                            } else {
+                                emit_error!(
+                                    meta.span(),
+                                    "Expected comma separated list of tags idents: {:?}",
+                                    meta
+                                )
+                            }
+                        }
+                        if !tags.is_empty() {
+                            params.push(ident.clone());
+                            values.push(quote!(vec![ #( #tags.to_string() ),* ]));
+                        }
+                    }
+                    x => emit_error!(ident.span(), "Unknown list ident {}", x),
+                }
+            }
         } else {
             emit_error!(attr.span(), "Not supported attribute type {:?}", attr)
         }
diff --git a/plugins/actix-web/src/lib.rs b/plugins/actix-web/src/lib.rs
index 1cb2906b..65915ce4 100644
--- a/plugins/actix-web/src/lib.rs
+++ b/plugins/actix-web/src/lib.rs
@@ -37,6 +37,11 @@ pub trait OpenApiExt<T, B> {
     /// Consumes this app and produces its wrapper to start tracking
     /// paths and their corresponding operations.
     fn wrap_api(self) -> Self::Wrapper;
+
+    /// Same as `wrap_api` initializing with provided specification
+    /// defaults. Useful for defining Api properties outside of definitions and
+    /// paths.
+    fn wrap_api_with_spec(self, spec: DefaultApiRaw) -> Self::Wrapper;
 }
 
 impl<T, B> OpenApiExt<T, B> for actix_web::App<T, B> {
@@ -48,6 +53,13 @@ impl<T, B> OpenApiExt<T, B> for actix_web::App<T, B> {
             inner: Some(self),
         }
     }
+
+    fn wrap_api_with_spec(self, spec: DefaultApiRaw) -> Self::Wrapper {
+        App {
+            spec: Arc::new(RwLock::new(spec)),
+            inner: Some(self),
+        }
+    }
 }
 
 /// Indicates that this thingmabob has a path and a bunch of definitions and operations.


Test patch:
diff --git a/tests/test_app.rs b/tests/test_app.rs
index 103864b2..37f3b2c2 100644
--- a/tests/test_app.rs
+++ b/tests/test_app.rs
@@ -13,6 +13,7 @@ use paperclip::actix::{
     api_v2_errors, api_v2_operation, web, Apiv2Schema, Apiv2Security, CreatedJson, NoContent,
     OpenApiExt,
 };
+use paperclip::v2::models::{DefaultApiRaw, Info, Tag};
 use parking_lot::Mutex;
 
 use std::collections::{BTreeMap, HashMap, HashSet};
@@ -1290,6 +1291,120 @@ fn test_list_in_out() {
     );
 }
 
+#[test]
+fn test_tags() {
+    #[derive(Serialize, Apiv2Schema)]
+    struct Image {
+        data: String,
+        id: u64,
+    }
+
+    #[api_v2_operation(tags(Cats, Dogs))]
+    fn some_pets_images() -> impl Future<Output = web::Json<Vec<Image>>> {
+        ready(web::Json(Vec::new()))
+    }
+
+    run_and_check_app(
+        || {
+            let mut spec = DefaultApiRaw::default();
+            spec.tags = vec![
+                Tag {
+                    name: "Dogs".to_string(),
+                    description: Some("Images of dogs".to_string()),
+                    external_docs: None,
+                },
+                Tag {
+                    name: "Cats".to_string(),
+                    description: Some("Images of cats".to_string()),
+                    external_docs: None,
+                },
+                Tag {
+                    name: "Cars".to_string(),
+                    description: Some("Images of nice cars".to_string()),
+                    external_docs: None,
+                },
+            ];
+            spec.info = Info {
+                version: "0.1".into(),
+                title: "Image server".into(),
+                ..Default::default()
+            };
+
+            App::new()
+                .wrap_api_with_spec(spec)
+                .with_json_spec_at("/api/spec")
+                .service(web::resource("/images/pets").route(web::get().to(some_pets_images)))
+                .build()
+        },
+        |addr| {
+            let resp = CLIENT
+                .get(&format!("http://{}/api/spec", addr))
+                .send()
+                .expect("request failed?");
+
+            check_json(
+                resp,
+                json!({
+                    "definitions":{
+                        "Image":{
+                            "properties":{
+                                "data":{
+                                "type":"string"
+                                },
+                                "id":{
+                                "format":"int64",
+                                "type":"integer"
+                                }
+                            },
+                            "required":[
+                                "data",
+                                "id"
+                            ]
+                        }
+                    },
+                    "info":{
+                        "title":"Image server",
+                        "version":"0.1"
+                    },
+                    "paths":{
+                        "/images/pets":{
+                            "get":{
+                                "responses":{
+                                "200":{
+                                    "description":"OK",
+                                    "schema":{
+                                        "items":{
+                                            "$ref":"#/definitions/Image"
+                                        },
+                                        "type":"array"
+                                    }
+                                }
+                                },
+                                "tags":[ "Cats", "Dogs" ]
+                            }
+                        }
+                    },
+                    "swagger":"2.0",
+                    "tags":[
+                        {
+                            "description":"Images of dogs",
+                            "name":"Dogs"
+                        },
+                        {
+                            "description":"Images of cats",
+                            "name":"Cats"
+                        },
+                        {
+                            "description":"Images of nice cars",
+                            "name":"Cars"
+                        }
+                    ]
+                }),
+            );
+        },
+    );
+}
+
 #[test]
 #[allow(unreachable_code)]
 fn test_impl_traits() {