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..1bcb1dcc4 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,10 @@ pub trait SchemaUtilityExtensions { } impl SchemaUtilityExtensions for Schema { + fn get_defined_keywords(&self) -> Vec { + self.as_object() + .map_or_else(Vec::new, |obj| obj.keys().cloned().collect::>()) + } fn get_keyword_as_array(&self, key: &str) -> Option<&Vec> { self.get(key) .and_then(Value::as_array) @@ -2083,6 +2171,19 @@ 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_or_else(Vec::new, |obj| obj.keys().cloned().collect::>()) + } + fn get_required_property_names(&self) -> Vec { + self.get_keyword_as_array("required") + .map_or_else(Vec::new, |arr| { + arr.iter() + .filter_map(Value::as_str) + .map(String::from) + .collect::>() + }) + } 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..960d22c88 --- /dev/null +++ b/lib/dsc-lib-jsonschema/src/transforms/idiomaticize_option_field.rs @@ -0,0 +1,162 @@ +// 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. +/// +/// 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 +/// +/// 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 +/// 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") + && 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") + && 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").is_some_and(|t| t == "null")); + 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").is_none_or(|t| t != "null")); + 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;