Repository: GREsau/schemars
Pull request #312 (2024-08-09 16:23:41)

Add `transform = ...` attribute
This allows running arbitrary transforms on generated schemas when deriving `JsonSchema`


Patch:
diff --git a/docs/1.1-attributes.md b/docs/1.1-attributes.md
index 317c51b..52ddc8a 100644
--- a/docs/1.1-attributes.md
+++ b/docs/1.1-attributes.md
@@ -49,6 +49,7 @@ TABLE OF CONTENTS
    - [`deprecated`](#deprecated)
    - [`crate`](#crate)
    - [`extend`](#extend)
+   - [`transform`](#transform)
    - [Doc Comments (`doc`)](#doc)
 
 </details>
@@ -326,10 +327,31 @@ Set on a container, variant or field to add properties (or replace existing prop
 The key must be a quoted string, and the value can be any expression that produces a type implementing `serde::Serialize`. The value can also be a JSON literal which can interpolate other values.
 
 ```plaintext
+#[derive(JsonSchema)]
 #[schemars(extend("simple" = "string value", "complex" = {"array": [1, 2, 3]}))]
 struct Struct;
 ```
 
+<h3 id="transform">
+
+`#[schemars(transform = some::transform)]`
+
+</h3>
+
+Set on a container, variant or field to run a `schemars::transform::Transform` against the generated schema. This can be specified multiple times to run multiple transforms.
+
+The `Transform` trait is implemented on functions with the signature `fn(&mut Schema) -> ()`, allowing you to do this:
+
+```rust
+fn my_transform(schema: &mut Schema) {
+   todo!()
+}
+
+#[derive(JsonSchema)]
+#[schemars(transform = my_transform)]
+struct Struct;
+```
+
 <h3 id="doc">
 
 Doc Comments (`#[doc = "..."]`)
diff --git a/docs/Gemfile b/docs/Gemfile
index d3cf985..0888f7a 100644
--- a/docs/Gemfile
+++ b/docs/Gemfile
@@ -24,7 +24,3 @@ install_if -> { RUBY_PLATFORM =~ %r!mingw|mswin|java! } do
   gem "tzinfo", "~> 1.2"
   gem "tzinfo-data"
 end
-
-# Performance-booster for watching directories on Windows
-gem "wdm", "~> 0.1.1", :install_if => Gem.win_platform?
-
diff --git a/schemars_derive/src/attr/mod.rs b/schemars_derive/src/attr/mod.rs
index 108cedc..6b6e676 100644
--- a/schemars_derive/src/attr/mod.rs
+++ b/schemars_derive/src/attr/mod.rs
@@ -27,6 +27,7 @@ pub struct Attrs {
     pub crate_name: Option<syn::Path>,
     pub is_renamed: bool,
     pub extensions: Vec<(String, TokenStream)>,
+    pub transforms: Vec<syn::Expr>,
 }
 
 #[derive(Debug)]
@@ -70,6 +71,7 @@ impl Attrs {
             deprecated: self.deprecated,
             examples: &self.examples,
             extensions: &self.extensions,
+            transforms: &self.transforms,
             read_only: false,
             write_only: false,
             default: None,
@@ -164,6 +166,25 @@ impl Attrs {
                     }
                 }
 
+                Meta::NameValue(m) if m.path.is_ident("transform") && attr_type == "schemars" => {
+                    if let syn::Expr::Lit(syn::ExprLit {
+                        lit: syn::Lit::Str(lit_str),
+                        ..
+                    }) = &m.value
+                    {
+                        if parse_lit_str::<syn::Expr>(lit_str).is_ok() {
+                            errors.error_spanned_by(
+                                &m.value,
+                                format!(
+                                    "Expected a `fn(&mut Schema)` or other value implementing `schemars::transform::Transform`, found `&str`.\nDid you mean `[schemars(transform = {})]`?",
+                                    lit_str.value()
+                                ),
+                            )
+                        }
+                    }
+                    self.transforms.push(m.value.clone());
+                }
+
                 Meta::List(m) if m.path.is_ident("extend") && attr_type == "schemars" => {
                     let parser =
                         syn::punctuated::Punctuated::<Extension, Token![,]>::parse_terminated;
@@ -224,7 +245,8 @@ impl Attrs {
                 crate_name: None,
                 is_renamed: _,
                 extensions,
-            } if examples.is_empty() && extensions.is_empty())
+                transforms
+            } if examples.is_empty() && extensions.is_empty() && transforms.is_empty())
     }
 }
 
diff --git a/schemars_derive/src/metadata.rs b/schemars_derive/src/metadata.rs
index 6a3808c..c6b5dfe 100644
--- a/schemars_derive/src/metadata.rs
+++ b/schemars_derive/src/metadata.rs
@@ -1,4 +1,5 @@
 use proc_macro2::TokenStream;
+use syn::spanned::Spanned;
 
 #[derive(Debug, Clone)]
 pub struct SchemaMetadata<'a> {
@@ -10,6 +11,7 @@ pub struct SchemaMetadata<'a> {
     pub examples: &'a [syn::Path],
     pub default: Option<TokenStream>,
     pub extensions: &'a [(String, TokenStream)],
+    pub transforms: &'a [syn::Expr],
 }
 
 impl<'a> SchemaMetadata<'a> {
@@ -23,6 +25,18 @@ impl<'a> SchemaMetadata<'a> {
                 schema
             }}
         }
+        if !self.transforms.is_empty() {
+            let apply_transforms = self.transforms.iter().map(|t| {
+                quote_spanned! {t.span()=>
+                    schemars::transform::Transform::transform(&mut #t, &mut schema);
+                }
+            });
+            *schema_expr = quote! {{
+                let mut schema = #schema_expr;
+                #(#apply_transforms)*
+                schema
+            }};
+        }
     }
 
     fn make_setters(&self) -> Vec<TokenStream> {


Test patch:
diff --git a/schemars/tests/expected/transform_enum_external.json b/schemars/tests/expected/transform_enum_external.json
new file mode 100644
index 0000000..af746e3
--- /dev/null
+++ b/schemars/tests/expected/transform_enum_external.json
@@ -0,0 +1,25 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "External",
+  "oneOf": [
+    {
+      "type": "string",
+      "const": "Unit",
+      "propertyCount": 0,
+      "upperType": "STRING"
+    },
+    {
+      "type": "object",
+      "properties": {
+        "NewType": true
+      },
+      "required": [
+        "NewType"
+      ],
+      "additionalProperties": false,
+      "propertyCount": 1,
+      "upperType": "OBJECT"
+    }
+  ],
+  "propertyCount": 0
+}
\ No newline at end of file
diff --git a/schemars/tests/expected/transform_struct.json b/schemars/tests/expected/transform_struct.json
new file mode 100644
index 0000000..6723414
--- /dev/null
+++ b/schemars/tests/expected/transform_struct.json
@@ -0,0 +1,20 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "Struct",
+  "type": "object",
+  "properties": {
+    "value": true,
+    "int": {
+      "type": "integer",
+      "format": "int32",
+      "propertyCount": 0,
+      "upperType": "INTEGER"
+    }
+  },
+  "required": [
+    "value",
+    "int"
+  ],
+  "upperType": "OBJECT",
+  "propertyCount": 2
+}
\ No newline at end of file
diff --git a/schemars/tests/extend.rs b/schemars/tests/extend.rs
index 08f42fa..2b44f5e 100644
--- a/schemars/tests/extend.rs
+++ b/schemars/tests/extend.rs
@@ -17,7 +17,7 @@ struct Struct {
 }
 
 #[test]
-fn doc_comments_struct() -> TestResult {
+fn extend_struct() -> TestResult {
     test_default_generated_schema::<Struct>("extend_struct")
 }
 
@@ -36,7 +36,7 @@ enum External {
 }
 
 #[test]
-fn doc_comments_enum_external() -> TestResult {
+fn extend_enum_external() -> TestResult {
     test_default_generated_schema::<External>("extend_enum_external")
 }
 
@@ -53,7 +53,7 @@ enum Internal {
 }
 
 #[test]
-fn doc_comments_enum_internal() -> TestResult {
+fn extend_enum_internal() -> TestResult {
     test_default_generated_schema::<Internal>("extend_enum_internal")
 }
 
@@ -72,7 +72,7 @@ enum Untagged {
 }
 
 #[test]
-fn doc_comments_enum_untagged() -> TestResult {
+fn extend_enum_untagged() -> TestResult {
     test_default_generated_schema::<Untagged>("extend_enum_untagged")
 }
 
@@ -91,6 +91,6 @@ enum Adjacent {
 }
 
 #[test]
-fn doc_comments_enum_adjacent() -> TestResult {
+fn extend_enum_adjacent() -> TestResult {
     test_default_generated_schema::<Adjacent>("extend_enum_adjacent")
 }
diff --git a/schemars/tests/transform.rs b/schemars/tests/transform.rs
new file mode 100644
index 0000000..13ac30b
--- /dev/null
+++ b/schemars/tests/transform.rs
@@ -0,0 +1,51 @@
+mod util;
+use schemars::{transform::RecursiveTransform, JsonSchema, Schema};
+use serde_json::Value;
+use util::*;
+
+fn capitalize_type(schema: &mut Schema) {
+    if let Some(obj) = schema.as_object_mut() {
+        if let Some(Value::String(ty)) = obj.get("type") {
+            obj.insert("upperType".to_owned(), ty.to_uppercase().into());
+        }
+    }
+}
+
+fn insert_property_count(schema: &mut Schema) {
+    if let Some(obj) = schema.as_object_mut() {
+        let count = obj
+            .get("properties")
+            .and_then(|p| p.as_object())
+            .map_or(0, |p| p.len());
+        obj.insert("propertyCount".to_owned(), count.into());
+    }
+}
+
+#[allow(dead_code)]
+#[derive(JsonSchema)]
+#[schemars(transform = RecursiveTransform(capitalize_type), transform = insert_property_count)]
+struct Struct {
+    value: Value,
+    #[schemars(transform = insert_property_count)]
+    int: i32,
+}
+
+#[test]
+fn transform_struct() -> TestResult {
+    test_default_generated_schema::<Struct>("transform_struct")
+}
+
+#[allow(dead_code)]
+#[derive(JsonSchema)]
+#[schemars(transform = RecursiveTransform(capitalize_type), transform = insert_property_count)]
+enum External {
+    #[schemars(transform = insert_property_count)]
+    Unit,
+    #[schemars(transform = insert_property_count)]
+    NewType(Value),
+}
+
+#[test]
+fn transform_enum_external() -> TestResult {
+    test_default_generated_schema::<External>("transform_enum_external")
+}
diff --git a/schemars/tests/ui/transform_str.rs b/schemars/tests/ui/transform_str.rs
new file mode 100644
index 0000000..6570acc
--- /dev/null
+++ b/schemars/tests/ui/transform_str.rs
@@ -0,0 +1,7 @@
+use schemars::JsonSchema;
+
+#[derive(JsonSchema)]
+#[schemars(transform = "x")]
+pub struct Struct;
+
+fn main() {}
diff --git a/schemars/tests/ui/transform_str.stderr b/schemars/tests/ui/transform_str.stderr
new file mode 100644
index 0000000..6ee3698
--- /dev/null
+++ b/schemars/tests/ui/transform_str.stderr
@@ -0,0 +1,6 @@
+error: Expected a `fn(&mut Schema)` or other value implementing `schemars::transform::Transform`, found `&str`.
+       Did you mean `[schemars(transform = x)]`?
+ --> tests/ui/transform_str.rs:4:24
+  |
+4 | #[schemars(transform = "x")]
+  |                        ^^^