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}