Repository: wafflespeanut/paperclip
Pull request #229 (2020-09-29 17:04:13)

Adds support for serde(flatten)
This change adds support for #[serde(flatten)] item attribute for Apiv2Schema derive macro. This allows to reuse structs for building flat JSON queries and responses like in example below:
```
    #[derive(Deserialize, Serialize, Apiv2Schema)]
    struct Paging {
        /// Starting image number
        offset: i32,
        /// Total images found
        total: i32,
        /// Page size
        size: i32,
    };

    #[derive(Serialize, Apiv2Schema)]
    struct Image {
        data: String,
        id: Uuid,
        time: chrono_dev::DateTime<chrono_dev::Utc>,
    }

    /// Images response with paging information embedded
    #[derive(Serialize, Apiv2Schema)]
    struct Images {
        data: Vec<Image>,
        #[serde(flatten)]
        paging: Paging,
    }
```

will turn into
```
                        "Images": {
                          "properties": {
                            "data": {
                              "items": {
                                "properties": {
                                  "data": {
                                    "type": "string"
                                  },
                                  "id": {
                                    "format": "uuid",
                                    "type": "string"
                                  },
                                  "time": {
                                    "format": "date-time",
                                    "type": "string"
                                  }
                                },
                                "required": [
                                  "data",
                                  "id",
                                  "time"
                                ]
                              },
                              "type": "array"
                            },
                            "offset": {
                              "description": "Starting image number",
                              "format": "int32",
                              "type": "integer"
                            },
                            "size": {
                              "description": "Page size",
                              "format": "int32",
                              "type": "integer"
                            },
                            "total": {
                              "description": "Total images found",
                              "format": "int32",
                              "type": "integer"
                            }
                          },
                          "required": [
                            "data",
                            "paging"
                          ]
                        }
```



Patch:
diff --git a/Cargo.toml b/Cargo.toml
index 4fa27096..390a0161 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -92,3 +92,7 @@ required-features = ["v2", "codegen"]
 [[test]]
 name = "test_errors"
 required-features = ["v2", "codegen"]
+
+[[test]]
+name = "test_app"
+required-features = ["cli", "actix", "uuid", "chrono"]
diff --git a/macros/src/actix.rs b/macros/src/actix.rs
index 7cae1e23..8c9aa054 100644
--- a/macros/src/actix.rs
+++ b/macros/src/actix.rs
@@ -850,15 +850,20 @@ fn handle_field_struct(
         let docs = extract_documentation(&field.attrs);
         let docs = docs.trim();
 
-        let mut gen = quote!(
-            {
+        let mut gen = if !SerdeFlatten::exists(&field.attrs) {
+            quote!({
                 let mut s = #ty_ref::raw_schema();
                 if !#docs.is_empty() {
                     s.description = Some(#docs.to_string());
                 }
                 schema.properties.insert(#field_name.into(), s.into());
-            }
-        );
+            })
+        } else {
+            quote!({
+                let s = #ty_ref::raw_schema();
+                schema.properties.extend(s.properties);
+            })
+        };
 
         if is_required {
             gen.extend(quote! {
@@ -1057,3 +1062,36 @@ impl SerdeProps {
         props
     }
 }
+
+/// Supported flattening of embedded struct (https://serde.rs/variant-attrs.html).
+struct SerdeFlatten;
+
+impl SerdeFlatten {
+    /// Traverses the field attributes and returns true if there is `#[serde(flatten)]`.
+    fn exists(field_attrs: &[Attribute]) -> bool {
+        for meta in field_attrs.iter().filter_map(|a| a.parse_meta().ok()) {
+            let inner_meta = match meta {
+                Meta::List(ref l)
+                    if l.path
+                        .segments
+                        .last()
+                        .map(|p| p.ident == "serde")
+                        .unwrap_or(false) =>
+                {
+                    &l.nested
+                }
+                _ => continue,
+            };
+
+            for meta in inner_meta {
+                if let NestedMeta::Meta(Meta::Path(syn::Path { segments, .. })) = meta {
+                    if segments.iter().any(|p| p.ident == "flatten") {
+                        return true;
+                    }
+                }
+            }
+        }
+
+        false
+    }
+}


Test patch:
diff --git a/tests/test_app.rs b/tests/test_app.rs
index b243bdc2..cd494202 100644
--- a/tests/test_app.rs
+++ b/tests/test_app.rs
@@ -987,6 +987,169 @@ fn test_map_in_out() {
     );
 }
 
+#[test]
+fn test_serde_flatten() {
+    #[derive(Deserialize, Serialize, Apiv2Schema)]
+    struct PagedQuery {
+        /// First image number to return
+        offset: Option<i32>,
+        /// Return number of images
+        size: Option<i32>,
+    };
+
+    #[derive(Deserialize, Serialize, Apiv2Schema)]
+    struct Paging {
+        /// Starting image number
+        offset: i32,
+        /// Total images found
+        total: i32,
+        /// Page size
+        size: i32,
+    };
+
+    #[derive(Serialize, Apiv2Schema)]
+    struct Image {
+        data: String,
+        id: Uuid,
+        time: chrono_dev::DateTime<chrono_dev::Utc>,
+    }
+
+    /// Images response with paging information embedded
+    #[derive(Serialize, Apiv2Schema)]
+    struct Images {
+        data: Vec<Image>,
+        #[serde(flatten)]
+        paging: Paging,
+    }
+
+    /// Query images from library by name
+    #[derive(Deserialize, Apiv2Schema)]
+    struct ImagesQuery {
+        #[serde(flatten)]
+        paging: PagedQuery,
+        name: Option<String>,
+    }
+
+    #[api_v2_operation]
+    async fn some_images(_filter: web::Query<ImagesQuery>) -> Result<web::Json<Images>, ()> {
+        #[allow(unreachable_code)]
+        if _filter.paging.offset.is_some() && _filter.name.is_some() {
+            unimplemented!()
+        }
+        unimplemented!()
+    }
+
+    run_and_check_app(
+        || {
+            App::new()
+                .wrap_api()
+                .with_json_spec_at("/api/spec")
+                .service(web::resource("/images").route(web::get().to(some_images)))
+                .build()
+        },
+        |addr| {
+            let resp = CLIENT
+                .get(&format!("http://{}/api/spec", addr))
+                .send()
+                .expect("request failed?");
+
+            check_json(
+                resp,
+                json!({
+                    "definitions": {
+                        "Images": {
+                          "properties": {
+                            "data": {
+                              "items": {
+                                "properties": {
+                                  "data": {
+                                    "type": "string"
+                                  },
+                                  "id": {
+                                    "format": "uuid",
+                                    "type": "string"
+                                  },
+                                  "time": {
+                                    "format": "date-time",
+                                    "type": "string"
+                                  }
+                                },
+                                "required": [
+                                  "data",
+                                  "id",
+                                  "time"
+                                ]
+                              },
+                              "type": "array"
+                            },
+                            "offset": {
+                              "description": "Starting image number",
+                              "format": "int32",
+                              "type": "integer"
+                            },
+                            "size": {
+                              "description": "Page size",
+                              "format": "int32",
+                              "type": "integer"
+                            },
+                            "total": {
+                              "description": "Total images found",
+                              "format": "int32",
+                              "type": "integer"
+                            }
+                          },
+                          "required": [
+                            "data",
+                            "paging"
+                          ]
+                        }
+                      },
+                      "info": {
+                        "title": "",
+                        "version": ""
+                      },
+                      "paths": {
+                        "/images": {
+                          "get": {
+                            "parameters": [
+                              {
+                                "in": "query",
+                                "name": "name",
+                                "type": "string"
+                              },
+                              {
+                                "description": "First image number to return",
+                                "format": "int32",
+                                "in": "query",
+                                "name": "offset",
+                                "type": "integer"
+                              },
+                              {
+                                "description": "Return number of images",
+                                "format": "int32",
+                                "in": "query",
+                                "name": "size",
+                                "type": "integer"
+                              }
+                            ],
+                            "responses": {
+                              "200": {
+                                "description": "OK",
+                                "schema": {
+                                  "$ref": "#/definitions/Images"
+                                }
+                              }
+                            }
+                          }
+                        }
+                      },
+                      "swagger": "2.0"
+                }),
+            );
+        },
+    );
+}
+
 #[test]
 fn test_list_in_out() {
     #[derive(Serialize, Deserialize, Apiv2Schema)]