Skip to content

Commit e7f8cf9

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 87571d4 commit e7f8cf9

File tree

11 files changed

+490
-500
lines changed

11 files changed

+490
-500
lines changed

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

0 commit comments

Comments
 (0)