/*! Contains the [`Transform`] trait, used to modify a constructed schema and optionally its subschemas. This trait is automatically implemented for functions of the form `fn(&mut Schema) -> ()`. # Recursive Transforms To make a transform recursive (i.e. apply it to subschemas), you have two options: 1. call the [`transform_subschemas`] function within the transform function 2. wrap the `Transform` in a [`RecursiveTransform`] # Examples To add a custom property to all object schemas: ``` # use schemars::{Schema, json_schema}; use schemars::transform::{Transform, transform_subschemas}; pub struct MyTransform; impl Transform for MyTransform { fn transform(&mut self, schema: &mut Schema) { // First, make our change to this schema schema.insert("my_property".to_string(), "hello world".into()); // Then apply the transform to any subschemas transform_subschemas(self, schema); } } let mut schema = json_schema!({ "type": "array", "items": {} }); MyTransform.transform(&mut schema); assert_eq!( schema, json_schema!({ "type": "array", "items": { "my_property": "hello world" }, "my_property": "hello world" }) ); ``` The same example with a `fn` transform: ``` # use schemars::{Schema, json_schema}; use schemars::transform::transform_subschemas; fn add_property(schema: &mut Schema) { schema.insert("my_property".to_string(), "hello world".into()); transform_subschemas(&mut add_property, schema) } let mut schema = json_schema!({ "type": "array", "items": {} }); add_property(&mut schema); assert_eq!( schema, json_schema!({ "type": "array", "items": { "my_property": "hello world" }, "my_property": "hello world" }) ); ``` And the same example using a closure wrapped in a `RecursiveTransform`: ``` # use schemars::{Schema, json_schema}; use schemars::transform::{Transform, RecursiveTransform}; let mut transform = RecursiveTransform(|schema: &mut Schema| { schema.insert("my_property".to_string(), "hello world".into()); }); let mut schema = json_schema!({ "type": "array", "items": {} }); transform.transform(&mut schema); assert_eq!( schema, json_schema!({ "type": "array", "items": { "my_property": "hello world" }, "my_property": "hello world" }) ); ``` */ use crate::_alloc_prelude::*; use crate::{consts::meta_schemas, Schema}; use alloc::borrow::Cow; use alloc::collections::BTreeSet; use serde_json::{json, Map, Value}; /// Trait used to modify a constructed schema and optionally its subschemas. /// /// See the [module documentation](self) for more details on implementing this trait. pub trait Transform { /// Applies the transform to the given [`Schema`]. /// /// When overriding this method, you may want to call the [`transform_subschemas`] function to /// also transform any subschemas. fn transform(&mut self, schema: &mut Schema); // Not public API // Hack to enable implementing Debug on Box even though closures don't // implement Debug #[doc(hidden)] fn _debug_type_name(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.write_str(core::any::type_name::()) } } impl Transform for F where F: FnMut(&mut Schema), { fn transform(&mut self, schema: &mut Schema) { self(schema); } } /// Applies the given [`Transform`] to all direct subschemas of the [`Schema`]. pub fn transform_subschemas(t: &mut T, schema: &mut Schema) { for (key, value) in schema.as_object_mut().into_iter().flatten() { // This is intentionally written to work with multiple JSON Schema versions, so that // users can add their own transforms on the end of e.g. `SchemaSettings::draft07()` and // they will still apply to all subschemas "as expected". // This is why this match statement contains both `additionalProperties` (which was // dropped in draft 2020-12) and `prefixItems` (which was added in draft 2020-12). match key.as_str() { "not" | "if" | "then" | "else" | "contains" | "additionalProperties" | "propertyNames" | "additionalItems" => { if let Ok(subschema) = value.try_into() { t.transform(subschema); } } "allOf" | "anyOf" | "oneOf" | "prefixItems" => { if let Some(array) = value.as_array_mut() { for value in array { if let Ok(subschema) = value.try_into() { t.transform(subschema); } } } } // Support `items` array even though this is not allowed in draft 2020-12 (see above // comment) "items" => { if let Some(array) = value.as_array_mut() { for value in array { if let Ok(subschema) = value.try_into() { t.transform(subschema); } } } else if let Ok(subschema) = value.try_into() { t.transform(subschema); } } "properties" | "patternProperties" | "$defs" | "definitions" => { if let Some(obj) = value.as_object_mut() { for value in obj.values_mut() { if let Ok(subschema) = value.try_into() { t.transform(subschema); } } } } _ => {} } } } // Similar to `transform_subschemas`, but only transforms subschemas that apply to the top-level // object, e.g. "oneOf" but not "properties". pub(crate) fn transform_immediate_subschemas( t: &mut T, schema: &mut Schema, ) { for (key, value) in schema.as_object_mut().into_iter().flatten() { match key.as_str() { "if" | "then" | "else" => { if let Ok(subschema) = value.try_into() { t.transform(subschema); } } "allOf" | "anyOf" | "oneOf" => { if let Some(array) = value.as_array_mut() { for value in array { if let Ok(subschema) = value.try_into() { t.transform(subschema); } } } } _ => {} } } } /// A helper struct that can wrap a non-recursive [`Transform`] (i.e. one that does not apply to /// subschemas) into a recursive one. /// /// Its implementation of `Transform` will first apply the inner transform to the "parent" schema, /// and then its subschemas (and their subschemas, and so on). /// /// # Example /// ``` /// # use schemars::{Schema, json_schema}; /// use schemars::transform::{Transform, RecursiveTransform}; /// /// let mut transform = RecursiveTransform(|schema: &mut Schema| { /// schema.insert("my_property".to_string(), "hello world".into()); /// }); /// /// let mut schema = json_schema!({ /// "type": "array", /// "items": {} /// }); /// /// transform.transform(&mut schema); /// /// assert_eq!( /// schema, /// json_schema!({ /// "type": "array", /// "items": { /// "my_property": "hello world" /// }, /// "my_property": "hello world" /// }) /// ); /// ``` #[derive(Debug, Clone)] #[allow(clippy::exhaustive_structs)] pub struct RecursiveTransform(pub T); impl Transform for RecursiveTransform where T: Transform, { fn transform(&mut self, schema: &mut Schema) { self.0.transform(schema); transform_subschemas(self, schema); } } /// Replaces boolean JSON Schemas with equivalent object schemas. /// /// This also applies to subschemas. /// /// This is useful for dialects of JSON Schema (e.g. OpenAPI 3.0) that do not support booleans as /// schemas. #[derive(Debug, Clone, Default)] #[non_exhaustive] pub struct ReplaceBoolSchemas { /// When set to `true`, a schema's `additionalProperties` property will not be changed from a /// boolean. /// /// Defaults to `false`. pub skip_additional_properties: bool, } impl Transform for ReplaceBoolSchemas { fn transform(&mut self, schema: &mut Schema) { if let Some(obj) = schema.as_object_mut() { if self.skip_additional_properties { if let Some((ap_key, ap_value)) = obj.remove_entry("additionalProperties") { transform_subschemas(self, schema); schema.insert(ap_key, ap_value); return; } } transform_subschemas(self, schema); } else { schema.ensure_object(); } } } /// Restructures JSON Schema objects so that the `$ref` property will never appear alongside any /// other properties. /// /// This also applies to subschemas. /// /// This is useful for versions of JSON Schema (e.g. Draft 7) that do not support other properties /// alongside `$ref`. #[derive(Debug, Clone, Default)] #[non_exhaustive] pub struct RemoveRefSiblings; impl Transform for RemoveRefSiblings { fn transform(&mut self, schema: &mut Schema) { transform_subschemas(self, schema); if let Some(obj) = schema.as_object_mut().filter(|o| o.len() > 1) { if let Some(ref_value) = obj.remove("$ref") { if let Value::Array(all_of) = obj.entry("allOf").or_insert(Value::Array(Vec::new())) { all_of.push(json!({ "$ref": ref_value })); } } } } } /// Removes the `examples` schema property and (if present) set its first value as the `example` /// property. /// /// This also applies to subschemas. /// /// This is useful for dialects of JSON Schema (e.g. OpenAPI 3.0) that do not support the `examples` /// property. #[derive(Debug, Clone, Default)] #[non_exhaustive] pub struct SetSingleExample; impl Transform for SetSingleExample { fn transform(&mut self, schema: &mut Schema) { transform_subschemas(self, schema); if let Some(Value::Array(examples)) = schema.remove("examples") { if let Some(first_example) = examples.into_iter().next() { schema.insert("example".into(), first_example); } } } } /// Replaces the `const` schema property with a single-valued `enum` property. /// /// This also applies to subschemas. /// /// This is useful for dialects of JSON Schema (e.g. OpenAPI 3.0) that do not support the `const` /// property. #[derive(Debug, Clone, Default)] #[non_exhaustive] pub struct ReplaceConstValue; impl Transform for ReplaceConstValue { fn transform(&mut self, schema: &mut Schema) { transform_subschemas(self, schema); if let Some(value) = schema.remove("const") { schema.insert("enum".into(), Value::Array(vec![value])); } } } /// Rename the `prefixItems` schema property to `items`. /// /// This also applies to subschemas. /// /// If the schema contains both `prefixItems` and `items`, then this additionally renames `items` to /// `additionalItems`. /// /// This is useful for versions of JSON Schema (e.g. Draft 7) that do not support the `prefixItems` /// property. #[derive(Debug, Clone, Default)] #[non_exhaustive] pub struct ReplacePrefixItems; impl Transform for ReplacePrefixItems { fn transform(&mut self, schema: &mut Schema) { transform_subschemas(self, schema); if let Some(prefix_items) = schema.remove("prefixItems") { let previous_items = schema.insert("items".to_owned(), prefix_items); if let Some(previous_items) = previous_items { schema.insert("additionalItems".to_owned(), previous_items); } } } } /// Adds a `"nullable": true` property to schemas that allow `null` types. /// /// This also applies to subschemas. /// /// This is useful for dialects of JSON Schema (e.g. OpenAPI 3.0) that use `nullable` instead of /// explicit null types. #[derive(Debug, Clone)] #[non_exhaustive] pub struct AddNullable { /// When set to `true` (the default), `"null"` will also be removed from the schemas `type`. pub remove_null_type: bool, /// When set to `true` (the default), a schema that has a type only allowing `null` will also /// have the equivalent `"const": null` inserted. pub add_const_null: bool, } impl Default for AddNullable { fn default() -> Self { Self { remove_null_type: true, add_const_null: true, } } } impl Transform for AddNullable { fn transform(&mut self, schema: &mut Schema) { if schema.has_type("null") { // has_type returned true so we know schema is an object let obj = schema.as_object_mut().unwrap(); obj.insert("nullable".into(), true.into()); // has_type returned true so we know "type" exists and is a string or array let ty = obj.get_mut("type").unwrap(); let only_allows_null = ty.is_string() || ty.as_array().unwrap().iter().all(|v| v == "null"); if only_allows_null { if self.add_const_null { obj.insert("const".to_string(), Value::Null); if self.remove_null_type { obj.remove("type"); } } else if self.remove_null_type { *ty = Value::Array(Vec::new()); } } else if self.remove_null_type { // We know `type` is an array containing at least one non-null type let array = ty.as_array_mut().unwrap(); array.retain(|t| t != "null"); if array.len() == 1 { *ty = array.remove(0); } } } transform_subschemas(self, schema); } } /// Replaces the `unevaluatedProperties` schema property with the `additionalProperties` property, /// adding properties from a schema's subschemas to its `properties` where necessary. /// /// This also applies to subschemas. /// /// This is useful for versions of JSON Schema (e.g. Draft 7) that do not support the /// `unevaluatedProperties` property. #[derive(Debug, Clone, Default)] #[non_exhaustive] pub struct ReplaceUnevaluatedProperties; impl Transform for ReplaceUnevaluatedProperties { fn transform(&mut self, schema: &mut Schema) { transform_subschemas(self, schema); let Some(up) = schema.remove("unevaluatedProperties") else { return; }; schema.insert("additionalProperties".to_owned(), up); let mut gather_property_names = GatherPropertyNames::default(); gather_property_names.transform(schema); let property_names = gather_property_names.0; if property_names.is_empty() { return; } if let Some(properties) = schema .ensure_object() .entry("properties") .or_insert(Map::new().into()) .as_object_mut() { for name in property_names { properties.entry(name).or_insert(true.into()); } } } } // Helper for getting property names for all *immediate* subschemas #[derive(Default)] struct GatherPropertyNames(BTreeSet); impl Transform for GatherPropertyNames { fn transform(&mut self, schema: &mut Schema) { self.0.extend( schema .as_object() .iter() .filter_map(|o| o.get("properties")) .filter_map(Value::as_object) .flat_map(Map::keys) .cloned(), ); transform_immediate_subschemas(self, schema); } } /// Removes any `format` values that are not defined by the JSON Schema standard or explicitly /// allowed by a custom list. /// /// This also applies to subschemas. /// /// By default, this will infer the version of JSON Schema from the schema's `$schema` property, /// and no additional formats will be allowed (even when the JSON schema allows nonstandard /// formats). /// /// # Example /// ``` /// use schemars::json_schema; /// use schemars::transform::{RestrictFormats, Transform}; /// /// let mut schema = schemars::json_schema!({ /// "$schema": "https://json-schema.org/draft/2020-12/schema", /// "anyOf": [ /// { /// "type": "string", /// "format": "uuid" /// }, /// { /// "$schema": "http://json-schema.org/draft-07/schema#", /// "type": "string", /// "format": "uuid" /// }, /// { /// "type": "string", /// "format": "allowed-custom-format" /// }, /// { /// "type": "string", /// "format": "forbidden-custom-format" /// } /// ] /// }); /// /// let mut transform = RestrictFormats::default(); /// transform.allowed_formats.insert("allowed-custom-format".into()); /// transform.transform(&mut schema); /// /// assert_eq!( /// schema, /// json_schema!({ /// "$schema": "https://json-schema.org/draft/2020-12/schema", /// "anyOf": [ /// { /// // "uuid" format is defined in draft 2020-12. /// "type": "string", /// "format": "uuid" /// }, /// { /// // "uuid" format is not defined in draft-07, so is removed from this subschema. /// "$schema": "http://json-schema.org/draft-07/schema#", /// "type": "string" /// }, /// { /// // "allowed-custom-format" format was present in `allowed_formats`... /// "type": "string", /// "format": "allowed-custom-format" /// }, /// { /// // ...but "forbidden-custom-format" format was not, so is also removed. /// "type": "string" /// } /// ] /// }) /// ); /// ``` #[derive(Debug, Clone)] #[non_exhaustive] pub struct RestrictFormats { /// Whether to read the schema's `$schema` property to determine which version of JSON Schema /// is being used, and allow only formats defined in that standard. If this is `true` but the /// JSON Schema version can't be determined because `$schema` is missing or unknown, then no /// `format` values will be removed. /// /// If this is set to `false`, then only the formats explicitly included in /// [`allowed_formats`](Self::allowed_formats) will be allowed. /// /// By default, this is `true`. pub infer_from_meta_schema: bool, /// Values of the `format` property in schemas that will always be allowed, regardless of the /// inferred version of JSON Schema. pub allowed_formats: BTreeSet>, } impl Default for RestrictFormats { fn default() -> Self { Self { infer_from_meta_schema: true, allowed_formats: BTreeSet::new(), } } } impl Transform for RestrictFormats { fn transform(&mut self, schema: &mut Schema) { let mut implementation = RestrictFormatsImpl { infer_from_meta_schema: self.infer_from_meta_schema, inferred_formats: None, allowed_formats: &self.allowed_formats, }; implementation.transform(schema); } } static DEFINED_FORMATS: &[&str] = &[ // `duration` and `uuid` are defined only in draft 2019-09+ "duration", "uuid", // The rest are also defined in draft-07: "date-time", "date", "time", "email", "idn-email", "hostname", "idn-hostname", "ipv4", "ipv6", "uri", "uri-reference", "iri", "iri-reference", "uri-template", "json-pointer", "relative-json-pointer", "regex", ]; struct RestrictFormatsImpl<'a> { infer_from_meta_schema: bool, inferred_formats: Option<&'static [&'static str]>, allowed_formats: &'a BTreeSet>, } impl Transform for RestrictFormatsImpl<'_> { fn transform(&mut self, schema: &mut Schema) { let Some(obj) = schema.as_object_mut() else { return; }; let previous_inferred_formats = self.inferred_formats; if self.infer_from_meta_schema && obj.contains_key("$schema") { self.inferred_formats = match obj .get("$schema") .and_then(Value::as_str) .unwrap_or_default() { meta_schemas::DRAFT07 => Some(&DEFINED_FORMATS[2..]), meta_schemas::DRAFT2019_09 | meta_schemas::DRAFT2020_12 => Some(DEFINED_FORMATS), _ => { // we can't handle an unrecognised meta-schema return; } }; } if let Some(format) = obj.get("format").and_then(Value::as_str) { if !self.allowed_formats.contains(format) && !self .inferred_formats .is_some_and(|formats| formats.contains(&format)) { obj.remove("format"); } } transform_subschemas(self, schema); self.inferred_formats = previous_inferred_formats; } } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; #[test] fn restrict_formats() { let mut schema = json_schema!({ "$schema": meta_schemas::DRAFT2020_12, "anyOf": [ { "format": "uuid" }, { "$schema": meta_schemas::DRAFT07, "format": "uuid" }, { "$schema": "http://unknown", "format": "uuid" }, { "format": "date" }, { "$schema": meta_schemas::DRAFT07, "format": "date" }, { "$schema": "http://unknown", "format": "date" }, { "format": "custom1" }, { "$schema": meta_schemas::DRAFT07, "format": "custom1" }, { "$schema": "http://unknown", "format": "custom1" }, { "format": "custom2" }, { "$schema": meta_schemas::DRAFT07, "format": "custom2" }, { "$schema": "http://unknown", "format": "custom2" }, ] }); let mut transform = RestrictFormats::default(); transform.allowed_formats.insert("custom1".into()); transform.transform(&mut schema); assert_eq!( schema, json_schema!({ "$schema": meta_schemas::DRAFT2020_12, "anyOf": [ { "format": "uuid" }, { "$schema": meta_schemas::DRAFT07 }, { "$schema": "http://unknown", "format": "uuid" }, { "format": "date" }, { "$schema": meta_schemas::DRAFT07, "format": "date" }, { "$schema": "http://unknown", "format": "date" }, { "format": "custom1" }, { "$schema": meta_schemas::DRAFT07, "format": "custom1" }, { "$schema": "http://unknown", "format": "custom1" }, { }, { "$schema": meta_schemas::DRAFT07 }, { "$schema": "http://unknown", "format": "custom2" }, ] }) ); } }