Repository: GREsau/schemars
Pull request #297 (2024-06-05 19:59:35)

Add `#[schemars(extend("key" = value))]` attribute
Implements #50

- this attribute can be used to add properties (or replace existing properties) in a generated schema
- can be set on a struct, enum, or enum variant
- can have multiple key/value pairs and/or be specified multiple times - but if so, every key must be unique (otherwise, a compile error is emitted)
- key must be quoted but can be any string
- value can be any expression that produces a value implementing `Serialize`
- value can also be a JSON literal which can interpolate other values, e.g. `#[schemars(extend("obj" = {"array": [null, ()]}))]`
  - this follows the same rules as arguments to the `serde_json::json!(...)` macro (which this attribute uses under the hood)


Patch:
diff --git a/schemars_derive/src/attr/mod.rs b/schemars_derive/src/attr/mod.rs
index 86c9349..108cedc 100644
--- a/schemars_derive/src/attr/mod.rs
+++ b/schemars_derive/src/attr/mod.rs
@@ -10,7 +10,7 @@ use proc_macro2::{Group, Span, TokenStream, TokenTree};
 use quote::ToTokens;
 use serde_derive_internals::Ctxt;
 use syn::parse::{self, Parse};
-use syn::{Meta, MetaNameValue};
+use syn::{LitStr, Meta, MetaNameValue};
 
 // FIXME using the same struct for containers+variants+fields means that
 //  with/schema_with are accepted (but ignored) on containers, and
@@ -26,6 +26,7 @@ pub struct Attrs {
     pub repr: Option<syn::Type>,
     pub crate_name: Option<syn::Path>,
     pub is_renamed: bool,
+    pub extensions: Vec<(String, TokenStream)>,
 }
 
 #[derive(Debug)]
@@ -68,6 +69,7 @@ impl Attrs {
             description: self.description.as_ref().and_then(none_if_empty),
             deprecated: self.deprecated,
             examples: &self.examples,
+            extensions: &self.extensions,
             read_only: false,
             write_only: false,
             default: None,
@@ -162,6 +164,29 @@ impl Attrs {
                     }
                 }
 
+                Meta::List(m) if m.path.is_ident("extend") && attr_type == "schemars" => {
+                    let parser =
+                        syn::punctuated::Punctuated::<Extension, Token![,]>::parse_terminated;
+                    match m.parse_args_with(parser) {
+                        Ok(extensions) => {
+                            for extension in extensions {
+                                let key = extension.key.value();
+                                // This is O(n^2) but should be fine with the typically small number of extensions.
+                                // If this does become a problem, it can be changed to use IndexMap, or a separate Map with cloned keys.
+                                if self.extensions.iter().any(|e| e.0 == key) {
+                                    errors.error_spanned_by(
+                                        extension.key,
+                                        format!("Duplicate extension key '{}'", key),
+                                    );
+                                } else {
+                                    self.extensions.push((key, extension.value));
+                                }
+                            }
+                        }
+                        Err(err) => errors.syn_error(err),
+                    }
+                }
+
                 _ if ignore_errors => {}
 
                 Meta::List(m) if m.path.is_ident("inner") && attr_type == "schemars" => {
@@ -198,7 +223,8 @@ impl Attrs {
                 repr: None,
                 crate_name: None,
                 is_renamed: _,
-            } if examples.is_empty())
+                extensions,
+            } if examples.is_empty() && extensions.is_empty())
     }
 }
 
@@ -322,3 +348,27 @@ fn respan_token_tree(mut token: TokenTree, span: Span) -> TokenTree {
     token.set_span(span);
     token
 }
+
+#[derive(Debug)]
+struct Extension {
+    key: LitStr,
+    value: TokenStream,
+}
+
+impl Parse for Extension {
+    fn parse(input: parse::ParseStream) -> syn::Result<Self> {
+        let key = input.parse::<LitStr>()?;
+        input.parse::<Token![=]>()?;
+        let mut value = TokenStream::new();
+
+        while !input.is_empty() && !input.peek(Token![,]) {
+            value.extend([input.parse::<TokenTree>()?]);
+        }
+
+        if value.is_empty() {
+            return Err(syn::Error::new(input.span(), "Expected extension value"));
+        }
+
+        Ok(Extension { key, value })
+    }
+}
diff --git a/schemars_derive/src/metadata.rs b/schemars_derive/src/metadata.rs
index 6de6834..6a3808c 100644
--- a/schemars_derive/src/metadata.rs
+++ b/schemars_derive/src/metadata.rs
@@ -9,6 +9,7 @@ pub struct SchemaMetadata<'a> {
     pub write_only: bool,
     pub examples: &'a [syn::Path],
     pub default: Option<TokenStream>,
+    pub extensions: &'a [(String, TokenStream)],
 }
 
 impl<'a> SchemaMetadata<'a> {
@@ -74,6 +75,12 @@ impl<'a> SchemaMetadata<'a> {
             });
         }
 
+        for (k, v) in self.extensions {
+            setters.push(quote! {
+                obj.insert(#k.to_owned(), schemars::_serde_json::json!(#v));
+            });
+        }
+
         setters
     }
 }
diff --git a/schemars_derive/src/schema_exprs.rs b/schemars_derive/src/schema_exprs.rs
index 48da5ef..1f5df99 100644
--- a/schemars_derive/src/schema_exprs.rs
+++ b/schemars_derive/src/schema_exprs.rs
@@ -232,13 +232,13 @@ fn expr_for_internal_tagged_enum<'a>(
             let name = variant.name();
 
             let mut schema_expr = expr_for_internal_tagged_enum_variant(variant, deny_unknown_fields);
-            variant.attrs.as_metadata().apply_to_schema(&mut schema_expr);
-
-            quote!({
+            schema_expr = quote!({
                 let mut schema = #schema_expr;
                 schemars::_private::apply_internal_enum_variant_tag(&mut schema, #tag_name, #name, #deny_unknown_fields);
                 schema
-            })
+            });
+            variant.attrs.as_metadata().apply_to_schema(&mut schema_expr);
+            schema_expr
         })
         .collect();
 


Test patch:
diff --git a/schemars/tests/expected/default.json b/schemars/tests/expected/default.json
index d70fb57..72e580d 100644
--- a/schemars/tests/expected/default.json
+++ b/schemars/tests/expected/default.json
@@ -13,11 +13,11 @@
       "default": false
     },
     "my_optional_string": {
-      "default": null,
       "type": [
         "string",
         "null"
-      ]
+      ],
+      "default": null
     },
     "my_struct2": {
       "$ref": "#/$defs/MyStruct2",
diff --git a/schemars/tests/expected/extend_enum_adjacent.json b/schemars/tests/expected/extend_enum_adjacent.json
new file mode 100644
index 0000000..6241e07
--- /dev/null
+++ b/schemars/tests/expected/extend_enum_adjacent.json
@@ -0,0 +1,101 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "Adjacent",
+  "oneOf": [
+    {
+      "type": "object",
+      "properties": {
+        "t": {
+          "type": "string",
+          "enum": [
+            "Unit"
+          ]
+        }
+      },
+      "required": [
+        "t"
+      ],
+      "foo": "bar"
+    },
+    {
+      "type": "object",
+      "properties": {
+        "t": {
+          "type": "string",
+          "enum": [
+            "NewType"
+          ]
+        },
+        "c": true
+      },
+      "required": [
+        "t",
+        "c"
+      ],
+      "foo": "bar"
+    },
+    {
+      "type": "object",
+      "properties": {
+        "t": {
+          "type": "string",
+          "enum": [
+            "Tuple"
+          ]
+        },
+        "c": {
+          "type": "array",
+          "prefixItems": [
+            {
+              "type": "integer",
+              "format": "int32"
+            },
+            {
+              "type": "boolean"
+            }
+          ],
+          "minItems": 2,
+          "maxItems": 2
+        }
+      },
+      "required": [
+        "t",
+        "c"
+      ],
+      "foo": "bar"
+    },
+    {
+      "type": "object",
+      "properties": {
+        "t": {
+          "type": "string",
+          "enum": [
+            "Struct"
+          ]
+        },
+        "c": {
+          "type": "object",
+          "properties": {
+            "i": {
+              "type": "integer",
+              "format": "int32"
+            },
+            "b": {
+              "type": "boolean"
+            }
+          },
+          "required": [
+            "i",
+            "b"
+          ]
+        }
+      },
+      "required": [
+        "t",
+        "c"
+      ],
+      "foo": "bar"
+    }
+  ],
+  "foo": "bar"
+}
\ No newline at end of file
diff --git a/schemars/tests/expected/extend_enum_external.json b/schemars/tests/expected/extend_enum_external.json
new file mode 100644
index 0000000..c15d47f
--- /dev/null
+++ b/schemars/tests/expected/extend_enum_external.json
@@ -0,0 +1,73 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "External",
+  "oneOf": [
+    {
+      "type": "string",
+      "const": "Unit",
+      "foo": "bar"
+    },
+    {
+      "type": "object",
+      "properties": {
+        "NewType": true
+      },
+      "required": [
+        "NewType"
+      ],
+      "additionalProperties": false,
+      "foo": "bar"
+    },
+    {
+      "type": "object",
+      "properties": {
+        "Tuple": {
+          "type": "array",
+          "prefixItems": [
+            {
+              "type": "integer",
+              "format": "int32"
+            },
+            {
+              "type": "boolean"
+            }
+          ],
+          "minItems": 2,
+          "maxItems": 2
+        }
+      },
+      "required": [
+        "Tuple"
+      ],
+      "additionalProperties": false,
+      "foo": "bar"
+    },
+    {
+      "type": "object",
+      "properties": {
+        "Struct": {
+          "type": "object",
+          "properties": {
+            "i": {
+              "type": "integer",
+              "format": "int32"
+            },
+            "b": {
+              "type": "boolean"
+            }
+          },
+          "required": [
+            "i",
+            "b"
+          ]
+        }
+      },
+      "required": [
+        "Struct"
+      ],
+      "additionalProperties": false,
+      "foo": "bar"
+    }
+  ],
+  "foo": "bar"
+}
\ No newline at end of file
diff --git a/schemars/tests/expected/extend_enum_internal.json b/schemars/tests/expected/extend_enum_internal.json
new file mode 100644
index 0000000..0dee817
--- /dev/null
+++ b/schemars/tests/expected/extend_enum_internal.json
@@ -0,0 +1,55 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "Internal",
+  "oneOf": [
+    {
+      "type": "object",
+      "properties": {
+        "typeProperty": {
+          "type": "string",
+          "const": "Unit"
+        }
+      },
+      "required": [
+        "typeProperty"
+      ],
+      "foo": "bar"
+    },
+    {
+      "type": "object",
+      "properties": {
+        "typeProperty": {
+          "type": "string",
+          "const": "NewType"
+        }
+      },
+      "required": [
+        "typeProperty"
+      ],
+      "foo": "bar"
+    },
+    {
+      "type": "object",
+      "properties": {
+        "i": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "b": {
+          "type": "boolean"
+        },
+        "typeProperty": {
+          "type": "string",
+          "const": "Struct"
+        }
+      },
+      "required": [
+        "typeProperty",
+        "i",
+        "b"
+      ],
+      "foo": "bar"
+    }
+  ],
+  "foo": "bar"
+}
\ No newline at end of file
diff --git a/schemars/tests/expected/extend_enum_untagged.json b/schemars/tests/expected/extend_enum_untagged.json
new file mode 100644
index 0000000..4f733fe
--- /dev/null
+++ b/schemars/tests/expected/extend_enum_untagged.json
@@ -0,0 +1,46 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "Untagged",
+  "anyOf": [
+    {
+      "type": "null",
+      "foo": "bar"
+    },
+    {
+      "foo": "bar"
+    },
+    {
+      "type": "array",
+      "prefixItems": [
+        {
+          "type": "integer",
+          "format": "int32"
+        },
+        {
+          "type": "boolean"
+        }
+      ],
+      "minItems": 2,
+      "maxItems": 2,
+      "foo": "bar"
+    },
+    {
+      "type": "object",
+      "properties": {
+        "i": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "b": {
+          "type": "boolean"
+        }
+      },
+      "required": [
+        "i",
+        "b"
+      ],
+      "foo": "bar"
+    }
+  ],
+  "foo": "bar"
+}
\ No newline at end of file
diff --git a/schemars/tests/expected/extend_struct.json b/schemars/tests/expected/extend_struct.json
new file mode 100644
index 0000000..fc7dd50
--- /dev/null
+++ b/schemars/tests/expected/extend_struct.json
@@ -0,0 +1,27 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "Struct",
+  "type": "object",
+  "properties": {
+    "value": {
+      "foo": "bar"
+    },
+    "int": {
+      "type": "overridden",
+      "format": "int32"
+    }
+  },
+  "required": [
+    "value",
+    "int"
+  ],
+  "msg": "hello world",
+  "obj": {
+    "array": [
+      null,
+      null
+    ]
+  },
+  "3": 3.0,
+  "pi": 3.14
+}
\ No newline at end of file
diff --git a/schemars/tests/expected/from_value_2019_09.json b/schemars/tests/expected/from_value_2019_09.json
index 52c0524..939410d 100644
--- a/schemars/tests/expected/from_value_2019_09.json
+++ b/schemars/tests/expected/from_value_2019_09.json
@@ -35,6 +35,8 @@
         },
         "my_tuple": {
           "type": "array",
+          "minItems": 2,
+          "maxItems": 2,
           "items": [
             {
               "type": "string",
@@ -44,9 +46,7 @@
             {
               "type": "integer"
             }
-          ],
-          "maxItems": 2,
-          "minItems": 2
+          ]
         }
       }
     }
diff --git a/schemars/tests/expected/from_value_draft07.json b/schemars/tests/expected/from_value_draft07.json
index de89fad..721e871 100644
--- a/schemars/tests/expected/from_value_draft07.json
+++ b/schemars/tests/expected/from_value_draft07.json
@@ -35,6 +35,8 @@
         },
         "my_tuple": {
           "type": "array",
+          "minItems": 2,
+          "maxItems": 2,
           "items": [
             {
               "type": "string",
@@ -44,9 +46,7 @@
             {
               "type": "integer"
             }
-          ],
-          "maxItems": 2,
-          "minItems": 2
+          ]
         }
       }
     }
diff --git a/schemars/tests/expected/from_value_openapi3.json b/schemars/tests/expected/from_value_openapi3.json
index 4e9dd2c..88f08a7 100644
--- a/schemars/tests/expected/from_value_openapi3.json
+++ b/schemars/tests/expected/from_value_openapi3.json
@@ -37,6 +37,8 @@
         },
         "my_tuple": {
           "type": "array",
+          "minItems": 2,
+          "maxItems": 2,
           "items": [
             {
               "type": "string",
@@ -46,9 +48,7 @@
             {
               "type": "integer"
             }
-          ],
-          "maxItems": 2,
-          "minItems": 2
+          ]
         }
       }
     }
diff --git a/schemars/tests/expected/schema_settings-2019_09.json b/schemars/tests/expected/schema_settings-2019_09.json
index 6b6dc61..bc99f15 100644
--- a/schemars/tests/expected/schema_settings-2019_09.json
+++ b/schemars/tests/expected/schema_settings-2019_09.json
@@ -30,6 +30,8 @@
       "type": "array",
       "items": {
         "type": "array",
+        "maxItems": 2,
+        "minItems": 2,
         "items": [
           {
             "type": "integer",
@@ -40,9 +42,7 @@
             "type": "integer",
             "format": "int64"
           }
-        ],
-        "minItems": 2,
-        "maxItems": 2
+        ]
       }
     }
   },
diff --git a/schemars/tests/expected/schema_settings-openapi3.json b/schemars/tests/expected/schema_settings-openapi3.json
index e5032f0..8365822 100644
--- a/schemars/tests/expected/schema_settings-openapi3.json
+++ b/schemars/tests/expected/schema_settings-openapi3.json
@@ -25,6 +25,8 @@
       "type": "array",
       "items": {
         "type": "array",
+        "maxItems": 2,
+        "minItems": 2,
         "items": [
           {
             "type": "integer",
@@ -35,9 +37,7 @@
             "type": "integer",
             "format": "int64"
           }
-        ],
-        "minItems": 2,
-        "maxItems": 2
+        ]
       }
     }
   },
diff --git a/schemars/tests/expected/schema_settings.json b/schemars/tests/expected/schema_settings.json
index a836cd6..4c435ad 100644
--- a/schemars/tests/expected/schema_settings.json
+++ b/schemars/tests/expected/schema_settings.json
@@ -30,6 +30,8 @@
       "type": "array",
       "items": {
         "type": "array",
+        "maxItems": 2,
+        "minItems": 2,
         "items": [
           {
             "type": "integer",
@@ -40,9 +42,7 @@
             "type": "integer",
             "format": "int64"
           }
-        ],
-        "minItems": 2,
-        "maxItems": 2
+        ]
       }
     }
   },
diff --git a/schemars/tests/extend.rs b/schemars/tests/extend.rs
new file mode 100644
index 0000000..08f42fa
--- /dev/null
+++ b/schemars/tests/extend.rs
@@ -0,0 +1,96 @@
+mod util;
+use schemars::JsonSchema;
+use serde_json::Value;
+use util::*;
+
+const THREE: f64 = 3.0;
+
+#[allow(dead_code)]
+#[derive(JsonSchema)]
+#[schemars(extend("msg" = concat!("hello ", "world"), "obj" = {"array": [null, ()]}))]
+#[schemars(extend("3" = THREE), extend("pi" = THREE + 0.14))]
+struct Struct {
+    #[schemars(extend("foo" = "bar"))]
+    value: Value,
+    #[schemars(extend("type" = "overridden"))]
+    int: i32,
+}
+
+#[test]
+fn doc_comments_struct() -> TestResult {
+    test_default_generated_schema::<Struct>("extend_struct")
+}
+
+#[allow(dead_code)]
+#[derive(JsonSchema)]
+#[schemars(extend("foo" = "bar"))]
+enum External {
+    #[schemars(extend("foo" = "bar"))]
+    Unit,
+    #[schemars(extend("foo" = "bar"))]
+    NewType(Value),
+    #[schemars(extend("foo" = "bar"))]
+    Tuple(i32, bool),
+    #[schemars(extend("foo" = "bar"))]
+    Struct { i: i32, b: bool },
+}
+
+#[test]
+fn doc_comments_enum_external() -> TestResult {
+    test_default_generated_schema::<External>("extend_enum_external")
+}
+
+#[allow(dead_code)]
+#[derive(JsonSchema)]
+#[schemars(tag = "typeProperty", extend("foo" = "bar"))]
+enum Internal {
+    #[schemars(extend("foo" = "bar"))]
+    Unit,
+    #[schemars(extend("foo" = "bar"))]
+    NewType(Value),
+    #[schemars(extend("foo" = "bar"))]
+    Struct { i: i32, b: bool },
+}
+
+#[test]
+fn doc_comments_enum_internal() -> TestResult {
+    test_default_generated_schema::<Internal>("extend_enum_internal")
+}
+
+#[allow(dead_code)]
+#[derive(JsonSchema)]
+#[schemars(untagged, extend("foo" = "bar"))]
+enum Untagged {
+    #[schemars(extend("foo" = "bar"))]
+    Unit,
+    #[schemars(extend("foo" = "bar"))]
+    NewType(Value),
+    #[schemars(extend("foo" = "bar"))]
+    Tuple(i32, bool),
+    #[schemars(extend("foo" = "bar"))]
+    Struct { i: i32, b: bool },
+}
+
+#[test]
+fn doc_comments_enum_untagged() -> TestResult {
+    test_default_generated_schema::<Untagged>("extend_enum_untagged")
+}
+
+#[allow(dead_code)]
+#[derive(JsonSchema)]
+#[schemars(tag = "t", content = "c", extend("foo" = "bar"))]
+enum Adjacent {
+    #[schemars(extend("foo" = "bar"))]
+    Unit,
+    #[schemars(extend("foo" = "bar"))]
+    NewType(Value),
+    #[schemars(extend("foo" = "bar"))]
+    Tuple(i32, bool),
+    #[schemars(extend("foo" = "bar"))]
+    Struct { i: i32, b: bool },
+}
+
+#[test]
+fn doc_comments_enum_adjacent() -> TestResult {
+    test_default_generated_schema::<Adjacent>("extend_enum_adjacent")
+}
diff --git a/schemars/tests/ui/invalid_extend.rs b/schemars/tests/ui/invalid_extend.rs
new file mode 100644
index 0000000..b7295f7
--- /dev/null
+++ b/schemars/tests/ui/invalid_extend.rs
@@ -0,0 +1,11 @@
+use schemars::JsonSchema;
+
+#[derive(JsonSchema)]
+#[schemars(extend(x))]
+#[schemars(extend("x"))]
+#[schemars(extend("x" = ))]
+#[schemars(extend("y" = "ok!", "y" = "duplicated!"), extend("y" = "duplicated!"))]
+#[schemars(extend("y" = "duplicated!"))]
+pub struct Struct;
+
+fn main() {}
diff --git a/schemars/tests/ui/invalid_extend.stderr b/schemars/tests/ui/invalid_extend.stderr
new file mode 100644
index 0000000..d7d2179
--- /dev/null
+++ b/schemars/tests/ui/invalid_extend.stderr
@@ -0,0 +1,35 @@
+error: expected string literal
+ --> tests/ui/invalid_extend.rs:4:19
+  |
+4 | #[schemars(extend(x))]
+  |                   ^
+
+error: expected `=`
+ --> tests/ui/invalid_extend.rs:5:22
+  |
+5 | #[schemars(extend("x"))]
+  |                      ^
+
+error: Expected extension value
+ --> tests/ui/invalid_extend.rs:6:25
+  |
+6 | #[schemars(extend("x" = ))]
+  |                         ^
+
+error: Duplicate extension key 'y'
+ --> tests/ui/invalid_extend.rs:7:32
+  |
+7 | #[schemars(extend("y" = "ok!", "y" = "duplicated!"), extend("y" = "duplicated!"))]
+  |                                ^^^
+
+error: Duplicate extension key 'y'
+ --> tests/ui/invalid_extend.rs:7:61
+  |
+7 | #[schemars(extend("y" = "ok!", "y" = "duplicated!"), extend("y" = "duplicated!"))]
+  |                                                             ^^^
+
+error: Duplicate extension key 'y'
+ --> tests/ui/invalid_extend.rs:8:19
+  |
+8 | #[schemars(extend("y" = "duplicated!"))]
+  |                   ^^^