schemars/
transform.rs

1/*!
2Contains the [`Transform`] trait, used to modify a constructed schema and optionally its subschemas.
3This trait is automatically implemented for functions of the form `fn(&mut Schema) -> ()`.
4
5# Recursive Transforms
6
7To make a transform recursive (i.e. apply it to subschemas), you have two options:
81. call the [`transform_subschemas`] function within the transform function
92. wrap the `Transform` in a [`RecursiveTransform`]
10
11# Examples
12
13To add a custom property to all object schemas:
14
15```
16# use schemars::{Schema, json_schema};
17use schemars::transform::{Transform, transform_subschemas};
18
19pub struct MyTransform;
20
21impl Transform for MyTransform {
22    fn transform(&mut self, schema: &mut Schema) {
23        // First, make our change to this schema
24        schema.insert("my_property".to_string(), "hello world".into());
25
26        // Then apply the transform to any subschemas
27        transform_subschemas(self, schema);
28    }
29}
30
31let mut schema = json_schema!({
32    "type": "array",
33    "items": {}
34});
35
36MyTransform.transform(&mut schema);
37
38assert_eq!(
39    schema,
40    json_schema!({
41        "type": "array",
42        "items": {
43            "my_property": "hello world"
44        },
45        "my_property": "hello world"
46    })
47);
48```
49
50The same example with a `fn` transform:
51```
52# use schemars::{Schema, json_schema};
53use schemars::transform::transform_subschemas;
54
55fn add_property(schema: &mut Schema) {
56    schema.insert("my_property".to_string(), "hello world".into());
57
58    transform_subschemas(&mut add_property, schema)
59}
60
61let mut schema = json_schema!({
62    "type": "array",
63    "items": {}
64});
65
66add_property(&mut schema);
67
68assert_eq!(
69    schema,
70    json_schema!({
71        "type": "array",
72        "items": {
73            "my_property": "hello world"
74        },
75        "my_property": "hello world"
76    })
77);
78```
79
80And the same example using a closure wrapped in a `RecursiveTransform`:
81```
82# use schemars::{Schema, json_schema};
83use schemars::transform::{Transform, RecursiveTransform};
84
85let mut transform = RecursiveTransform(|schema: &mut Schema| {
86    schema.insert("my_property".to_string(), "hello world".into());
87});
88
89let mut schema = json_schema!({
90    "type": "array",
91    "items": {}
92});
93
94transform.transform(&mut schema);
95
96assert_eq!(
97    schema,
98    json_schema!({
99        "type": "array",
100        "items": {
101            "my_property": "hello world"
102        },
103        "my_property": "hello world"
104    })
105);
106```
107
108*/
109use crate::_alloc_prelude::*;
110use crate::{consts::meta_schemas, Schema};
111use alloc::borrow::Cow;
112use alloc::collections::BTreeSet;
113use serde_json::{json, Map, Value};
114
115/// Trait used to modify a constructed schema and optionally its subschemas.
116///
117/// See the [module documentation](self) for more details on implementing this trait.
118pub trait Transform {
119    /// Applies the transform to the given [`Schema`].
120    ///
121    /// When overriding this method, you may want to call the [`transform_subschemas`] function to
122    /// also transform any subschemas.
123    fn transform(&mut self, schema: &mut Schema);
124
125    // Not public API
126    // Hack to enable implementing Debug on Box<dyn GenTransform> even though closures don't
127    // implement Debug
128    #[doc(hidden)]
129    fn _debug_type_name(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
130        f.write_str(core::any::type_name::<Self>())
131    }
132}
133
134impl<F> Transform for F
135where
136    F: FnMut(&mut Schema),
137{
138    fn transform(&mut self, schema: &mut Schema) {
139        self(schema);
140    }
141}
142
143/// Applies the given [`Transform`] to all direct subschemas of the [`Schema`].
144pub fn transform_subschemas<T: Transform + ?Sized>(t: &mut T, schema: &mut Schema) {
145    for (key, value) in schema.as_object_mut().into_iter().flatten() {
146        // This is intentionally written to work with multiple JSON Schema versions, so that
147        // users can add their own transforms on the end of e.g. `SchemaSettings::draft07()` and
148        // they will still apply to all subschemas "as expected".
149        // This is why this match statement contains both `additionalProperties` (which was
150        // dropped in draft 2020-12) and `prefixItems` (which was added in draft 2020-12).
151        match key.as_str() {
152            "not"
153            | "if"
154            | "then"
155            | "else"
156            | "contains"
157            | "additionalProperties"
158            | "propertyNames"
159            | "additionalItems" => {
160                if let Ok(subschema) = value.try_into() {
161                    t.transform(subschema);
162                }
163            }
164            "allOf" | "anyOf" | "oneOf" | "prefixItems" => {
165                if let Some(array) = value.as_array_mut() {
166                    for value in array {
167                        if let Ok(subschema) = value.try_into() {
168                            t.transform(subschema);
169                        }
170                    }
171                }
172            }
173            // Support `items` array even though this is not allowed in draft 2020-12 (see above
174            // comment)
175            "items" => {
176                if let Some(array) = value.as_array_mut() {
177                    for value in array {
178                        if let Ok(subschema) = value.try_into() {
179                            t.transform(subschema);
180                        }
181                    }
182                } else if let Ok(subschema) = value.try_into() {
183                    t.transform(subschema);
184                }
185            }
186            "properties" | "patternProperties" | "$defs" | "definitions" => {
187                if let Some(obj) = value.as_object_mut() {
188                    for value in obj.values_mut() {
189                        if let Ok(subschema) = value.try_into() {
190                            t.transform(subschema);
191                        }
192                    }
193                }
194            }
195            _ => {}
196        }
197    }
198}
199
200// Similar to `transform_subschemas`, but only transforms subschemas that apply to the top-level
201// object, e.g. "oneOf" but not "properties".
202pub(crate) fn transform_immediate_subschemas<T: Transform + ?Sized>(
203    t: &mut T,
204    schema: &mut Schema,
205) {
206    for (key, value) in schema.as_object_mut().into_iter().flatten() {
207        match key.as_str() {
208            "if" | "then" | "else" => {
209                if let Ok(subschema) = value.try_into() {
210                    t.transform(subschema);
211                }
212            }
213            "allOf" | "anyOf" | "oneOf" => {
214                if let Some(array) = value.as_array_mut() {
215                    for value in array {
216                        if let Ok(subschema) = value.try_into() {
217                            t.transform(subschema);
218                        }
219                    }
220                }
221            }
222            _ => {}
223        }
224    }
225}
226
227/// A helper struct that can wrap a non-recursive [`Transform`] (i.e. one that does not apply to
228/// subschemas) into a recursive one.
229///
230/// Its implementation of `Transform` will first apply the inner transform to the "parent" schema,
231/// and then its subschemas (and their subschemas, and so on).
232///
233/// # Example
234/// ```
235/// # use schemars::{Schema, json_schema};
236/// use schemars::transform::{Transform, RecursiveTransform};
237///
238/// let mut transform = RecursiveTransform(|schema: &mut Schema| {
239///     schema.insert("my_property".to_string(), "hello world".into());
240/// });
241///
242/// let mut schema = json_schema!({
243///     "type": "array",
244///     "items": {}
245/// });
246///
247/// transform.transform(&mut schema);
248///
249/// assert_eq!(
250///     schema,
251///     json_schema!({
252///         "type": "array",
253///         "items": {
254///             "my_property": "hello world"
255///         },
256///         "my_property": "hello world"
257///     })
258/// );
259/// ```
260#[derive(Debug, Clone)]
261#[allow(clippy::exhaustive_structs)]
262pub struct RecursiveTransform<T>(pub T);
263
264impl<T> Transform for RecursiveTransform<T>
265where
266    T: Transform,
267{
268    fn transform(&mut self, schema: &mut Schema) {
269        self.0.transform(schema);
270        transform_subschemas(self, schema);
271    }
272}
273
274/// Replaces boolean JSON Schemas with equivalent object schemas.
275///
276/// This also applies to subschemas.
277///
278/// This is useful for dialects of JSON Schema (e.g. OpenAPI 3.0) that do not support booleans as
279/// schemas.
280#[derive(Debug, Clone, Default)]
281#[non_exhaustive]
282pub struct ReplaceBoolSchemas {
283    /// When set to `true`, a schema's `additionalProperties` property will not be changed from a
284    /// boolean.
285    ///
286    /// Defaults to `false`.
287    pub skip_additional_properties: bool,
288}
289
290impl Transform for ReplaceBoolSchemas {
291    fn transform(&mut self, schema: &mut Schema) {
292        if let Some(obj) = schema.as_object_mut() {
293            if self.skip_additional_properties {
294                if let Some((ap_key, ap_value)) = obj.remove_entry("additionalProperties") {
295                    transform_subschemas(self, schema);
296
297                    schema.insert(ap_key, ap_value);
298
299                    return;
300                }
301            }
302
303            transform_subschemas(self, schema);
304        } else {
305            schema.ensure_object();
306        }
307    }
308}
309
310/// Restructures JSON Schema objects so that the `$ref` property will never appear alongside any
311/// other properties.
312///
313/// This also applies to subschemas.
314///
315/// This is useful for versions of JSON Schema (e.g. Draft 7) that do not support other properties
316/// alongside `$ref`.
317#[derive(Debug, Clone, Default)]
318#[non_exhaustive]
319pub struct RemoveRefSiblings;
320
321impl Transform for RemoveRefSiblings {
322    fn transform(&mut self, schema: &mut Schema) {
323        transform_subschemas(self, schema);
324
325        if let Some(obj) = schema.as_object_mut().filter(|o| o.len() > 1) {
326            if let Some(ref_value) = obj.remove("$ref") {
327                if let Value::Array(all_of) = obj.entry("allOf").or_insert(Value::Array(Vec::new()))
328                {
329                    all_of.push(json!({
330                        "$ref": ref_value
331                    }));
332                }
333            }
334        }
335    }
336}
337
338/// Removes the `examples` schema property and (if present) set its first value as the `example`
339/// property.
340///
341/// This also applies to subschemas.
342///
343/// This is useful for dialects of JSON Schema (e.g. OpenAPI 3.0) that do not support the `examples`
344/// property.
345#[derive(Debug, Clone, Default)]
346#[non_exhaustive]
347pub struct SetSingleExample;
348
349impl Transform for SetSingleExample {
350    fn transform(&mut self, schema: &mut Schema) {
351        transform_subschemas(self, schema);
352
353        if let Some(Value::Array(examples)) = schema.remove("examples") {
354            if let Some(first_example) = examples.into_iter().next() {
355                schema.insert("example".into(), first_example);
356            }
357        }
358    }
359}
360
361/// Replaces the `const` schema property with a single-valued `enum` property.
362///
363/// This also applies to subschemas.
364///
365/// This is useful for dialects of JSON Schema (e.g. OpenAPI 3.0) that do not support the `const`
366/// property.
367#[derive(Debug, Clone, Default)]
368#[non_exhaustive]
369pub struct ReplaceConstValue;
370
371impl Transform for ReplaceConstValue {
372    fn transform(&mut self, schema: &mut Schema) {
373        transform_subschemas(self, schema);
374
375        if let Some(value) = schema.remove("const") {
376            schema.insert("enum".into(), Value::Array(vec![value]));
377        }
378    }
379}
380
381/// Rename the `prefixItems` schema property to `items`.
382///
383/// This also applies to subschemas.
384///
385/// If the schema contains both `prefixItems` and `items`, then this additionally renames `items` to
386/// `additionalItems`.
387///
388/// This is useful for versions of JSON Schema (e.g. Draft 7) that do not support the `prefixItems`
389/// property.
390#[derive(Debug, Clone, Default)]
391#[non_exhaustive]
392pub struct ReplacePrefixItems;
393
394impl Transform for ReplacePrefixItems {
395    fn transform(&mut self, schema: &mut Schema) {
396        transform_subschemas(self, schema);
397
398        if let Some(prefix_items) = schema.remove("prefixItems") {
399            let previous_items = schema.insert("items".to_owned(), prefix_items);
400
401            if let Some(previous_items) = previous_items {
402                schema.insert("additionalItems".to_owned(), previous_items);
403            }
404        }
405    }
406}
407
408/// Adds a `"nullable": true` property to schemas that allow `null` types.
409///
410/// This also applies to subschemas.
411///
412/// This is useful for dialects of JSON Schema (e.g. OpenAPI 3.0) that use `nullable` instead of
413/// explicit null types.
414#[derive(Debug, Clone)]
415#[non_exhaustive]
416pub struct AddNullable {
417    /// When set to `true` (the default), `"null"` will also be removed from the schemas `type`.
418    pub remove_null_type: bool,
419    /// When set to `true` (the default), a schema that has a type only allowing `null` will also
420    /// have the equivalent `"const": null` inserted.
421    pub add_const_null: bool,
422}
423
424impl Default for AddNullable {
425    fn default() -> Self {
426        Self {
427            remove_null_type: true,
428            add_const_null: true,
429        }
430    }
431}
432
433impl Transform for AddNullable {
434    fn transform(&mut self, schema: &mut Schema) {
435        if schema.has_type("null") {
436            schema.insert("nullable".into(), true.into());
437
438            // has_type returned true so we know "type" exists and is a string or array
439            let ty = schema.get_mut("type").unwrap();
440            let only_allows_null =
441                ty.is_string() || ty.as_array().unwrap().iter().all(|v| v == "null");
442
443            if only_allows_null {
444                if self.add_const_null {
445                    schema.insert("const".to_string(), Value::Null);
446
447                    if self.remove_null_type {
448                        schema.remove("type");
449                    }
450                } else if self.remove_null_type {
451                    *ty = Value::Array(Vec::new());
452                }
453            } else if self.remove_null_type {
454                // We know `type` is an array containing at least one non-null type
455                let array = ty.as_array_mut().unwrap();
456                array.retain(|t| t != "null");
457
458                if array.len() == 1 {
459                    *ty = array.remove(0);
460                }
461            }
462        }
463
464        transform_subschemas(self, schema);
465    }
466}
467
468/// Replaces the `unevaluatedProperties` schema property with the `additionalProperties` property,
469/// adding properties from a schema's subschemas to its `properties` where necessary.
470///
471/// This also applies to subschemas.
472///
473/// This is useful for versions of JSON Schema (e.g. Draft 7) that do not support the
474/// `unevaluatedProperties` property.
475#[derive(Debug, Clone, Default)]
476#[non_exhaustive]
477pub struct ReplaceUnevaluatedProperties;
478
479impl Transform for ReplaceUnevaluatedProperties {
480    fn transform(&mut self, schema: &mut Schema) {
481        transform_subschemas(self, schema);
482
483        let Some(up) = schema.remove("unevaluatedProperties") else {
484            return;
485        };
486
487        schema.insert("additionalProperties".to_owned(), up);
488
489        let mut gather_property_names = GatherPropertyNames::default();
490        gather_property_names.transform(schema);
491        let property_names = gather_property_names.0;
492
493        if property_names.is_empty() {
494            return;
495        }
496
497        if let Some(properties) = schema
498            .ensure_object()
499            .entry("properties")
500            .or_insert(Map::new().into())
501            .as_object_mut()
502        {
503            for name in property_names {
504                properties.entry(name).or_insert(true.into());
505            }
506        }
507    }
508}
509
510// Helper for getting property names for all *immediate* subschemas
511#[derive(Default)]
512struct GatherPropertyNames(BTreeSet<String>);
513
514impl Transform for GatherPropertyNames {
515    fn transform(&mut self, schema: &mut Schema) {
516        self.0.extend(
517            schema
518                .as_object()
519                .iter()
520                .filter_map(|o| o.get("properties"))
521                .filter_map(Value::as_object)
522                .flat_map(Map::keys)
523                .cloned(),
524        );
525
526        transform_immediate_subschemas(self, schema);
527    }
528}
529
530/// Removes any `format` values that are not defined by the JSON Schema standard or explicitly
531/// allowed by a custom list.
532///
533/// This also applies to subschemas.
534///
535/// By default, this will infer the version of JSON Schema from the schema's `$schema` property,
536/// and no additional formats will be allowed (even when the JSON schema allows nonstandard
537/// formats).
538///
539/// # Example
540/// ```
541/// use schemars::json_schema;
542/// use schemars::transform::{RestrictFormats, Transform};
543///
544/// let mut schema = schemars::json_schema!({
545///     "$schema": "https://json-schema.org/draft/2020-12/schema",
546///     "anyOf": [
547///         {
548///             "type": "string",
549///             "format": "uuid"
550///         },
551///         {
552///             "$schema": "http://json-schema.org/draft-07/schema#",
553///             "type": "string",
554///             "format": "uuid"
555///         },
556///         {
557///             "type": "string",
558///             "format": "allowed-custom-format"
559///         },
560///         {
561///             "type": "string",
562///             "format": "forbidden-custom-format"
563///         }
564///     ]
565/// });
566///
567/// let mut transform = RestrictFormats::default();
568/// transform.allowed_formats.insert("allowed-custom-format".into());
569/// transform.transform(&mut schema);
570///
571/// assert_eq!(
572///     schema,
573///     json_schema!({
574///         "$schema": "https://json-schema.org/draft/2020-12/schema",
575///         "anyOf": [
576///             {
577///                 // "uuid" format is defined in draft 2020-12.
578///                 "type": "string",
579///                 "format": "uuid"
580///             },
581///             {
582///                 // "uuid" format is not defined in draft-07, so is removed from this subschema.
583///                 "$schema": "http://json-schema.org/draft-07/schema#",
584///                 "type": "string"
585///             },
586///             {
587///                 // "allowed-custom-format" format was present in `allowed_formats`...
588///                 "type": "string",
589///                 "format": "allowed-custom-format"
590///             },
591///             {
592///                 // ...but "forbidden-custom-format" format was not, so is also removed.
593///                 "type": "string"
594///             }
595///         ]
596///     })
597/// );
598/// ```
599#[derive(Debug, Clone)]
600#[non_exhaustive]
601pub struct RestrictFormats {
602    /// Whether to read the schema's `$schema` property to determine which version of JSON Schema
603    /// is being used, and allow only formats defined in that standard. If this is `true` but the
604    /// JSON Schema version can't be determined because `$schema` is missing or unknown, then no
605    /// `format` values will be removed.
606    ///
607    /// If this is set to `false`, then only the formats explicitly included in
608    /// [`allowed_formats`](Self::allowed_formats) will be allowed.
609    ///
610    /// By default, this is `true`.
611    pub infer_from_meta_schema: bool,
612    /// Values of the `format` property in schemas that will always be allowed, regardless of the
613    /// inferred version of JSON Schema.
614    pub allowed_formats: BTreeSet<Cow<'static, str>>,
615}
616
617impl Default for RestrictFormats {
618    fn default() -> Self {
619        Self {
620            infer_from_meta_schema: true,
621            allowed_formats: BTreeSet::new(),
622        }
623    }
624}
625
626impl Transform for RestrictFormats {
627    fn transform(&mut self, schema: &mut Schema) {
628        let mut implementation = RestrictFormatsImpl {
629            infer_from_meta_schema: self.infer_from_meta_schema,
630            inferred_formats: None,
631            allowed_formats: &self.allowed_formats,
632        };
633
634        implementation.transform(schema);
635    }
636}
637
638static DEFINED_FORMATS: &[&str] = &[
639    // `duration` and `uuid` are defined only in draft 2019-09+
640    "duration",
641    "uuid",
642    // The rest are also defined in draft-07:
643    "date-time",
644    "date",
645    "time",
646    "email",
647    "idn-email",
648    "hostname",
649    "idn-hostname",
650    "ipv4",
651    "ipv6",
652    "uri",
653    "uri-reference",
654    "iri",
655    "iri-reference",
656    "uri-template",
657    "json-pointer",
658    "relative-json-pointer",
659    "regex",
660];
661
662struct RestrictFormatsImpl<'a> {
663    infer_from_meta_schema: bool,
664    inferred_formats: Option<&'static [&'static str]>,
665    allowed_formats: &'a BTreeSet<Cow<'static, str>>,
666}
667
668impl Transform for RestrictFormatsImpl<'_> {
669    fn transform(&mut self, schema: &mut Schema) {
670        let Some(obj) = schema.as_object_mut() else {
671            return;
672        };
673
674        let previous_inferred_formats = self.inferred_formats;
675
676        if self.infer_from_meta_schema && obj.contains_key("$schema") {
677            self.inferred_formats = match obj
678                .get("$schema")
679                .and_then(Value::as_str)
680                .unwrap_or_default()
681            {
682                meta_schemas::DRAFT07 => Some(&DEFINED_FORMATS[2..]),
683                meta_schemas::DRAFT2019_09 | meta_schemas::DRAFT2020_12 => Some(DEFINED_FORMATS),
684                _ => {
685                    // we can't handle an unrecognised meta-schema
686                    return;
687                }
688            };
689        }
690
691        if let Some(format) = obj.get("format").and_then(Value::as_str) {
692            if !self.allowed_formats.contains(format)
693                && !self
694                    .inferred_formats
695                    .is_some_and(|formats| formats.contains(&format))
696            {
697                obj.remove("format");
698            }
699        }
700
701        transform_subschemas(self, schema);
702
703        self.inferred_formats = previous_inferred_formats;
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710    use pretty_assertions::assert_eq;
711
712    #[test]
713    fn restrict_formats() {
714        let mut schema = json_schema!({
715            "$schema": meta_schemas::DRAFT2020_12,
716            "anyOf": [
717                { "format": "uuid" },
718                { "$schema": meta_schemas::DRAFT07, "format": "uuid" },
719                { "$schema": "http://unknown", "format": "uuid" },
720                { "format": "date" },
721                { "$schema": meta_schemas::DRAFT07, "format": "date" },
722                { "$schema": "http://unknown", "format": "date" },
723                { "format": "custom1" },
724                { "$schema": meta_schemas::DRAFT07, "format": "custom1" },
725                { "$schema": "http://unknown", "format": "custom1" },
726                { "format": "custom2" },
727                { "$schema": meta_schemas::DRAFT07, "format": "custom2" },
728                { "$schema": "http://unknown", "format": "custom2" },
729            ]
730        });
731
732        let mut transform = RestrictFormats::default();
733        transform.allowed_formats.insert("custom1".into());
734        transform.transform(&mut schema);
735
736        assert_eq!(
737            schema,
738            json_schema!({
739                "$schema": meta_schemas::DRAFT2020_12,
740                "anyOf": [
741                    { "format": "uuid" },
742                    { "$schema": meta_schemas::DRAFT07 },
743                    { "$schema": "http://unknown", "format": "uuid" },
744                    { "format": "date" },
745                    { "$schema": meta_schemas::DRAFT07, "format": "date" },
746                    { "$schema": "http://unknown", "format": "date" },
747                    { "format": "custom1" },
748                    { "$schema": meta_schemas::DRAFT07, "format": "custom1" },
749                    { "$schema": "http://unknown", "format": "custom1" },
750                    { },
751                    { "$schema": meta_schemas::DRAFT07 },
752                    { "$schema": "http://unknown", "format": "custom2" },
753                ]
754            })
755        );
756    }
757}