Skip to content

Commit 48a672e

Browse files
(MAINT) Restructure dsc-lib-jsonschema
This change refactors the `dsc-lib-jsonschema` library without modifying any behavior. This change: - Splits the functions in the `transforms` module out into submodules and re-exports them from `transforms` - this keeps referencing the functions the way it was before but makes it easier to navigate the files, given their length. - Makes the unit tests for `schema_utility_extensions` mirror the structure from the source code. - Makes the integration tests for `transform` mirror the structure from the source code.
1 parent 192888a commit 48a672e

File tree

12 files changed

+511
-518
lines changed

12 files changed

+511
-518
lines changed

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

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ pub trait SchemaUtilityExtensions {
344344
/// None
345345
/// )
346346
/// ```
347-
fn get_keyword_as_object(&self, key: &str) -> Option<& Map<String, Value>>;
347+
fn get_keyword_as_object(&self, key: &str) -> Option<&Map<String, Value>>;
348348
/// Checks a JSON Schema for a given keyword and mutably borrows the value of that keyword,
349349
/// if it exists, as a [`Map`].
350350
///
@@ -395,7 +395,7 @@ pub trait SchemaUtilityExtensions {
395395
/// None
396396
/// )
397397
/// ```
398-
fn get_keyword_as_object_mut(&mut self, key: &str) -> Option<&mut Map<String, Value>>;
398+
fn get_keyword_as_object_mut(&mut self, key: &str) -> Option<&mut Map<String, Value>>;
399399
/// Checks a JSON schema for a given keyword and borrows the value of that keyword, if it
400400
/// exists, as a [`Number`].
401401
///
@@ -537,7 +537,7 @@ pub trait SchemaUtilityExtensions {
537537
/// Checks a JSON Schema for a given keyword and returns the value of that keyword, if it
538538
/// exists, as a [`Schema`].
539539
///
540-
/// If the keyword doesn't exist or isn't a subchema, this function returns [`None`].
540+
/// If the keyword doesn't exist or isn't a subschema, this function returns [`None`].
541541
///
542542
/// # Examples
543543
///
@@ -621,12 +621,12 @@ pub trait SchemaUtilityExtensions {
621621
/// });
622622
///
623623
/// assert_eq!(
624-
/// schema.get_keyword_as_object_mut("not_exist"),
624+
/// schema.get_keyword_as_subschema_mut("not_exist"),
625625
/// None
626626
/// );
627627
///
628628
/// assert_eq!(
629-
/// schema.get_keyword_as_object_mut("items"),
629+
/// schema.get_keyword_as_subschema_mut("items"),
630630
/// None
631631
/// )
632632
/// ```
@@ -800,7 +800,7 @@ pub trait SchemaUtilityExtensions {
800800
/// defs_json.as_object()
801801
/// );
802802
/// ```
803-
fn get_defs(&self) -> Option<& Map<String, Value>>;
803+
fn get_defs(&self) -> Option<&Map<String, Value>>;
804804
/// Retrieves the `$defs` keyword and mutably borrows the object if it exists.
805805
///
806806
/// If the keyword isn't defined or isn't an object, the function returns [`None`].
@@ -828,7 +828,7 @@ pub trait SchemaUtilityExtensions {
828828
/// defs_json.as_object_mut()
829829
/// );
830830
/// ```
831-
fn get_defs_mut(&mut self) -> Option<&mut Map<String, Value>>;
831+
fn get_defs_mut(&mut self) -> Option<&mut Map<String, Value>>;
832832
/// Looks up a reference in the `$defs` keyword by `$id` and returns the subschema entry as a
833833
/// [`Schema`] if it exists.
834834
///
@@ -1107,7 +1107,7 @@ pub trait SchemaUtilityExtensions {
11071107
/// Some(&mut new_definition)
11081108
/// )
11091109
/// ```
1110-
fn insert_defs_subschema(&mut self, definition_key: &str, definition_value: & Map<String, Value>) -> Option< Map<String, Value>>;
1110+
fn insert_defs_subschema(&mut self, definition_key: &str, definition_value: &Map<String, Value>) -> Option< Map<String, Value>>;
11111111
/// Looks up a subschema in the `$defs` keyword by reference and, if it exists, renames the
11121112
/// _key_ for the definition.
11131113
///
@@ -1177,7 +1177,7 @@ pub trait SchemaUtilityExtensions {
11771177
/// properties_json.as_object()
11781178
/// );
11791179
/// ```
1180-
fn get_properties(&self) -> Option<& Map<String, Value>>;
1180+
fn get_properties(&self) -> Option<&Map<String, Value>>;
11811181
/// Retrieves the `properties` keyword and mutably borrows the object if it exists.
11821182
///
11831183
/// If the keyword isn't defined or isn't an object, the function returns [`None`].
@@ -1205,7 +1205,7 @@ pub trait SchemaUtilityExtensions {
12051205
/// properties_json.as_object_mut()
12061206
/// );
12071207
/// ```
1208-
fn get_properties_mut(&mut self) -> Option<&mut Map<String, Value>>;
1208+
fn get_properties_mut(&mut self) -> Option<&mut Map<String, Value>>;
12091209
/// Looks up a property in the `properties` keyword by name and returns the subschema entry as
12101210
/// a [`Schema`] if it exists.
12111211
///
@@ -1235,9 +1235,9 @@ pub trait SchemaUtilityExtensions {
12351235
/// ```
12361236
fn get_property_subschema(&self, property_name: &str) -> Option<&Schema>;
12371237
/// Looks up a property in the `properties` keyword by name and mutably borrows the subschema
1238-
/// entry as an object if it exists.
1238+
/// entry as a [`Schema`] if it exists.
12391239
///
1240-
/// If the named property doesn't exist or isn't an object, this function returns [`None`].
1240+
/// If the named property doesn't exist or isn't a [`Schema`], this function returns [`None`].
12411241
///
12421242
/// # Examples
12431243
///
@@ -1292,11 +1292,11 @@ impl SchemaUtilityExtensions for Schema {
12921292
self.get(key)
12931293
.and_then(Value::as_number)
12941294
}
1295-
fn get_keyword_as_object(&self, key: &str) -> Option<& Map<String, Value>> {
1295+
fn get_keyword_as_object(&self, key: &str) -> Option<&Map<String, Value>> {
12961296
self.get(key)
12971297
.and_then(Value::as_object)
12981298
}
1299-
fn get_keyword_as_object_mut(&mut self, key: &str) -> Option<&mut Map<String, Value>> {
1299+
fn get_keyword_as_object_mut(&mut self, key: &str) -> Option<&mut Map<String, Value>> {
13001300
self.get_mut(key)
13011301
.and_then(Value::as_object_mut)
13021302
}
@@ -1321,10 +1321,10 @@ impl SchemaUtilityExtensions for Schema {
13211321
self.get(key)
13221322
.and_then(Value::as_u64)
13231323
}
1324-
fn get_defs(&self) -> Option<& Map<String, Value>> {
1324+
fn get_defs(&self) -> Option<&Map<String, Value>> {
13251325
self.get_keyword_as_object("$defs")
13261326
}
1327-
fn get_defs_mut(&mut self) -> Option<&mut Map<String, Value>> {
1327+
fn get_defs_mut(&mut self) -> Option<&mut Map<String, Value>> {
13281328
self.get_keyword_as_object_mut("$defs")
13291329
}
13301330
fn get_defs_subschema_from_id(&self, id: &str) -> Option<&Schema> {
@@ -1468,10 +1468,10 @@ impl SchemaUtilityExtensions for Schema {
14681468
self.insert("$id".to_string(), Value::String(id_uri.to_string()))
14691469
.and(old_id)
14701470
}
1471-
fn get_properties(&self) -> Option<& Map<String, Value>> {
1471+
fn get_properties(&self) -> Option<&Map<String, Value>> {
14721472
self.get_keyword_as_object("properties")
14731473
}
1474-
fn get_properties_mut(&mut self) -> Option<&mut Map<String, Value>> {
1474+
fn get_properties_mut(&mut self) -> Option<&mut Map<String, Value>> {
14751475
self.get_keyword_as_object_mut("properties")
14761476
}
14771477
fn get_property_subschema(&self, property_name: &str) -> Option<&Schema> {

lib/dsc-lib-jsonschema/src/tests/mod.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,4 @@
1313
//! of the modules from the rest of the source tree.
1414
1515
#[cfg(test)] mod schema_utility_extensions;
16-
#[cfg(test)] mod transforms;
1716
#[cfg(test)] mod vscode;

lib/dsc-lib-jsonschema/src/tests/transforms/mod.rs

Lines changed: 0 additions & 4 deletions
This file was deleted.
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
use schemars::Schema;
5+
use serde_json::{Map, Value, json};
6+
7+
use crate::vscode::VSCODE_KEYWORDS;
8+
9+
/// Munges the generated schema for externally tagged enums into an idiomatic object schema.
10+
///
11+
/// Schemars generates the schema for externally tagged enums as a schema with the `oneOf`
12+
/// keyword where every tag is a different item in the array. Each item defines a type with a
13+
/// single property, requires that property, and disallows specifying any other properties.
14+
///
15+
/// This transformer returns the schema as a single object schema with each of the tags defined
16+
/// as properties. It sets both the `minProperties` and `maxProperties` keywords to `1`. This
17+
/// is more idiomatic, shorter to read and parse, easier to reason about, and matches the
18+
/// underlying data semantics more accurately.
19+
///
20+
/// This transformer should _only_ be used on externally tagged enums. You must specify it with the
21+
/// [schemars `transform()` attribute][`transform`].
22+
///
23+
/// # Examples
24+
///
25+
/// The following struct derives [`JsonSchema`] without specifying the [`transform`] attribute
26+
/// with [`idiomaticize_externally_tagged_enum`]:
27+
///
28+
/// ```
29+
/// use pretty_assertions::assert_eq;
30+
/// use serde_json;
31+
/// use schemars::{schema_for, JsonSchema, json_schema};
32+
/// #[derive(JsonSchema)]
33+
/// pub enum ExternallyTaggedEnum {
34+
/// Name(String),
35+
/// Count(f32),
36+
/// }
37+
///
38+
/// let generated_schema = schema_for!(ExternallyTaggedEnum);
39+
/// let expected_schema = json_schema!({
40+
/// "$schema": "https://json-schema.org/draft/2020-12/schema",
41+
/// "title": "ExternallyTaggedEnum",
42+
/// "oneOf": [
43+
/// {
44+
/// "type": "object",
45+
/// "properties": {
46+
/// "Name": {
47+
/// "type": "string"
48+
/// }
49+
/// },
50+
/// "additionalProperties": false,
51+
/// "required": ["Name"]
52+
/// },
53+
/// {
54+
/// "type": "object",
55+
/// "properties": {
56+
/// "Count": {
57+
/// "type": "number",
58+
/// "format": "float"
59+
/// }
60+
/// },
61+
/// "additionalProperties": false,
62+
/// "required": ["Count"]
63+
/// }
64+
/// ]
65+
/// });
66+
/// assert_eq!(generated_schema, expected_schema);
67+
/// ```
68+
///
69+
/// While the derived schema _does_ effectively validate the enum, it's difficult to understand
70+
/// without deep familiarity with JSON Schema. Compare it to the same enum with the
71+
/// [`idiomaticize_externally_tagged_enum`] transform applied:
72+
///
73+
/// ```
74+
/// use pretty_assertions::assert_eq;
75+
/// use serde_json;
76+
/// use schemars::{schema_for, JsonSchema, json_schema};
77+
/// use dsc_lib_jsonschema::transforms::idiomaticize_externally_tagged_enum;
78+
///
79+
/// #[derive(JsonSchema)]
80+
/// #[schemars(transform = idiomaticize_externally_tagged_enum)]
81+
/// pub enum ExternallyTaggedEnum {
82+
/// Name(String),
83+
/// Count(f32),
84+
/// }
85+
///
86+
/// let generated_schema = schema_for!(ExternallyTaggedEnum);
87+
/// let expected_schema = json_schema!({
88+
/// "$schema": "https://json-schema.org/draft/2020-12/schema",
89+
/// "title": "ExternallyTaggedEnum",
90+
/// "type": "object",
91+
/// "properties": {
92+
/// "Name": {
93+
/// "type": "string"
94+
/// },
95+
/// "Count": {
96+
/// "type": "number",
97+
/// "format": "float"
98+
/// }
99+
/// },
100+
/// "minProperties": 1,
101+
/// "maxProperties": 1,
102+
/// "additionalProperties": false
103+
/// });
104+
/// assert_eq!(generated_schema, expected_schema);
105+
/// ```
106+
///
107+
/// The transformed schema is shorter, clearer, and idiomatic for JSON Schema draft 2019-09 and
108+
/// later. It validates values as effectively as the default output for an externally tagged
109+
/// enum, but is easier for your users and integrating developers to understand and work
110+
/// with.
111+
///
112+
/// # Panics
113+
///
114+
/// This transform panics when called against a generated schema that doesn't define the `oneOf`
115+
/// keyword. Schemars uses the `oneOf` keyword when generating subschemas for externally tagged
116+
/// enums. This transform panics on an invalid application of the transform to prevent unexpected
117+
/// behavior for the schema transformation. This ensures invalid applications are caught during
118+
/// development and CI instead of shipping broken schemas.
119+
///
120+
/// [`JsonSchema`]: schemars::JsonSchema
121+
/// [`transform`]: derive@schemars::JsonSchema
122+
pub fn idiomaticize_externally_tagged_enum(schema: &mut Schema) {
123+
// First, retrieve the oneOf keyword entries. If this transformer was called against an invalid
124+
// schema or subschema, it should fail fast.
125+
let one_ofs = schema.get("oneOf")
126+
.unwrap_or_else(|| panic_t!(
127+
"transforms.idiomaticize_externally_tagged_enum.applies_to",
128+
transforming_schema = serde_json::to_string_pretty(schema).unwrap()
129+
))
130+
.as_array()
131+
.unwrap_or_else(|| panic_t!(
132+
"transforms.idiomaticize_externally_tagged_enum.oneOf_array",
133+
transforming_schema = serde_json::to_string_pretty(schema).unwrap()
134+
));
135+
// Initialize the map of properties to fill in when introspecting on the items in the oneOf array.
136+
let mut properties_map = Map::new();
137+
138+
for item in one_ofs {
139+
let item_data: Map<String, Value> = item.as_object()
140+
.unwrap_or_else(|| panic_t!(
141+
"transforms.idiomaticize_externally_tagged_enum.oneOf_item_as_object",
142+
transforming_schema = serde_json::to_string_pretty(schema).unwrap(),
143+
invalid_item = serde_json::to_string_pretty(item).unwrap()
144+
))
145+
.clone();
146+
// If we're accidentally operating on an invalid schema, short-circuit.
147+
let item_data_type = item_data.get("type")
148+
.unwrap_or_else(|| panic_t!(
149+
"transforms.idiomaticize_externally_tagged_enum.oneOf_item_define_type",
150+
transforming_schema = serde_json::to_string_pretty(schema).unwrap(),
151+
invalid_item = serde_json::to_string_pretty(&item_data).unwrap()
152+
))
153+
.as_str()
154+
.unwrap_or_else(|| panic_t!(
155+
"transforms.idiomaticize_externally_tagged_enum.oneOf_item_type_string",
156+
transforming_schema = serde_json::to_string_pretty(schema).unwrap(),
157+
invalid_item = serde_json::to_string_pretty(&item_data).unwrap()
158+
));
159+
assert_t!(
160+
!item_data_type.ne("object"),
161+
"transforms.idiomaticize_externally_tagged_enum.oneOf_item_not_object_type",
162+
transforming_schema = serde_json::to_string_pretty(schema).unwrap(),
163+
invalid_item = serde_json::to_string_pretty(&item_data).unwrap(),
164+
invalid_type = item_data_type
165+
);
166+
// Retrieve the title and description from the top-level of the item, if any. Depending on
167+
// the implementation, these values might be set on the item, in the property, or both.
168+
let item_title = item_data.get("title");
169+
let item_desc = item_data.get("description");
170+
// Retrieve the property definitions. There should never be more than one property per item,
171+
// but this implementation doesn't guard against that edge case..
172+
let properties_data = item_data.get("properties")
173+
.unwrap_or_else(|| panic_t!(
174+
"transforms.idiomaticize_externally_tagged_enum.oneOf_item_properties_missing",
175+
transforming_schema = serde_json::to_string_pretty(schema).unwrap(),
176+
invalid_item = serde_json::to_string_pretty(&item_data).unwrap(),
177+
))
178+
.as_object()
179+
.unwrap_or_else(|| panic_t!(
180+
"transforms.idiomaticize_externally_tagged_enum.oneOf_item_properties_not_object",
181+
transforming_schema = serde_json::to_string_pretty(schema).unwrap(),
182+
invalid_item = serde_json::to_string_pretty(&item_data).unwrap(),
183+
))
184+
.clone();
185+
for property_name in properties_data.keys() {
186+
// Retrieve the property definition to munge as needed.
187+
let mut property_data = properties_data.get(property_name)
188+
.unwrap() // can't fail because we're iterating on keys in the map
189+
.as_object()
190+
.unwrap_or_else(|| panic_t!(
191+
"transforms.idiomaticize_externally_tagged_enum.oneOf_item_properties_entry_not_object",
192+
transforming_schema = serde_json::to_string_pretty(schema).unwrap(),
193+
invalid_item = serde_json::to_string_pretty(&item_data).unwrap(),
194+
name = property_name
195+
))
196+
.clone();
197+
// Process the annotation keywords. If they are defined on the item but not the property,
198+
// insert the item-defined keywords into the property data.
199+
if let Some(t) = item_title && property_data.get("title").is_none() {
200+
property_data.insert("title".into(), t.clone());
201+
}
202+
if let Some(d) = item_desc && property_data.get("description").is_none() {
203+
property_data.insert("description".into(), d.clone());
204+
}
205+
for keyword in VSCODE_KEYWORDS {
206+
if let Some(keyword_value) = item_data.get(keyword) && property_data.get(keyword).is_none() {
207+
property_data.insert(keyword.to_string(), keyword_value.clone());
208+
}
209+
}
210+
// Insert the processed property into the top-level properties definition.
211+
properties_map.insert(property_name.into(), serde_json::Value::Object(property_data));
212+
}
213+
}
214+
// Replace the oneOf array with an idiomatic object schema definition
215+
schema.remove("oneOf");
216+
schema.insert("type".to_string(), json!("object"));
217+
schema.insert("minProperties".to_string(), json!(1));
218+
schema.insert("maxProperties".to_string(), json!(1));
219+
schema.insert("additionalProperties".to_string(), json!(false));
220+
schema.insert("properties".to_string(), properties_map.into());
221+
}

0 commit comments

Comments
 (0)