Skip to content

Commit fc48c5c

Browse files
(GH-538) Define transforms for Option<T> (#1702)
* (GH-538) Define transforms for `Option<T>` Prior to this change we had no (relatively) convenient way to make the emitted schemas for structs with `Option<T>` fields more idiomatic. As of the `v1.0.0` release of schemars, the schema generator _always_ wraps handling for `Option<T>` 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 * Fix clippy violations * Fix documentation for idiomaticize_option_field transform Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 45b1007 commit fc48c5c

8 files changed

Lines changed: 781 additions & 0 deletions

File tree

lib/dsc-lib-jsonschema/locales/en-us.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ invalid item: %{invalid_item}
6262
transforming schema: %{transforming_schema}
6363
"""
6464

65+
[transforms.idiomaticize_option_field]
66+
applies_to = "invalid application of idiomaticize_option_field; expected an optional field with `anyOf` keyword in transforming schema: %{transforming_schema}"
67+
anyOf_array = "invalid application of idiomaticize_option_field; 'anyOf' isn't an array in transforming schema: %{transforming_schema}"
68+
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}"
69+
null_schema_missing = "invalid application of idiomaticize_option_field; expected one of the 'anyOf' items to be `{\"type\": \"null\"}` in transforming schema: %{transforming_schema}"
70+
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}"
71+
6572
[transforms.idiomaticize_string_enum]
6673
applies_to = "invalid application of idiomaticize_string_enum; missing 'oneOf' keyword in transforming schema: %{transforming_schema}"
6774
oneOf_array = "invalid application of idiomaticize_string_enum; 'oneOf' isn't an array in transforming schema: %{transforming_schema}"

lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,37 @@ use url::{Position, Url};
2020

2121
/// Provides utility extension methods for [`schemars::Schema`].
2222
pub trait SchemaUtilityExtensions {
23+
/// Returns a vector of every keyword defined at the top level of a schema.
24+
///
25+
/// # Returns
26+
///
27+
/// A vector containing every keyword defined at the top level of the
28+
/// schema. If the schema is boolean the vector is empty.
29+
///
30+
/// # Example
31+
///
32+
/// ```
33+
/// # use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions;
34+
/// # use schemars::json_schema;
35+
/// let schema = json_schema!({
36+
/// "type": "object",
37+
/// "properties": {
38+
/// "foo": { "type": "string" },
39+
/// "bar": { "type": "number" },
40+
/// },
41+
/// "required": ["foo"],
42+
/// });
43+
///
44+
/// assert_eq!(
45+
/// schema.get_defined_keywords(),
46+
/// vec![
47+
/// "type".to_string(),
48+
/// "properties".to_string(),
49+
/// "required".to_string()
50+
/// ]
51+
/// );
52+
/// ```
53+
fn get_defined_keywords(&self) -> Vec<String>;
2354
//********************** get_keyword_as_* functions **********************//
2455
/// Checks a JSON Schema for a given keyword and returns a reference to the value of that
2556
/// keyword, if it exists, as a [`Vec`].
@@ -1470,6 +1501,59 @@ pub trait SchemaUtilityExtensions {
14701501
/// );
14711502
/// ```
14721503
fn get_property_subschema_mut(&mut self, property_name: &str) -> Option<&mut Schema>;
1504+
/// Returns the name of every property defined in the `properties` keyword.
1505+
///
1506+
/// # Returns
1507+
///
1508+
/// A vector containing every property name defined in the `properties` keyword. If the keyword
1509+
/// isn't defined, the vector is empty.
1510+
///
1511+
/// # Example
1512+
///
1513+
/// ```rust
1514+
/// # use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions;
1515+
/// # use schemars::json_schema;
1516+
/// let schema = json_schema!({
1517+
/// "type": "object",
1518+
/// "properties": {
1519+
/// "foo": { "type": "string" },
1520+
/// "bar": { "type": "number" },
1521+
/// },
1522+
/// });
1523+
///
1524+
/// assert_eq!(
1525+
/// schema.get_properties_keys(),
1526+
/// vec!["foo".to_string(), "bar".to_string()]
1527+
/// );
1528+
/// ```
1529+
fn get_properties_keys(&self) -> Vec<String>;
1530+
/// Returns a vector containing every property name in the `required` keyword.
1531+
///
1532+
/// # Returns
1533+
///
1534+
/// A vector containing every property name in the `required` keyword. If the keyword isn't
1535+
/// defined, the vector is empty.
1536+
///
1537+
/// # Example
1538+
///
1539+
/// ```rust
1540+
/// # use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions;
1541+
/// # use schemars::json_schema;
1542+
/// let schema = json_schema!({
1543+
/// "type": "object",
1544+
/// "required": ["foo"],
1545+
/// "properties": {
1546+
/// "foo": { "type": "string" },
1547+
/// "bar": { "type": "number" },
1548+
/// }
1549+
/// });
1550+
///
1551+
/// assert_eq!(
1552+
/// schema.get_required_property_names(),
1553+
/// vec!["foo".to_string()]
1554+
/// );
1555+
/// ```
1556+
fn get_required_property_names(&self) -> Vec<String>;
14731557

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

17901874
impl SchemaUtilityExtensions for Schema {
1875+
fn get_defined_keywords(&self) -> Vec<String> {
1876+
self.as_object()
1877+
.map_or_else(Vec::new, |obj| obj.keys().cloned().collect::<Vec<String>>())
1878+
}
17911879
fn get_keyword_as_array(&self, key: &str) -> Option<&Vec<Value>> {
17921880
self.get(key)
17931881
.and_then(Value::as_array)
@@ -2083,6 +2171,19 @@ impl SchemaUtilityExtensions for Schema {
20832171
.and_then(|properties| properties.get_mut(property_name))
20842172
.and_then(|v| <&mut Value as TryInto<&mut Schema>>::try_into(v).ok())
20852173
}
2174+
fn get_properties_keys(&self) -> Vec<String> {
2175+
self.get_properties()
2176+
.map_or_else(Vec::new, |obj| obj.keys().cloned().collect::<Vec<String>>())
2177+
}
2178+
fn get_required_property_names(&self) -> Vec<String> {
2179+
self.get_keyword_as_array("required")
2180+
.map_or_else(Vec::new, |arr| {
2181+
arr.iter()
2182+
.filter_map(Value::as_str)
2183+
.map(String::from)
2184+
.collect::<Vec<String>>()
2185+
})
2186+
}
20862187
fn get_references(&self) -> HashSet<&str> {
20872188
let mut references: HashSet<&str> = HashSet::new();
20882189
// First, check the top-level for a reference
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
use schemars::Schema;
5+
use serde_json::json;
6+
7+
use crate::schema_utility_extensions::SchemaUtilityExtensions;
8+
9+
/// Transforms the default generated schema for optional fields into a more idiomatic representation.
10+
///
11+
/// This transform is intended to be applied to the schema for a single `Option<T>` field.
12+
/// It removes the `null` branch that schemars adds (via `type: ["…", "null"]`, `enum: […, null]`, or `anyOf`).
13+
/// The field’s optionality should instead be expressed by omitting the property name from `required`.
14+
///
15+
/// # Panics
16+
///
17+
/// This transform panics if any apparently optional field doesn't define either:
18+
///
19+
/// - `type` as an array where one value is `"null"`
20+
/// - `anyOf` with exactly two subschemas, one of which is just `{ "type": "null" }`
21+
///
22+
/// # Example
23+
///
24+
/// ```rust
25+
/// use schemars::json_schema;
26+
/// use dsc_lib_jsonschema::transforms::idiomaticize_option_field;
27+
///
28+
/// let mut schema = json_schema!({
29+
/// "title": "Example",
30+
/// "description": "Optional string",
31+
/// "anyOf": [
32+
/// { "type": "null" },
33+
/// {
34+
/// "type": "string",
35+
/// "pattern": "^\\w+$",
36+
/// "title": "Foo"
37+
/// }
38+
/// ]
39+
/// });
40+
///
41+
/// idiomaticize_option_field(&mut schema);
42+
///
43+
/// let expected = json_schema!({
44+
/// "title": "Example",
45+
/// "description": "Optional string",
46+
/// "type": "string",
47+
/// "pattern": "^\\w+$"
48+
/// });
49+
///
50+
/// assert_eq!(schema, expected);
51+
/// ```
52+
///
53+
/// ```
54+
/// use schemars::json_schema;
55+
/// use dsc_lib_jsonschema::transforms::idiomaticize_option_field;
56+
///
57+
/// let mut schema = json_schema!({
58+
/// "title": "Example",
59+
/// "description": "Optional string",
60+
/// "type": ["null", "string"],
61+
/// "pattern": "^\\w+$"
62+
/// });
63+
///
64+
/// idiomaticize_option_field(&mut schema);
65+
///
66+
/// let expected = json_schema!({
67+
/// "title": "Example",
68+
/// "description": "Optional string",
69+
/// "type": "string",
70+
/// "pattern": "^\\w+$"
71+
/// });
72+
///
73+
/// assert_eq!(schema, expected);
74+
/// ```
75+
pub fn idiomaticize_option_field(schema: &mut Schema) {
76+
// Workaround for inability to borrow both mutably and immutably.
77+
let lookup_schema = schema.clone();
78+
let mut munged_schema = false;
79+
80+
// First, handle the case where the schema defines `type` with two values, one of which is
81+
// `"null"`. This is emitted by schemars for `Option<T>` fields where `T` is a type that
82+
// schemars implemented `JsonSchema` for, like `String` or `i32`.
83+
if let Some(types) = lookup_schema.get_keyword_as_array("type")
84+
&& types.len() == 2 && types.contains(&json!("null")) {
85+
let actual_type = types.iter().find(|t| t != &&serde_json::json!("null"));
86+
schema.insert("type".to_string(), actual_type.unwrap().clone());
87+
88+
munged_schema = true;
89+
}
90+
91+
// Handle `null` in `enum` keyword - remove if needed.
92+
if let Some(enum_values) = lookup_schema.get_keyword_as_array("enum")
93+
&& enum_values.contains(&json!(null)) {
94+
let mut new_enum_values = enum_values.clone();
95+
new_enum_values.retain(|v| v != &json!(null));
96+
schema.insert("enum".to_string(), json!(new_enum_values));
97+
98+
munged_schema = true;
99+
}
100+
101+
// If we munged the schema for type/enum, return early. The remaining code handles cases where
102+
// schemars inserted an `anyOf` keyword for referencing the underlying type schema.
103+
if munged_schema {
104+
return;
105+
}
106+
107+
// Next, handle the case where the schema uses `anyOf` to represent an optional field.
108+
// This is emitted by schemars for `Option<T>` fields where `T` is a type that implements
109+
// `JsonSchema`. In this case, `anyOf` defines exactly two subschemas, one of which only
110+
// specifies `type` as `"null"`. Usually, the other subschema only includes a reference to
111+
// the underlying type schema (`$ref` keyword) unless that schema is inlined.
112+
let any_ofs = lookup_schema.get("anyOf")
113+
.unwrap_or_else(|| panic_t!(
114+
"transforms.idiomaticize_option_field.applies_to",
115+
transforming_schema = serde_json::to_string_pretty(schema).unwrap()
116+
))
117+
.as_array()
118+
.unwrap_or_else(|| panic_t!(
119+
"transforms.idiomaticize_option_field.anyOf_array",
120+
transforming_schema = serde_json::to_string_pretty(schema).unwrap()
121+
));
122+
123+
if any_ofs.len() != 2 {
124+
panic_t!(
125+
"transforms.idiomaticize_option_field.anyOf_length_mismatch",
126+
actual_length = any_ofs.len(),
127+
transforming_schema = serde_json::to_string_pretty(schema).unwrap()
128+
);
129+
}
130+
131+
let null_schema = any_ofs
132+
.iter()
133+
.find(|s| s.get("type").is_some_and(|t| t == "null"));
134+
if null_schema.is_none() {
135+
panic_t!(
136+
"transforms.idiomaticize_option_field.null_schema_missing",
137+
transforming_schema = serde_json::to_string_pretty(schema).unwrap()
138+
);
139+
}
140+
let actual_schema = any_ofs
141+
.iter()
142+
.find(|s| s.get("type").is_none_or(|t| t != "null"));
143+
if actual_schema.is_none() {
144+
panic_t!(
145+
"transforms.idiomaticize_option_field.actual_schema_missing",
146+
transforming_schema = serde_json::to_string_pretty(schema).unwrap()
147+
);
148+
}
149+
150+
// At this point, we've verified that the target schema supports this transform.
151+
let actual_schema: &Schema = actual_schema.unwrap().try_into().unwrap();
152+
let munging_schema_keys = schema.get_defined_keywords();
153+
let actual_schema_keys = actual_schema.get_defined_keywords();
154+
155+
for key in actual_schema_keys {
156+
if !munging_schema_keys.contains(&key) {
157+
schema.insert(key.clone(), actual_schema.get(&key).unwrap().clone());
158+
}
159+
}
160+
161+
schema.remove("anyOf");
162+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
use schemars::Schema;
5+
6+
use crate::transforms::idiomaticize_option_field;
7+
use crate::schema_utility_extensions::SchemaUtilityExtensions;
8+
9+
/// Transforms all optional properties in the given JSON Schema to use the idiomatic `Option` type.
10+
///
11+
/// This function iterates over all properties in the schema and applies the
12+
/// [`idiomaticize_option_field`] transform to those that aren't in the `required` keyword array.
13+
///
14+
/// # Example
15+
///
16+
/// ```rust
17+
/// use schemars::json_schema;
18+
/// use dsc_lib_jsonschema::transforms::idiomaticize_optional_properties;
19+
///
20+
/// let mut schema = json_schema!({
21+
/// "title": "Example struct",
22+
/// "type": "object",
23+
/// "required": ["baz"],
24+
/// "properties": {
25+
/// "foo": {
26+
/// "type": ["string", "null"],
27+
/// "pattern": "^\\w+$",
28+
/// },
29+
/// "bar": {
30+
/// "anyOf": [
31+
/// { "$ref": "$defs/bar" },
32+
/// { "type": "null" },
33+
/// ]
34+
/// },
35+
/// "baz": {
36+
/// "type": ["string", "null"]
37+
/// }
38+
/// },
39+
/// "$defs": {
40+
/// "bar": {
41+
/// "type": "boolean"
42+
/// }
43+
/// }
44+
/// });
45+
/// idiomaticize_optional_properties(&mut schema);
46+
///
47+
/// let expected = json_schema!({
48+
/// "title": "Example struct",
49+
/// "type": "object",
50+
/// "required": ["baz"],
51+
/// "properties": {
52+
/// "foo": {
53+
/// "type": "string",
54+
/// "pattern": "^\\w+$",
55+
/// },
56+
/// "bar": {
57+
/// "$ref": "$defs/bar"
58+
/// },
59+
/// "baz": {
60+
/// "type": ["string", "null"]
61+
/// }
62+
/// },
63+
/// "$defs": {
64+
/// "bar": {
65+
/// "type": "boolean"
66+
/// }
67+
/// }
68+
/// });
69+
///
70+
/// assert_eq!(schema, expected);
71+
/// ```
72+
pub fn idiomaticize_optional_properties(schema: &mut Schema) {
73+
let lookup_schema = schema.clone();
74+
let required_properties = lookup_schema.get_required_property_names();
75+
for property_name in lookup_schema.get_properties_keys() {
76+
if required_properties.contains(&property_name) {
77+
continue;
78+
}
79+
if let Some(property_schema) = schema.get_property_subschema_mut(&property_name) {
80+
idiomaticize_option_field(property_schema);
81+
}
82+
}
83+
}

0 commit comments

Comments
 (0)