From 387ff56345578d08d052663a9275c1a1f96a2a3e Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Tue, 1 Sep 2026 19:19:02 -0500 Subject: [PATCH 1/3] (GH-538) Define transforms for `Option` Prior to this change we had no (relatively) convenient way to make the emitted schemas for structs with `Option` fields more idiomatic. As of the `v1.0.0` release of schemars, the schema generator _always_ wraps handling for `Option` fields. The emitted schema depends on `T`: - When `T` is for a primitive type, like `String` or `bool`, the emitted schema looks like: ```json { "type": "object", "properties": { "string_field": { "type": ["string", "null"] }, "boolean_field": { "type": ["boolean", "null"] } } } ``` - When `T` is for an inlined string enum, the emitted schema looks like: ```json { "type": "object", "properties": { "enum_field": { "type": ["string", "null"], "enum": ["Variant1", "Variant2", "Variant3", null] } } } ``` - When `T` is for an inlined struct, the emitted schema looks like: ```json { "type": "object", "properties": { "struct_field": { "type": ["string", "null"], "pattern": "^[a-zA-Z0-9_]+$" } } } ``` - When `T` is for an enum or struct that isn't inlined, the emitted schema looks like: ```json { "type": "object", "properties": { "non_inlined_field": { "anyOf": [ { "$ref": "#/$defs/NonInlinedType" }, { "type": "null" } ] } }, "$defs": { "NonInlinedType": { "type": "object", "properties": { "inner_field": { "type": "string" } } } } } ``` All of these representations are non-idiomatic. In JSON Schema, explicitly defining a field as `null` is **_not_** equivalent to not specifying the field. We should only permit specifying a field as `null` when this is semantically accurate, not as shorthand for "not defined." We control whether a property is mandatory in the schema with other keywords, like `required` and `dependentRequired`. This change: - Adds the `idiomaticize_option_field` transform to address this limitation of schemars for a single field or variant. - Adds the `idiomaticize_optional_properties` transform to address this limitation of schemars at the top level of a struct, applying the `idiomaticize_option_field` transform to every optional field in the struct. - Adds new helpers to `SchemaUtilityExtensions` for ease of use in the implementation of the new transforms: - `get_defined_keywords` to return every keyword defined at the top level of the schema. - `get_properties_keys` to return the keys of the `properties` object at the top level of the schema. - `get_required_property_names` to return the names of the properties defined in the `required` keyword at the top level of the schema. - Includes documentation and integration testing --- lib/dsc-lib-jsonschema/locales/en-us.toml | 7 + .../src/schema_utility_extensions.rs | 104 ++++++++ .../transforms/idiomaticize_option_field.rs | 153 +++++++++++ .../idiomaticize_optional_properties.rs | 83 ++++++ lib/dsc-lib-jsonschema/src/transforms/mod.rs | 4 + .../transforms/idiomaticize_option_field.rs | 243 ++++++++++++++++++ .../idiomaticize_optional_properties.rs | 179 +++++++++++++ .../tests/integration/transforms/mod.rs | 2 + 8 files changed, 775 insertions(+) create mode 100644 lib/dsc-lib-jsonschema/src/transforms/idiomaticize_option_field.rs create mode 100644 lib/dsc-lib-jsonschema/src/transforms/idiomaticize_optional_properties.rs create mode 100644 lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticize_option_field.rs create mode 100644 lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticize_optional_properties.rs diff --git a/lib/dsc-lib-jsonschema/locales/en-us.toml b/lib/dsc-lib-jsonschema/locales/en-us.toml index 1c28d2d4b..e92b2034d 100644 --- a/lib/dsc-lib-jsonschema/locales/en-us.toml +++ b/lib/dsc-lib-jsonschema/locales/en-us.toml @@ -62,6 +62,13 @@ invalid item: %{invalid_item} transforming schema: %{transforming_schema} """ +[transforms.idiomaticize_option_field] +applies_to = "invalid application of idiomaticize_option_field; expected an optional field with `anyOf` keyword in transforming schema: %{transforming_schema}" +anyOf_array = "invalid application of idiomaticize_option_field; 'anyOf' isn't an array in transforming schema: %{transforming_schema}" +anyOf_length_mismatch = "invalid application of idiomaticize_option_field; expected 'anyOf' to contain 2 items but had %{actual_length} items in transforming schema: %{transforming_schema}" +null_schema_missing = "invalid application of idiomaticize_option_field; expected one of the 'anyOf' items to be `{\"type\": \"null\"}` in transforming schema: %{transforming_schema}" +actual_schema_missing = "invalid application of idiomaticize_option_field; expected one of the 'anyOf' items to define the actual field schema in transforming schema: %{transforming_schema}" + [transforms.idiomaticize_string_enum] applies_to = "invalid application of idiomaticize_string_enum; missing 'oneOf' keyword in transforming schema: %{transforming_schema}" oneOf_array = "invalid application of idiomaticize_string_enum; 'oneOf' isn't an array in transforming schema: %{transforming_schema}" diff --git a/lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs b/lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs index 57fd02040..678927623 100644 --- a/lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs +++ b/lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs @@ -20,6 +20,37 @@ use url::{Position, Url}; /// Provides utility extension methods for [`schemars::Schema`]. pub trait SchemaUtilityExtensions { + /// Returns a vector of every keyword defined at the top level of a schema. + /// + /// # Returns + /// + /// A vector containing every keyword defined at the top level of the + /// schema. If the schema is boolean the vector is empty. + /// + /// # Example + /// + /// ``` + /// # use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// # use schemars::json_schema; + /// let schema = json_schema!({ + /// "type": "object", + /// "properties": { + /// "foo": { "type": "string" }, + /// "bar": { "type": "number" }, + /// }, + /// "required": ["foo"], + /// }); + /// + /// assert_eq!( + /// schema.get_defined_keywords(), + /// vec![ + /// "type".to_string(), + /// "properties".to_string(), + /// "required".to_string() + /// ] + /// ); + /// ``` + fn get_defined_keywords(&self) -> Vec; //********************** get_keyword_as_* functions **********************// /// Checks a JSON Schema for a given keyword and returns a reference to the value of that /// keyword, if it exists, as a [`Vec`]. @@ -1470,6 +1501,59 @@ pub trait SchemaUtilityExtensions { /// ); /// ``` fn get_property_subschema_mut(&mut self, property_name: &str) -> Option<&mut Schema>; + /// Returns the name of every property defined in the `properties` keyword. + /// + /// # Returns + /// + /// A vector containing every property name defined in the `properties` keyword. If the keyword + /// isn't defined, the vector is empty. + /// + /// # Example + /// + /// ```rust + /// # use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// # use schemars::json_schema; + /// let schema = json_schema!({ + /// "type": "object", + /// "properties": { + /// "foo": { "type": "string" }, + /// "bar": { "type": "number" }, + /// }, + /// }); + /// + /// assert_eq!( + /// schema.get_properties_keys(), + /// vec!["foo".to_string(), "bar".to_string()] + /// ); + /// ``` + fn get_properties_keys(&self) -> Vec; + /// Returns a vector containing every property name in the `required` keyword. + /// + /// # Returns + /// + /// A vector containing every property name in the `required` keyword. If the keyword isn't + /// defined, the vector is empty. + /// + /// # Example + /// + /// ```rust + /// # use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + /// # use schemars::json_schema; + /// let schema = json_schema!({ + /// "type": "object", + /// "required": ["foo"], + /// "properties": { + /// "foo": { "type": "string" }, + /// "bar": { "type": "number" }, + /// } + /// }); + /// + /// assert_eq!( + /// schema.get_required_property_names(), + /// vec!["foo".to_string()] + /// ); + /// ``` + fn get_required_property_names(&self) -> Vec; //************************ $ref keyword functions ************************// /// Retrieves the value for every `$ref` keyword from the [`Schema`] as a [`HashSet`] of @@ -1788,6 +1872,11 @@ pub trait SchemaUtilityExtensions { } impl SchemaUtilityExtensions for Schema { + fn get_defined_keywords(&self) -> Vec { + self.as_object() + .map(|obj| obj.keys().cloned().collect::>()) + .unwrap_or_else(Vec::new) + } fn get_keyword_as_array(&self, key: &str) -> Option<&Vec> { self.get(key) .and_then(Value::as_array) @@ -2083,6 +2172,21 @@ impl SchemaUtilityExtensions for Schema { .and_then(|properties| properties.get_mut(property_name)) .and_then(|v| <&mut Value as TryInto<&mut Schema>>::try_into(v).ok()) } + fn get_properties_keys(&self) -> Vec { + self.get_properties() + .map(|obj| obj.keys().cloned().collect::>()) + .unwrap_or_else(Vec::new) + } + fn get_required_property_names(&self) -> Vec { + self.get_keyword_as_array("required") + .map(|arr| { + arr.iter() + .filter_map(Value::as_str) + .map(String::from) + .collect::>() + }) + .unwrap_or_else(Vec::new) + } fn get_references(&self) -> HashSet<&str> { let mut references: HashSet<&str> = HashSet::new(); // First, check the top-level for a reference diff --git a/lib/dsc-lib-jsonschema/src/transforms/idiomaticize_option_field.rs b/lib/dsc-lib-jsonschema/src/transforms/idiomaticize_option_field.rs new file mode 100644 index 000000000..c4f343bb2 --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/transforms/idiomaticize_option_field.rs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use schemars::Schema; +use serde_json::json; + +use crate::schema_utility_extensions::SchemaUtilityExtensions; + +/// Transforms the default generated schema for optional fields into a more idiomatic representation. +/// +/// # Example +/// +/// ```rust +/// use schemars::json_schema; +/// use dsc_lib_jsonschema::transforms::idiomaticize_option_field; +/// +/// let mut schema = json_schema!({ +/// "title": "Example", +/// "description": "Optional string", +/// "anyOf": [ +/// { "type": "null" }, +/// { +/// "type": "string", +/// "pattern": "^\\w+$", +/// "title": "Foo" +/// } +/// ] +/// }); +/// +/// idiomaticize_option_field(&mut schema); +/// +/// let expected = json_schema!({ +/// "title": "Example", +/// "description": "Optional string", +/// "type": "string", +/// "pattern": "^\\w+$" +/// }); +/// +/// assert_eq!(schema, expected); +/// ``` +/// +/// ``` +/// use schemars::json_schema; +/// use dsc_lib_jsonschema::transforms::idiomaticize_option_field; +/// +/// let mut schema = json_schema!({ +/// "title": "Example", +/// "description": "Optional string", +/// "type": ["null", "string"], +/// "pattern": "^\\w+$" +/// }); +/// +/// idiomaticize_option_field(&mut schema); +/// +/// let expected = json_schema!({ +/// "title": "Example", +/// "description": "Optional string", +/// "type": "string", +/// "pattern": "^\\w+$" +/// }); +/// +/// assert_eq!(schema, expected); +/// ``` +pub fn idiomaticize_option_field(schema: &mut Schema) { + // Workaround for inability to borrow both mutably and immutably. + let lookup_schema = schema.clone(); + let mut munged_schema = false; + + // First, handle the case where the schema defines `type` with two values, one of which is + // `"null"`. This is emitted by schemars for `Option` fields where `T` is a type that + // schemars implemented `JsonSchema` for, like `String` or `i32`. + if let Some(types) = lookup_schema.get_keyword_as_array("type") { + if types.len() == 2 && types.contains(&json!("null")) { + let actual_type = types.iter().find(|t| t != &&serde_json::json!("null")); + schema.insert("type".to_string(), actual_type.unwrap().clone()); + + munged_schema = true; + } + } + + // Handle `null` in `enum` keyword - remove if needed. + if let Some(enum_values) = lookup_schema.get_keyword_as_array("enum") { + if enum_values.contains(&json!(null)) { + let mut new_enum_values = enum_values.clone(); + new_enum_values.retain(|v| v != &json!(null)); + schema.insert("enum".to_string(), json!(new_enum_values)); + + munged_schema = true; + } + } + + // If we munged the schema for type/enum, return early. The remaining code handles cases where + // schemars inserted an `anyOf` keyword for referencing the underlying type schema. + if munged_schema { + return; + } + + // Next, handle the case where the schema uses `anyOf` to represent an optional field. + // This is emitted by schemars for `Option` fields where `T` is a type that implements + // `JsonSchema`. In this case, `anyOf` defines exactly two subschemas, one of which only + // specifies `type` as `"null"`. Usually, the other subschema only includes a reference to + // the underlying type schema (`$ref` keyword) unless that schema is inlined. + let any_ofs = lookup_schema.get("anyOf") + .unwrap_or_else(|| panic_t!( + "transforms.idiomaticize_option_field.applies_to", + transforming_schema = serde_json::to_string_pretty(schema).unwrap() + )) + .as_array() + .unwrap_or_else(|| panic_t!( + "transforms.idiomaticize_option_field.anyOf_array", + transforming_schema = serde_json::to_string_pretty(schema).unwrap() + )); + + if any_ofs.len() != 2 { + panic_t!( + "transforms.idiomaticize_option_field.anyOf_length_mismatch", + actual_length = any_ofs.len(), + transforming_schema = serde_json::to_string_pretty(schema).unwrap() + ); + } + + let null_schema = any_ofs + .iter() + .find(|s| s.get("type").map(|t| t == "null").unwrap_or(false)); + if null_schema.is_none() { + panic_t!( + "transforms.idiomaticize_option_field.null_schema_missing", + transforming_schema = serde_json::to_string_pretty(schema).unwrap() + ); + } + let actual_schema = any_ofs + .iter() + .find(|s| s.get("type").map(|t| t != "null").unwrap_or(true)); + if actual_schema.is_none() { + panic_t!( + "transforms.idiomaticize_option_field.actual_schema_missing", + transforming_schema = serde_json::to_string_pretty(schema).unwrap() + ); + } + + // At this point, we've verified that the target schema supports this transform. + let actual_schema: &Schema = actual_schema.unwrap().try_into().unwrap(); + let munging_schema_keys = schema.get_defined_keywords(); + let actual_schema_keys = actual_schema.get_defined_keywords(); + + for key in actual_schema_keys { + if !munging_schema_keys.contains(&key) { + schema.insert(key.clone(), actual_schema.get(&key).unwrap().clone()); + } + } + + schema.remove("anyOf"); +} diff --git a/lib/dsc-lib-jsonschema/src/transforms/idiomaticize_optional_properties.rs b/lib/dsc-lib-jsonschema/src/transforms/idiomaticize_optional_properties.rs new file mode 100644 index 000000000..1384447e4 --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/transforms/idiomaticize_optional_properties.rs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use schemars::Schema; + +use crate::transforms::idiomaticize_option_field; +use crate::schema_utility_extensions::SchemaUtilityExtensions; + +/// Transforms all optional properties in the given JSON Schema to use the idiomatic `Option` type. +/// +/// This function iterates over all properties in the schema and applies the +/// [`idiomaticize_option_field`] transform to those that aren't in the `required` keyword array. +/// +/// # Example +/// +/// ```rust +/// use schemars::json_schema; +/// use dsc_lib_jsonschema::transforms::idiomaticize_optional_properties; +/// +/// let mut schema = json_schema!({ +/// "title": "Example struct", +/// "type": "object", +/// "required": ["baz"], +/// "properties": { +/// "foo": { +/// "type": ["string", "null"], +/// "pattern": "^\\w+$", +/// }, +/// "bar": { +/// "anyOf": [ +/// { "$ref": "$defs/bar" }, +/// { "type": "null" }, +/// ] +/// }, +/// "baz": { +/// "type": ["string", "null"] +/// } +/// }, +/// "$defs": { +/// "bar": { +/// "type": "boolean" +/// } +/// } +/// }); +/// idiomaticize_optional_properties(&mut schema); +/// +/// let expected = json_schema!({ +/// "title": "Example struct", +/// "type": "object", +/// "required": ["baz"], +/// "properties": { +/// "foo": { +/// "type": "string", +/// "pattern": "^\\w+$", +/// }, +/// "bar": { +/// "$ref": "$defs/bar" +/// }, +/// "baz": { +/// "type": ["string", "null"] +/// } +/// }, +/// "$defs": { +/// "bar": { +/// "type": "boolean" +/// } +/// } +/// }); +/// +/// assert_eq!(schema, expected); +/// ``` +pub fn idiomaticize_optional_properties(schema: &mut Schema) { + let lookup_schema = schema.clone(); + let required_properties = lookup_schema.get_required_property_names(); + for property_name in lookup_schema.get_properties_keys() { + if required_properties.contains(&property_name) { + continue; + } + if let Some(property_schema) = schema.get_property_subschema_mut(&property_name) { + idiomaticize_option_field(property_schema); + } + } +} diff --git a/lib/dsc-lib-jsonschema/src/transforms/mod.rs b/lib/dsc-lib-jsonschema/src/transforms/mod.rs index 98be43c0e..35710f386 100644 --- a/lib/dsc-lib-jsonschema/src/transforms/mod.rs +++ b/lib/dsc-lib-jsonschema/src/transforms/mod.rs @@ -8,6 +8,10 @@ mod canonicalize_refs_and_defs; pub use canonicalize_refs_and_defs::canonicalize_refs_and_defs; +mod idiomaticize_option_field; +pub use idiomaticize_option_field::idiomaticize_option_field; +mod idiomaticize_optional_properties; +pub use idiomaticize_optional_properties::idiomaticize_optional_properties; mod idiomaticize_externally_tagged_enum; pub use idiomaticize_externally_tagged_enum::idiomaticize_externally_tagged_enum; mod idiomaticize_string_enum; diff --git a/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticize_option_field.rs b/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticize_option_field.rs new file mode 100644 index 000000000..3e6b60772 --- /dev/null +++ b/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticize_option_field.rs @@ -0,0 +1,243 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[schemars( + title = "StructField.definition", + extend( + "pattern" = "^\\w+$" + ) +)] +pub struct StructField(String); + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[schemars( + inline, + title = "StructFieldInlined.definition", + extend( + "pattern" = "^\\w+$" + ) +)] +pub struct StructFieldInlined(String); + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all="camelCase")] +#[schemars( + title = "EnumField.definition", +)] +pub enum EnumField { + Foo, + Bar, + Baz +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all="camelCase")] +#[schemars( + inline, + title = "EnumFieldInlined.definition", +)] +pub enum EnumFieldInlined { + Foo, + Bar, + Baz +} + +fn test_field(name: &str, expected: &schemars::Schema) { + let parent_schema = schemars::schema_for!(ContainerType); + let field_schema = parent_schema.get_property_subschema(name) + .expect(&format!("schema should define 'properties.{name}' as subschema")); + + pretty_assertions::assert_eq!( + serde_json::to_string_pretty(field_schema).unwrap(), + serde_json::to_string_pretty(expected).unwrap() + ); +} + +#[cfg(test)] mod without_transform { + use schemars::{json_schema, schema_for}; + use serde_json::json; + + use super::*; + + #[derive(Debug, Serialize, Deserialize, JsonSchema)] + pub struct Example { + pub struct_field: Option, + pub struct_field_inlined: Option, + pub enum_field: Option, + pub enum_field_inlined: Option, + pub primitive_field: Option, + } + + #[test] fn field_defined_as_option_wrapping_struct() { + test_field::("struct_field", &json_schema!({ + "anyOf": [ + { "$ref": "#/$defs/StructField"}, + { "type": "null" } + ] + })); + } + #[test] fn field_defined_as_option_wrapping_struct_inlined() { + let ref mut expected = schema_for!(StructFieldInlined); + // Schemars inserts the null type + expected.insert("type".to_string(), json!(["string", "null"])); + // Inlined schemas drop the `$schema` keyword + expected.remove("$schema"); + test_field::("struct_field_inlined", expected); + } + #[test] fn field_defined_as_option_wrapping_enum() { + test_field::("enum_field", &json_schema!({ + "anyOf": [ + { "$ref": "#/$defs/EnumField"}, + { "type": "null" } + ] + })); + } + #[test] fn field_defined_as_option_wrapping_enum_inlined() { + let ref mut expected = schema_for!(EnumFieldInlined); + // Schemars adds null type automatically + expected.insert("type".to_string(), json!(["string", "null"])); + // Schemars adds `null` as valid enum value + expected.get_keyword_as_array_mut("enum").map(|v| v.push(json!(null))); + // Inlined schemas drop the `$schema` keyword + expected.remove("$schema"); + + test_field::("enum_field_inlined", expected); + } + #[test] fn field_defined_as_option_wrapping_primitive() { + test_field::("primitive_field", &json_schema!({ + "type": ["string", "null"] + })); + } +} + +#[cfg(test)] mod with_transform { + use super::*; + use dsc_lib_jsonschema::transforms::idiomaticize_option_field; + + #[cfg(test)] mod without_field_keywords { + use schemars::{json_schema, schema_for}; + + use super::*; + + #[derive(Debug, Serialize, Deserialize, JsonSchema)] + pub struct Example { + #[schemars(transform = idiomaticize_option_field)] + pub struct_field: Option, + #[schemars(transform = idiomaticize_option_field)] + pub struct_field_inlined: Option, + #[schemars(transform = idiomaticize_option_field)] + pub enum_field: Option, + #[schemars(transform = idiomaticize_option_field)] + pub enum_field_inlined: Option, + #[schemars(transform = idiomaticize_option_field)] + pub primitive_field: Option, + } + + #[test] fn field_defined_as_option_wrapping_struct() { + test_field::("struct_field", &json_schema!({ + "$ref": "#/$defs/StructField" + })); + } + #[test] fn field_defined_as_option_wrapping_struct_inlined() { + let ref mut expected = schema_for!(StructFieldInlined); + // Inlined schemas drop the `$schema` keyword + expected.remove("$schema"); + test_field::("struct_field_inlined", expected); + } + #[test] fn field_defined_as_option_wrapping_enum() { + test_field::("enum_field", &json_schema!({ + "$ref": "#/$defs/EnumField" + })); + } + #[test] fn field_defined_as_option_wrapping_enum_inlined() { + let ref mut expected = schema_for!(EnumFieldInlined); + // Inlined schemas drop the `$schema` keyword + expected.remove("$schema"); + + test_field::("enum_field_inlined", expected); + } + #[test] fn field_defined_as_option_wrapping_primitive() { + test_field::("primitive_field", &json_schema!({ + "type": "string" + })); + } + } + + #[cfg(test)] mod with_field_keywords { + use schemars::{json_schema, schema_for}; + use serde_json::json; + + use super::*; + + #[derive(Debug, Serialize, Deserialize, JsonSchema)] + pub struct Example { + #[schemars( + title = "struct_field.field", + transform = idiomaticize_option_field + )] + pub struct_field: Option, + #[schemars( + title = "struct_field_inlined.field", + transform = idiomaticize_option_field + )] + pub struct_field_inlined: Option, + #[schemars( + title = "enum_field.field", + transform = idiomaticize_option_field + )] + pub enum_field: Option, + #[schemars( + title = "enum_field_inlined.field", + transform = idiomaticize_option_field + )] + pub enum_field_inlined: Option, + #[schemars( + title = "primitive_field.field", + transform = idiomaticize_option_field + )] + pub primitive_field: Option, + } + + #[test] fn field_defined_as_option_wrapping_struct() { + test_field::("struct_field", &json_schema!({ + "$ref": "#/$defs/StructField", + "title": "struct_field.field" + })); + } + #[test] fn field_defined_as_option_wrapping_struct_inlined() { + let ref mut expected = schema_for!(StructFieldInlined); + // Inlined schemas drop the `$schema` keyword + expected.remove("$schema"); + // Should have the attributed `title` keyword + expected.insert("title".to_string(), json!("struct_field_inlined.field")); + test_field::("struct_field_inlined", expected); + } + #[test] fn field_defined_as_option_wrapping_enum() { + test_field::("enum_field", &json_schema!({ + "$ref": "#/$defs/EnumField", + "title": "enum_field.field" + })); + } + #[test] fn field_defined_as_option_wrapping_enum_inlined() { + let ref mut expected = schema_for!(EnumFieldInlined); + // Inlined schemas drop the `$schema` keyword + expected.remove("$schema"); + // Should have the attributed `title` keyword + expected.insert("title".to_string(), json!("enum_field_inlined.field")); + + test_field::("enum_field_inlined", expected); + } + #[test] fn field_defined_as_option_wrapping_primitive() { + test_field::("primitive_field", &json_schema!({ + "type": "string", + "title": "primitive_field.field" + })); + } + } + +} \ No newline at end of file diff --git a/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticize_optional_properties.rs b/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticize_optional_properties.rs new file mode 100644 index 000000000..8c8042fae --- /dev/null +++ b/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticize_optional_properties.rs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[schemars( + title = "StructField.definition", + extend( + "pattern" = "^\\w+$" + ) +)] +pub struct StructField(String); + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[schemars( + inline, + title = "StructFieldInlined.definition", + extend( + "pattern" = "^\\w+$" + ) +)] +pub struct StructFieldInlined(String); + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all="camelCase")] +#[schemars( + title = "EnumField.definition", +)] +pub enum EnumField { + Foo, + Bar, + Baz +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all="camelCase")] +#[schemars( + inline, + title = "EnumFieldInlined.definition", +)] +pub enum EnumFieldInlined { + Foo, + Bar, + Baz +} + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct Example { + #[schemars(title = "struct_field.field")] + pub struct_field: Option, + #[schemars(title = "struct_field_inlined.field")] + pub struct_field_inlined: Option, + #[schemars(title = "enum_field.field")] + pub enum_field: Option, + #[schemars(title = "enum_field_inlined.field")] + pub enum_field_inlined: Option, + #[schemars(title = "primitive_field.field")] + pub primitive_field: Option, +} + +#[cfg(test)] mod without_transform { + use schemars::json_schema; + + use super::*; + + fn test_field(name: &str, expected: &schemars::Schema) { + let parent_schema = schemars::schema_for!(Example); + let field_schema = parent_schema.get_property_subschema(name) + .expect(&format!("schema should define 'properties.{name}' as subschema")); + + pretty_assertions::assert_eq!( + serde_json::to_string_pretty(field_schema).unwrap(), + serde_json::to_string_pretty(expected).unwrap() + ); + } + + #[test] fn field_defined_as_option_wrapping_struct() { + test_field("struct_field", &json_schema!({ + "title": "struct_field.field", + "anyOf": [ + { "$ref": "#/$defs/StructField" }, + { "type": "null" }, + ] + })); + } + + #[test] fn field_defined_as_option_wrapping_struct_inlined() { + test_field("struct_field_inlined", &json_schema!({ + "title": "struct_field_inlined.field", + "type": ["string", "null"], + "pattern": "^\\w+$" + })); + } + + #[test] fn field_defined_as_option_wrapping_enum() { + test_field("enum_field", &json_schema!({ + "title": "enum_field.field", + "anyOf": [ + { "$ref": "#/$defs/EnumField" }, + { "type": "null" }, + ] + })); + } + + #[test] fn field_defined_as_option_wrapping_enum_inlined() { + test_field("enum_field_inlined", &json_schema!({ + "title": "enum_field_inlined.field", + "type": ["string", "null"], + "enum": ["foo", "bar", "baz", null] + })); + } + + #[test] fn field_defined_as_option_wrapping_primitive() { + test_field("primitive_field", &json_schema!({ + "title": "primitive_field.field", + "type": ["string", "null"], + })); + } +} + +#[cfg(test)] mod with_transform { + use dsc_lib_jsonschema::transforms::idiomaticize_optional_properties; + use schemars::json_schema; + + use super::*; + + fn test_field(name: &str, expected: &schemars::Schema) { + let mut parent_schema = schemars::schema_for!(Example); + idiomaticize_optional_properties(&mut parent_schema); + + let field_schema = parent_schema.get_property_subschema(name) + .expect(&format!("schema should define 'properties.{name}' as subschema")); + + pretty_assertions::assert_eq!( + serde_json::to_string_pretty(field_schema).unwrap(), + serde_json::to_string_pretty(expected).unwrap() + ); + } + + #[test] fn field_defined_as_option_wrapping_struct() { + test_field("struct_field", &json_schema!({ + "title": "struct_field.field", + "$ref": "#/$defs/StructField" + })); + } + + #[test] fn field_defined_as_option_wrapping_struct_inlined() { + test_field("struct_field_inlined", &json_schema!({ + "title": "struct_field_inlined.field", + "type": "string", + "pattern": "^\\w+$" + })); + } + + #[test] fn field_defined_as_option_wrapping_enum() { + test_field("enum_field", &json_schema!({ + "title": "enum_field.field", + "$ref": "#/$defs/EnumField" + })); + } + + #[test] fn field_defined_as_option_wrapping_enum_inlined() { + test_field("enum_field_inlined", &json_schema!({ + "title": "enum_field_inlined.field", + "type": "string", + "enum": ["foo", "bar", "baz"] + })); + } + + #[test] fn field_defined_as_option_wrapping_primitive() { + test_field("primitive_field", &json_schema!({ + "title": "primitive_field.field", + "type": "string", + })); + } +} \ No newline at end of file diff --git a/lib/dsc-lib-jsonschema/tests/integration/transforms/mod.rs b/lib/dsc-lib-jsonschema/tests/integration/transforms/mod.rs index ca4c106f8..ec368612c 100644 --- a/lib/dsc-lib-jsonschema/tests/integration/transforms/mod.rs +++ b/lib/dsc-lib-jsonschema/tests/integration/transforms/mod.rs @@ -7,5 +7,7 @@ #[cfg(test)] mod canonicalize_refs_and_defs; #[cfg(test)] mod idiomaticize_externally_tagged_enum; +#[cfg(test)] mod idiomaticize_option_field; +#[cfg(test)] mod idiomaticize_optional_properties; #[cfg(test)] mod idiomaticize_string_enum; #[cfg(test)] mod remove_bundled_schema_resources; From f0409e2e423857b9a791eab43a3e568e0658201c Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Tue, 1 Sep 2026 20:26:38 -0500 Subject: [PATCH 2/3] Fix clippy violations --- .../src/schema_utility_extensions.rs | 9 +++---- .../transforms/idiomaticize_option_field.rs | 25 +++++++++++++------ 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs b/lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs index 678927623..1bcb1dcc4 100644 --- a/lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs +++ b/lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs @@ -1874,8 +1874,7 @@ pub trait SchemaUtilityExtensions { impl SchemaUtilityExtensions for Schema { fn get_defined_keywords(&self) -> Vec { self.as_object() - .map(|obj| obj.keys().cloned().collect::>()) - .unwrap_or_else(Vec::new) + .map_or_else(Vec::new, |obj| obj.keys().cloned().collect::>()) } fn get_keyword_as_array(&self, key: &str) -> Option<&Vec> { self.get(key) @@ -2174,18 +2173,16 @@ impl SchemaUtilityExtensions for Schema { } fn get_properties_keys(&self) -> Vec { self.get_properties() - .map(|obj| obj.keys().cloned().collect::>()) - .unwrap_or_else(Vec::new) + .map_or_else(Vec::new, |obj| obj.keys().cloned().collect::>()) } fn get_required_property_names(&self) -> Vec { self.get_keyword_as_array("required") - .map(|arr| { + .map_or_else(Vec::new, |arr| { arr.iter() .filter_map(Value::as_str) .map(String::from) .collect::>() }) - .unwrap_or_else(Vec::new) } fn get_references(&self) -> HashSet<&str> { let mut references: HashSet<&str> = HashSet::new(); diff --git a/lib/dsc-lib-jsonschema/src/transforms/idiomaticize_option_field.rs b/lib/dsc-lib-jsonschema/src/transforms/idiomaticize_option_field.rs index c4f343bb2..10d675bf6 100644 --- a/lib/dsc-lib-jsonschema/src/transforms/idiomaticize_option_field.rs +++ b/lib/dsc-lib-jsonschema/src/transforms/idiomaticize_option_field.rs @@ -8,6 +8,17 @@ use crate::schema_utility_extensions::SchemaUtilityExtensions; /// Transforms the default generated schema for optional fields into a more idiomatic representation. /// +/// This transform only applies to `struct` types that define one or more fields as [`Option`]. +/// It iterates over every field in the `properties` keyword, skipping any fields that are defined +/// in the `required` keyword. +/// +/// # Panics +/// +/// This transform panics if any apparently optional field doesn't define either: +/// +/// - `type` as an array where one value is `"null"` +/// - `anyOf` with exactly two subschemas, one of which is just `{ "type": "null" }` +/// /// # Example /// /// ```rust @@ -69,24 +80,22 @@ pub fn idiomaticize_option_field(schema: &mut Schema) { // First, handle the case where the schema defines `type` with two values, one of which is // `"null"`. This is emitted by schemars for `Option` fields where `T` is a type that // schemars implemented `JsonSchema` for, like `String` or `i32`. - if let Some(types) = lookup_schema.get_keyword_as_array("type") { - if types.len() == 2 && types.contains(&json!("null")) { + if let Some(types) = lookup_schema.get_keyword_as_array("type") + && types.len() == 2 && types.contains(&json!("null")) { let actual_type = types.iter().find(|t| t != &&serde_json::json!("null")); schema.insert("type".to_string(), actual_type.unwrap().clone()); munged_schema = true; - } } // Handle `null` in `enum` keyword - remove if needed. - if let Some(enum_values) = lookup_schema.get_keyword_as_array("enum") { - if enum_values.contains(&json!(null)) { + if let Some(enum_values) = lookup_schema.get_keyword_as_array("enum") + && enum_values.contains(&json!(null)) { let mut new_enum_values = enum_values.clone(); new_enum_values.retain(|v| v != &json!(null)); schema.insert("enum".to_string(), json!(new_enum_values)); munged_schema = true; - } } // If we munged the schema for type/enum, return early. The remaining code handles cases where @@ -121,7 +130,7 @@ pub fn idiomaticize_option_field(schema: &mut Schema) { let null_schema = any_ofs .iter() - .find(|s| s.get("type").map(|t| t == "null").unwrap_or(false)); + .find(|s| s.get("type").is_some_and(|t| t == "null")); if null_schema.is_none() { panic_t!( "transforms.idiomaticize_option_field.null_schema_missing", @@ -130,7 +139,7 @@ pub fn idiomaticize_option_field(schema: &mut Schema) { } let actual_schema = any_ofs .iter() - .find(|s| s.get("type").map(|t| t != "null").unwrap_or(true)); + .find(|s| s.get("type").is_none_or(|t| t != "null")); if actual_schema.is_none() { panic_t!( "transforms.idiomaticize_option_field.actual_schema_missing", From bd9f19f0b9a2b02680c1227c13f32f2052841e9b Mon Sep 17 00:00:00 2001 From: "Mikey Lombardi (He/Him)" Date: Thu, 3 Sep 2026 08:04:29 -0500 Subject: [PATCH 3/3] Fix documentation for idiomaticize_option_field transform Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/transforms/idiomaticize_option_field.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/dsc-lib-jsonschema/src/transforms/idiomaticize_option_field.rs b/lib/dsc-lib-jsonschema/src/transforms/idiomaticize_option_field.rs index 10d675bf6..960d22c88 100644 --- a/lib/dsc-lib-jsonschema/src/transforms/idiomaticize_option_field.rs +++ b/lib/dsc-lib-jsonschema/src/transforms/idiomaticize_option_field.rs @@ -8,9 +8,9 @@ use crate::schema_utility_extensions::SchemaUtilityExtensions; /// Transforms the default generated schema for optional fields into a more idiomatic representation. /// -/// This transform only applies to `struct` types that define one or more fields as [`Option`]. -/// It iterates over every field in the `properties` keyword, skipping any fields that are defined -/// in the `required` keyword. +/// This transform is intended to be applied to the schema for a single `Option` field. +/// It removes the `null` branch that schemars adds (via `type: ["…", "null"]`, `enum: […, null]`, or `anyOf`). +/// The field’s optionality should instead be expressed by omitting the property name from `required`. /// /// # Panics ///