Repository: wafflespeanut/paperclip
Pull request #289 (2021-01-24 22:31:33)

feat(macro): add macro helpers for method routing
Add macro helpers for routing methods get, put, post, delete:
these are based on the actix_web macros and intended to be facilitate
use paperclip on actix_web code which already makes use of the original
macros from actix_web:
https://docs.rs/actix-web-macros/0.1.0/actix_web_macros/

tiagolobocastro: Please let me know what you think of this!
Also, could I please get some help getting the doc_test example to compile? Had to comment it out because it failed to compile, I can't get to find the actix_web or paperclip crate :( 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 86f3b920..8f57fae3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,12 +6,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 ### Added
+- Add support for actix-web-macros methods routing [PR#289](https://github.com/wafflespeanut/paperclip/pull/289)
 
 ### Changed
 
 ### Fixed
 - Optional type aliases like `type Email = Option<String>` will not be added to the `required` fields.
-- Actix plugin: Path tuples now inherit field names and descriptions from doc comments
 
 ## [0.5.0] - 2020-11-28
 ### Added
diff --git a/core/src/v2/actix.rs b/core/src/v2/actix.rs
index 2794f99f..108fdea2 100644
--- a/core/src/v2/actix.rs
+++ b/core/src/v2/actix.rs
@@ -321,10 +321,10 @@ macro_rules! impl_param_extractor ({ $ty:ty => $container:ident } => {
                     data_type: def.data_type,
                     format: def.format,
                     enum_: def.enum_,
-                    description: def.description,
                     ..Default::default()
                 }));
             }
+
             for (k, v) in def.properties {
                 op.parameters.push(Either::Right(Parameter {
                     in_: ParameterIn::$container,
@@ -388,36 +388,19 @@ macro_rules! impl_path_tuple ({ $($ty:ident),+ } => {
         where $($ty: Apiv2Schema,)+
     {
         fn update_parameter(op: &mut DefaultOperationRaw) {
+            // NOTE: We're setting empty name, because we don't know
+            // the name in this context. We'll get it when we add services.
             $(
                 let def = $ty::raw_schema();
-                if def.properties.is_empty() {
-                    op.parameters.push(Either::Right(Parameter {
-                        // NOTE: We're setting empty name, because we don't know
-                        // the name in this context. We'll get it when we add services.
-                        name: String::new(),
-                        in_: ParameterIn::Path,
-                        required: true,
-                        data_type: def.data_type,
-                        format: def.format,
-                        enum_: def.enum_,
-                        description: def.description,
-                        ..Default::default()
-                    }));
-                }
-                for (k, v) in def.properties {
-                    op.parameters.push(Either::Right(Parameter {
-                        in_: ParameterIn::Path,
-                        required: def.required.contains(&k),
-                        data_type: v.data_type,
-                        format: v.format,
-                        enum_: v.enum_,
-                        description: v.description,
-                        collection_format: None, // this defaults to csv
-                        items: v.items.as_deref().map(map_schema_to_items),
-                        name: k,
-                        ..Default::default()
-                    }));
-                }
+                op.parameters.push(Either::Right(Parameter {
+                    name: String::new(),
+                    in_: ParameterIn::Path,
+                    required: true,
+                    data_type: def.data_type,
+                    format: def.format,
+                    enum_: def.enum_,
+                    ..Default::default()
+                }));
             )+
         }
     }
diff --git a/macros/src/actix.rs b/macros/src/actix.rs
index e580c301..9f7a80db 100644
--- a/macros/src/actix.rs
+++ b/macros/src/actix.rs
@@ -5,9 +5,10 @@ use heck::*;
 use http::StatusCode;
 use lazy_static::lazy_static;
 use proc_macro::TokenStream;
-use quote::quote;
+use quote::{quote, ToTokens};
 use strum_macros::EnumString;
 use syn::{
+    parse_macro_input,
     punctuated::{Pair, Punctuated},
     spanned::Spanned,
     Attribute, Data, DataEnum, DeriveInput, Field, Fields, FieldsNamed, FieldsUnnamed, FnArg,
@@ -493,7 +494,15 @@ pub fn emit_v2_definition(input: TokenStream) -> TokenStream {
             match &s.fields {
                 Fields::Named(ref f) => handle_field_struct(f, &props, &mut props_gen),
                 Fields::Unnamed(ref f) => {
-                    handle_unnamed_field_struct(f, &item_ast.attrs, &mut props_gen)
+                    if f.unnamed.len() == 1 {
+                        handle_unnamed_field_struct(f, &mut props_gen)
+                    } else {
+                        emit_warning!(
+                            f.span().unwrap(),
+                            "tuple structs do not have named fields and hence will have empty schema.";
+                            help = "{}", &*EMPTY_SCHEMA_HELP;
+                        );
+                    }
                 }
                 Fields::Unit => {
                     emit_warning!(
@@ -512,7 +521,6 @@ pub fn emit_v2_definition(input: TokenStream) -> TokenStream {
     };
 
     let schema_name = name.to_string();
-    let props_gen_empty = props_gen.is_empty();
     let gen = quote! {
         impl #impl_generics paperclip::v2::schema::Apiv2Schema for #name #ty_generics #where_clause {
             const NAME: Option<&'static str> = Some(#schema_name);
@@ -528,12 +536,6 @@ pub fn emit_v2_definition(input: TokenStream) -> TokenStream {
                     .. Default::default()
                 };
                 #props_gen
-                // props_gen may override the schema for unnamed structs with 1 element
-                // as it replaces the struct type with inner type.
-                // make sure we set the name properly if props_gen is not empty
-                if !#props_gen_empty {
-                    schema.name = Some(#schema_name.into());
-                }
                 schema
             }
         }
@@ -786,59 +788,13 @@ fn get_field_type(field: &Field) -> Option<proc_macro2::TokenStream> {
 }
 
 /// Generates code for a tuple struct with fields.
-fn handle_unnamed_field_struct(
-    fields: &FieldsUnnamed,
-    struct_attr: &[Attribute],
-    props_gen: &mut proc_macro2::TokenStream,
-) {
-    if fields.unnamed.len() == 1 {
-        let field = fields.unnamed.iter().next().unwrap();
-
-        if let Some(ty_ref) = get_field_type(&field) {
-            let docs = extract_documentation(struct_attr);
-            let docs = docs.trim();
+fn handle_unnamed_field_struct(fields: &FieldsUnnamed, props_gen: &mut proc_macro2::TokenStream) {
+    let field = fields.unnamed.iter().next().unwrap();
 
-            props_gen.extend(quote!({
-                let mut s = #ty_ref::raw_schema();
-                if !#docs.is_empty() {
-                    s.description = Some(#docs.to_string());
-                }
-                schema = s;
-            }));
-        }
-    } else {
-        for (inner_field_id, field) in (&fields.unnamed).into_iter().enumerate() {
-            let ty_ref = get_field_type(&field);
-
-            let docs = extract_documentation(&field.attrs);
-            let docs = docs.trim();
-
-            let mut gen = if !SerdeFlatten::exists(&field.attrs) {
-                // this is really not what we'd want to do because that's not how the
-                // deserialized struct will be like, ideally we want an actual tuple
-                // this type should therefore not be used for anything else than `Path`
-                quote!({
-                    let mut s = #ty_ref::raw_schema();
-                    if !#docs.is_empty() {
-                        s.description = Some(#docs.to_string());
-                    }
-                    schema.properties.insert(#inner_field_id.to_string(), s.into());
-                })
-            } else {
-                quote!({
-                    let s = #ty_ref::raw_schema();
-                    schema.properties.extend(s.properties);
-                })
-            };
-
-            gen.extend(quote! {
-                if #ty_ref::REQUIRED {
-                    schema.required.insert(#inner_field_id.to_string());
-                }
-            });
-
-            props_gen.extend(gen);
-        }
+    if let Some(ty_ref) = get_field_type(&field) {
+        props_gen.extend(quote!({
+            schema = #ty_ref::raw_schema();
+        }));
     }
 }
 
@@ -1161,3 +1117,146 @@ impl SerdeFlatten {
         false
     }
 }
+
+macro_rules! doc_comment {
+    ($x:expr; $($tt:tt)*) => {
+        #[doc = $x]
+        $($tt)*
+    };
+}
+
+#[cfg(feature = "actix")]
+impl super::Method {
+    fn handler_uri(attr: TokenStream) -> TokenStream {
+        let attr = parse_macro_input!(attr as syn::AttributeArgs);
+        attr.first().into_token_stream().into()
+    }
+    fn handler_name(item: TokenStream) -> syn::Result<syn::Ident> {
+        let handler: ItemFn = syn::parse(item)?;
+        Ok(handler.sig.ident)
+    }
+    pub(crate) fn generate(
+        &self,
+        attr: TokenStream,
+        item: TokenStream,
+    ) -> syn::Result<proc_macro2::TokenStream> {
+        let uri: proc_macro2::TokenStream = Self::handler_uri(attr).into();
+        let handler_name = Self::handler_name(item.clone())?;
+        let handler_fn: proc_macro2::TokenStream = item.into();
+        let method: proc_macro2::TokenStream = self.method().parse()?;
+        let variant: proc_macro2::TokenStream = self.variant().parse()?;
+        let handler_name_str = handler_name.to_string();
+
+        Ok(quote! {
+            #[allow(non_camel_case_types, missing_docs)]
+            pub struct #handler_name;
+
+            impl #handler_name {
+                fn resource() -> paperclip::actix::web::Resource {
+                    #handler_fn
+                    paperclip::actix::web::Resource::new(#uri)
+                        .name(#handler_name_str)
+                        .guard(actix_web::guard::#variant())
+                        .route(paperclip::actix::web::#method().to(#handler_name))
+                }
+            }
+
+            impl actix_web::dev::HttpServiceFactory for #handler_name {
+                fn register(self, config: &mut actix_web::dev::AppService) {
+                    Self::resource().register(config);
+                }
+            }
+
+            impl paperclip::actix::Mountable for #handler_name {
+                fn path(&self) -> &str {
+                    #uri
+                }
+
+                fn operations(
+                    &mut self,
+                ) -> std::collections::BTreeMap<
+                    paperclip::v2::models::HttpMethod,
+                    paperclip::v2::models::DefaultOperationRaw,
+                > {
+                    Self::resource().operations()
+                }
+
+                fn definitions(
+                    &mut self,
+                ) -> std::collections::BTreeMap<
+                    String,
+                    paperclip::v2::models::DefaultSchemaRaw,
+                > {
+                    Self::resource().definitions()
+                }
+
+                fn security_definitions(
+                    &mut self,
+                ) -> std::collections::BTreeMap<String, paperclip::v2::models::SecurityScheme>
+                {
+                    Self::resource().security_definitions()
+                }
+            }
+        })
+    }
+}
+
+#[macro_use]
+macro_rules! rest_methods {
+    (
+        $($variant:ident, $method:ident, )+
+    ) => {
+        /// All available Rest methods
+        #[derive(Debug, PartialEq, Eq, Hash)]
+        pub(crate) enum Method {
+            $(
+                $variant,
+            )+
+        }
+
+        impl Method {
+            fn method(&self) -> &'static str {
+                match self {
+                    $(Self::$variant => stringify!($method),)+
+                }
+            }
+            fn variant(&self) -> &'static str {
+                match self {
+                    $(Self::$variant => stringify!($variant),)+
+                }
+            }
+        }
+
+        $(doc_comment! {
+            concat!("
+Creates route handler with `paperclip::actix::web::Resource", "`.
+In order to control the output type and status codes the return value/response must implement the
+trait actix_web::Responder.
+
+# Syntax
+```text
+#[", stringify!($method), r#"("path"[, attributes])]
+```
+
+# Attributes
+- `"path"` - Raw literal string with path for which to register handler.
+
+# Example
+
+/// use paperclip::actix::web::Json;
+/// use paperclip_macros::"#, stringify!($method), ";
+/// #[", stringify!($method), r#"("/")]
+/// async fn example() {
+/// }
+"#);
+            #[cfg(feature = "actix")]
+            #[proc_macro_attribute]
+            pub fn $method(attr: TokenStream, item: TokenStream) -> TokenStream {
+                match Method::$variant.generate(attr, item) {
+                    Ok(v) => v.into(),
+                    Err(e) => e.to_compile_error().into(),
+                }
+            }
+        })+
+    };
+}
diff --git a/macros/src/lib.rs b/macros/src/lib.rs
index e3230397..7e982344 100644
--- a/macros/src/lib.rs
+++ b/macros/src/lib.rs
@@ -10,6 +10,7 @@ extern crate proc_macro;
 extern crate proc_macro_error;
 
 #[cfg(feature = "actix")]
+#[macro_use]
 mod actix;
 #[cfg(feature = "v2")]
 mod core;
@@ -108,3 +109,11 @@ fn parse_input_attrs(ts: TokenStream) -> MacroAttribute {
         .ok()
         .unwrap_or_default()
 }
+
+#[cfg(feature = "actix")]
+rest_methods! {
+    Get,    get,
+    Post,   post,
+    Put,    put,
+    Delete, delete,
+}
diff --git a/plugins/actix-web/src/lib.rs b/plugins/actix-web/src/lib.rs
index 736515d7..8e44ea30 100644
--- a/plugins/actix-web/src/lib.rs
+++ b/plugins/actix-web/src/lib.rs
@@ -6,7 +6,9 @@ extern crate actix_web3 as actix_web;
 pub mod web;
 
 pub use self::web::{Resource, Route, Scope};
-pub use paperclip_macros::{api_v2_errors, api_v2_operation, Apiv2Schema, Apiv2Security};
+pub use paperclip_macros::{
+    api_v2_errors, api_v2_operation, delete, get, post, put, Apiv2Schema, Apiv2Security,
+};
 
 use self::web::{RouteWrapper, ServiceConfig};
 use actix_service::ServiceFactory;
diff --git a/src/lib.rs b/src/lib.rs
index c454f549..272c6926 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -22,8 +22,8 @@ pub mod actix {
     //! Plugin types, traits and macros for actix-web framework.
 
     pub use paperclip_actix::{
-        api_v2_errors, api_v2_operation, web, Apiv2Schema, Apiv2Security, App, Mountable,
-        OpenApiExt,
+        api_v2_errors, api_v2_operation, delete, get, post, put, web, Apiv2Schema, Apiv2Security,
+        App, Mountable, OpenApiExt,
     };
     pub use paperclip_core::v2::{
         AcceptedJson, CreatedJson, NoContent, OperationModifier, ResponderWrapper, ResponseWrapper,


Test patch:
diff --git a/tests/test_app.rs b/tests/test_app.rs
index 1891a771..58ed3a0b 100644
--- a/tests/test_app.rs
+++ b/tests/test_app.rs
@@ -13,8 +13,8 @@ use futures::future::{ok as fut_ok, ready, Future, Ready};
 use once_cell::sync::Lazy;
 use paperclip::{
     actix::{
-        api_v2_errors, api_v2_operation, web, Apiv2Schema, Apiv2Security, CreatedJson, NoContent,
-        OpenApiExt,
+        api_v2_errors, api_v2_operation, delete, get, post, put, web, Apiv2Schema, Apiv2Security,
+        CreatedJson, NoContent, OpenApiExt,
     },
     v2::models::{DefaultApiRaw, Info, Tag},
 };
@@ -351,23 +351,6 @@ fn test_params() {
         name: String,
     }
 
-    /// KnownBadge Id Doc
-    #[derive(Serialize, Deserialize, Apiv2Schema)]
-    struct KnownBadgeId(String);
-
-    /// KnownBadge Id2 Doc
-    #[derive(Deserialize, Apiv2Schema)]
-    struct KnownBadgeId2(u64);
-
-    /// KnownBadge Id3 Doc
-    #[derive(Deserialize, Apiv2Schema)]
-    struct KnownBadgeId3(
-        /// Number Doc
-        pub u64,
-        /// String Doc
-        pub String,
-    );
-
     #[derive(Deserialize, Apiv2Schema)]
     struct BadgeParams {
         res: Option<u16>,
@@ -428,21 +411,6 @@ fn test_params() {
         ready("")
     }
 
-    #[api_v2_operation]
-    fn get_known_badge_3(
-        _p: web::Path<KnownBadgeId>,
-    ) -> impl Future<Output = Result<web::Json<KnownBadgeId>, ()>> {
-        futures::future::ok(web::Json(KnownBadgeId("id".into())))
-    }
-
-    #[api_v2_operation]
-    fn get_known_badge_4(
-        _p1: web::Path<KnownBadgeId>,
-        _p2: web::Path<(KnownBadgeId2, KnownBadgeId3)>,
-    ) -> impl Future<Output = &'static str> {
-        ready("")
-    }
-
     #[api_v2_operation]
     fn post_badge_1(
         _p: web::Path<KnownResourceBadge>,
@@ -496,14 +464,6 @@ fn test_params() {
                                         .route(web::get().to(get_known_badge_2))
                                         .route(web::post().to(post_badge_2)),
                                 )
-                                .service(
-                                    web::resource("/v/{id}")
-                                        .route(web::get().to(get_known_badge_3)),
-                                )
-                                .service(
-                                    web::resource("/v/{id}/{id2}/{id3}/{id4}")
-                                        .route(web::get().to(get_known_badge_4)),
-                                )
                                 .service(
                                     web::resource("/v")
                                         .route(web::post().to(post_badge_3))
@@ -547,11 +507,7 @@ fn test_params() {
                                 }
                             },
                             "type":"object"
-                        },
-                        "KnownBadgeId": {
-                            "description": "KnownBadge Id Doc",
-                            "type": "string"
-                        },
+                        }
                     },
                     "info": {
                         "title": "",
@@ -856,65 +812,6 @@ fn test_params() {
                                 }
                             }
                         },
-                        "/api/v2/{resource}/v/{id}": {
-                            "get": {
-                                "parameters": [
-                                    {
-                                        "description": "KnownBadge Id Doc",
-                                        "in": "path",
-                                        "name": "id",
-                                        "required": true,
-                                        "type": "string"
-                                    }
-                                ],
-                                "responses": {
-                                    "200": {
-                                        "description": "OK",
-                                        "schema": {
-                                            "$ref": "#/definitions/KnownBadgeId"
-                                        }
-                                    }
-                                }
-                            }
-                        },
-                        "/api/v2/{resource}/v/{id}/{id2}/{id3}/{id4}": {
-                            "get": {
-                                "parameters": [
-                                    {
-                                        "description": "KnownBadge Id Doc",
-                                        "in": "path",
-                                        "name": "id",
-                                        "required": true,
-                                        "type": "string"
-                                    },
-                                    {
-                                        "description": "KnownBadge Id2 Doc",
-                                        "format": "int64",
-                                        "in": "path",
-                                        "name": "id2",
-                                        "required": true,
-                                        "type": "integer"
-                                    },
-                                    {
-                                        "description": "Number Doc",
-                                        "format": "int64",
-                                        "in": "path",
-                                        "name": "id3",
-                                        "required": true,
-                                        "type": "integer"
-                                    },
-                                    {
-                                        "description": "String Doc",
-                                        "in": "path",
-                                        "name": "id4",
-                                        "required": true,
-                                        "type": "string"
-                                    },
-                                ],
-                                "responses": {
-                                }
-                            }
-                        },
                         "/api/v2/{resource}/v/{name}": {
                             "get": {
                                 "parameters": [
@@ -2674,6 +2571,181 @@ fn test_security_app() {
     );
 }
 
+#[test]
+fn test_method_macro() {
+    #[get("/v0/pets")]
+    #[api_v2_operation]
+    fn get_pets() -> impl Future<Output = Result<web::Json<Vec<Pet>>, ()>> {
+        futures::future::ready(Ok(web::Json(Default::default())))
+    }
+    #[put("/v0/pets/{name}")]
+    #[api_v2_operation]
+    fn put_pet(
+        _name: web::Path<String>,
+        pet: web::Json<Pet>,
+    ) -> impl Future<Output = Result<web::Json<Pet>, ()>> {
+        futures::future::ready(Ok(pet))
+    }
+    #[post("/v0/pets")]
+    #[api_v2_operation]
+    fn post_pet(pet: web::Json<Pet>) -> impl Future<Output = Result<web::Json<Pet>, ()>> {
+        futures::future::ready(Ok(pet))
+    }
+    #[delete("/v0/pets/{name}")]
+    #[api_v2_operation]
+    fn delete_pet(_name: web::Path<String>) -> impl Future<Output = Result<web::Json<()>, ()>> {
+        futures::future::ready(Ok(web::Json(())))
+    }
+
+    run_and_check_app(
+        || {
+            App::new()
+                .wrap_api()
+                .with_json_spec_at("/api/spec")
+                .service(get_pets)
+                .service(put_pet)
+                .service(post_pet)
+                .service(delete_pet)
+                .build()
+        },
+        |addr| {
+            let resp = CLIENT
+                .get(&format!("http://{}/api/spec", addr))
+                .send()
+                .expect("request failed?");
+
+            check_json(
+                resp,
+                json!({
+                    "definitions": {
+                        "Pet": {
+                            "properties": {
+                                "class": {
+                                "enum": ["dog", "cat", "other"],
+                                    "type":"string"
+                                },
+                                "id": {
+                                    "format": "int64",
+                                    "type": "integer"
+                                },
+                                "name": {
+                                    "description": "Pick a good one.",
+                                    "type": "string"
+                                },
+                                "birthday": {
+                                  "format": "date",
+                                  "type": "string"
+                                },
+                                "updatedOn": {
+                                    "format": "date-time",
+                                    "type": "string"
+                                },
+                                "uuid":{
+                                    "format": "uuid",
+                                    "type": "string"
+                                }
+                            },
+                            "required":[
+                                "birthday",
+                                "class",
+                                "name"
+                            ],
+                            "type":"object"
+                        }
+                    },
+                    "info": {
+                        "title":"",
+                        "version":""
+                    },
+                    "paths": {
+                        "/v0/pets": {
+                            "get": {
+                                "responses": {
+                                    "200": {
+                                        "description": "OK",
+                                        "schema": {
+                                            "items": {
+                                                "$ref": "#/definitions/Pet"
+                                            },
+                                            "type": "array"
+                                        }
+                                    }
+                                },
+                            },
+                            "post": {
+                                "parameters": [
+                                    {
+                                        "in": "body",
+                                        "name": "body",
+                                        "required": true,
+                                        "schema": {
+                                            "$ref": "#/definitions/Pet"
+                                        }
+                                    }
+                                ],
+                                "responses": {
+                                    "200": {
+                                        "description": "OK",
+                                        "schema": {
+                                            "$ref": "#/definitions/Pet"
+                                        }
+                                    }
+                                },
+                            }
+                        },
+                        "/v0/pets/{name}": {
+                            "delete": {
+                                "parameters": [
+                                    {
+                                        "in": "path",
+                                        "name": "name",
+                                        "required": true,
+                                        "type": "string"
+                                    },
+                                ],
+                                "responses": {
+                                    "200": {
+                                        "description": "OK",
+                                        "schema": {
+                                        }
+                                    }
+                                },
+                            },
+                            "put": {
+                                "parameters": [
+                                    {
+                                        "in": "path",
+                                        "name": "name",
+                                        "required": true,
+                                        "type": "string"
+                                    },
+                                    {
+                                        "in": "body",
+                                        "name": "body",
+                                        "required": true,
+                                        "schema": {
+                                            "$ref": "#/definitions/Pet"
+                                        }
+                                    }
+                                ],
+                                "responses": {
+                                    "200": {
+                                        "description": "OK",
+                                        "schema": {
+                                            "$ref": "#/definitions/Pet"
+                                        }
+                                    }
+                                },
+                            }
+                        }
+                    },
+                    "swagger": "2.0"
+                }),
+            );
+        },
+    );
+}
+
 fn run_and_check_app<F, G, T, B, U>(factory: F, check: G) -> U
 where
     F: Fn() -> App<T, B> + Clone + Send + Sync + 'static,
@@ -2716,6 +2788,7 @@ where
 
     let (_server, addr) = rx.recv().unwrap();
     let ret = check(addr);
+    let _ = _server.stop(true);
     ret
 }