Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lib/dsc-lib-jsonschema/locales/en-us.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
101 changes: 101 additions & 0 deletions lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>;
//********************** 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`].
Expand Down Expand Up @@ -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<String>;
/// 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<String>;

//************************ $ref keyword functions ************************//
/// Retrieves the value for every `$ref` keyword from the [`Schema`] as a [`HashSet`] of
Expand Down Expand Up @@ -1788,6 +1872,10 @@ pub trait SchemaUtilityExtensions {
}

impl SchemaUtilityExtensions for Schema {
fn get_defined_keywords(&self) -> Vec<String> {
self.as_object()
.map_or_else(Vec::new, |obj| obj.keys().cloned().collect::<Vec<String>>())
}
fn get_keyword_as_array(&self, key: &str) -> Option<&Vec<Value>> {
self.get(key)
.and_then(Value::as_array)
Expand Down Expand Up @@ -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<String> {
self.get_properties()
.map_or_else(Vec::new, |obj| obj.keys().cloned().collect::<Vec<String>>())
}
fn get_required_property_names(&self) -> Vec<String> {
self.get_keyword_as_array("required")
.map_or_else(Vec::new, |arr| {
arr.iter()
.filter_map(Value::as_str)
.map(String::from)
.collect::<Vec<String>>()
})
}
fn get_references(&self) -> HashSet<&str> {
let mut references: HashSet<&str> = HashSet::new();
// First, check the top-level for a reference
Expand Down
162 changes: 162 additions & 0 deletions lib/dsc-lib-jsonschema/src/transforms/idiomaticize_option_field.rs
Original file line number Diff line number Diff line change
@@ -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<T>` 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<T>` 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<T>` 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");
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading
Loading