Skip to content

Commit e91665b

Browse files
committed
feat: Add functions for specific hoisting tasks
These include tests for a variety of schemas
1 parent c23aabc commit e91665b

File tree

3 files changed

+758
-0
lines changed

3 files changed

+758
-0
lines changed

kube-core/src/schema/transforms.rs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
use std::ops::DerefMut;
2+
3+
use crate::schema::{Schema, SchemaObject, SubschemaValidation};
4+
5+
#[cfg(test)]
6+
#[test]
7+
fn tagged_enum_with_unit_variants() {
8+
let original_schema_object_value = serde_json::json!({
9+
"description": "A very simple enum with unit variants",
10+
"oneOf": [
11+
{
12+
"type": "string",
13+
"enum": [
14+
"C",
15+
"D"
16+
]
17+
},
18+
{
19+
"description": "First variant doc-comment",
20+
"type": "string",
21+
"enum": [
22+
"A"
23+
]
24+
},
25+
{
26+
"description": "Second variant doc-comment",
27+
"type": "string",
28+
"enum": [
29+
"B"
30+
]
31+
},
32+
]
33+
});
34+
35+
let expected_converted_schema_object_value = serde_json::json!({
36+
"description": "A very simple enum with unit variants",
37+
"type": "string",
38+
"enum": [
39+
"C",
40+
"D",
41+
"A",
42+
"B"
43+
]
44+
});
45+
46+
47+
let original_schema_object: SchemaObject =
48+
serde_json::from_value(original_schema_object_value).expect("valid JSON");
49+
let expected_converted_schema_object: SchemaObject =
50+
serde_json::from_value(expected_converted_schema_object_value).expect("valid JSON");
51+
52+
let mut actual_converted_schema_object = original_schema_object.clone();
53+
hoist_one_of_enum_with_unit_variants(&mut actual_converted_schema_object);
54+
55+
assert_json_diff::assert_json_eq!(actual_converted_schema_object, expected_converted_schema_object);
56+
}
57+
58+
59+
/// Replace a list of typed oneOf subschemas with a typed schema level enum
60+
///
61+
/// Used for correcting the schema for tagged enums with unit variants.
62+
/// NOTE: Subschema descriptions are lost when they are combined into a single enum of the same type.
63+
///
64+
/// This will return early without modifications unless:
65+
/// - There are `oneOf` subschemas (not empty)
66+
/// - Each subschema contains an enum
67+
/// - Each subschema is typed
68+
/// - Each subschemas types is the same as the others
69+
///
70+
/// NOTE: This should work regardless of whether other hoisting has been performed or not.
71+
fn hoist_one_of_enum_with_unit_variants(kube_schema: &mut SchemaObject) {
72+
// Run some initial checks in case there is nothing to do
73+
let SchemaObject {
74+
subschemas: Some(subschemas),
75+
..
76+
} = kube_schema
77+
else {
78+
return;
79+
};
80+
81+
let SubschemaValidation {
82+
one_of: Some(one_of), ..
83+
} = subschemas.deref_mut()
84+
else {
85+
return;
86+
};
87+
88+
if one_of.is_empty() {
89+
return;
90+
}
91+
92+
// At this point, we can be reasonably sure we need to hoist the oneOf
93+
// subschema enums and types up to the schema level, and unset the oneOf field.
94+
// From here, anything that looks wrong will panic instead of return.
95+
// TODO (@NickLarsenNZ): Return errors instead of panicking, leave panicking up to the infallible schemars::Transform
96+
97+
// Prepare to ensure each variant schema has a type
98+
let mut types = one_of.iter().map(|schema| match schema {
99+
Schema::Object(SchemaObject {
100+
instance_type: Some(r#type),
101+
..
102+
}) => r#type,
103+
Schema::Object(untyped) => panic!("oneOf variants need to define a type: {untyped:#?}"),
104+
Schema::Bool(_) => panic!("oneOf variants can not be of type boolean"),
105+
});
106+
107+
// Get the first type
108+
let variant_type = types.next().expect("at this point, there must be a type");
109+
// Ensure all variant types match it
110+
if types.any(|r#type| r#type != variant_type) {
111+
panic!("oneOf variants must all have the same type");
112+
}
113+
114+
// For each `oneOf` entry, iterate over the `enum` and `const` values.
115+
// Panic on an entry that doesn't contain an `enum` or `const`.
116+
let new_enums = one_of.iter().flat_map(|schema| match schema {
117+
Schema::Object(SchemaObject {
118+
enum_values: Some(r#enum),
119+
..
120+
}) => r#enum.clone(),
121+
// Warning: The `const` check below must come after the enum check above.
122+
// Otherwise it will panic on a valid entry with an `enum`.
123+
Schema::Object(SchemaObject { other, .. }) => match other.get("const") {
124+
Some(r#const) => vec![r#const.clone()],
125+
None => panic!("oneOf variant did not provide \"enum\" or \"const\": {schema:#?}"),
126+
},
127+
Schema::Bool(_) => panic!("oneOf variants can not be of type boolean"),
128+
});
129+
// Merge the enums (extend just to be safe)
130+
kube_schema.enum_values.get_or_insert_default().extend(new_enums);
131+
132+
// Hoist the type
133+
kube_schema.instance_type = Some(variant_type.clone());
134+
135+
// Clear the oneOf subschemas
136+
subschemas.one_of = None;
137+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
use std::ops::DerefMut;
2+
3+
use crate::schema::{Schema, SchemaObject, SubschemaValidation};
4+
5+
#[cfg(test)]
6+
#[test]
7+
fn optional_tagged_enum_with_unit_variants() {
8+
let original_schema_object_value = serde_json::json!({
9+
"anyOf": [
10+
{
11+
"description": "A very simple enum with empty variants",
12+
"oneOf": [
13+
{
14+
"type": "string",
15+
"enum": [
16+
"C",
17+
"D"
18+
]
19+
},
20+
{
21+
"description": "First variant doc-comment",
22+
"type": "string",
23+
"enum": [
24+
"A"
25+
]
26+
},
27+
{
28+
"description": "Second variant doc-comment",
29+
"type": "string",
30+
"enum": [
31+
"B"
32+
]
33+
}
34+
]
35+
},
36+
{
37+
"enum": [
38+
null
39+
],
40+
"nullable": true
41+
}
42+
]
43+
});
44+
45+
let expected_converted_schema_object_value = serde_json::json!({
46+
"description": "A very simple enum with empty variants",
47+
"nullable": true,
48+
"oneOf": [
49+
{
50+
"type": "string",
51+
"enum": [
52+
"C",
53+
"D"
54+
]
55+
},
56+
{
57+
"description": "First variant doc-comment",
58+
"type": "string",
59+
"enum": [
60+
"A"
61+
]
62+
},
63+
{
64+
"description": "Second variant doc-comment",
65+
"type": "string",
66+
"enum": [
67+
"B"
68+
]
69+
}
70+
]
71+
});
72+
73+
74+
let original_schema_object: SchemaObject =
75+
serde_json::from_value(original_schema_object_value).expect("valid JSON");
76+
let expected_converted_schema_object: SchemaObject =
77+
serde_json::from_value(expected_converted_schema_object_value).expect("valid JSON");
78+
79+
let mut actual_converted_schema_object = original_schema_object.clone();
80+
hoist_any_of_subschema_with_a_nullable_variant(&mut actual_converted_schema_object);
81+
82+
assert_json_diff::assert_json_eq!(actual_converted_schema_object, expected_converted_schema_object);
83+
}
84+
85+
86+
/// Replace the schema with the non-null anyOf subschema when the only other subschema is the null schema.
87+
///
88+
/// Used for correcting the schema for optional tagged unit enums.
89+
/// The non-null subschema is hoisted, and nullable will be set to true.
90+
///
91+
/// This will return early without modifications unless:
92+
/// - There are exactly 2 `anyOf` subschemas
93+
/// - One subschema represents the `null` (has an enum with a null entry, and nullable set to true)
94+
///
95+
/// NOTE: This should work regardless of whether other hoisting has been performed or not.
96+
fn hoist_any_of_subschema_with_a_nullable_variant(kube_schema: &mut SchemaObject) {
97+
// Run some initial checks in case there is nothing to do
98+
let SchemaObject {
99+
subschemas: Some(subschemas),
100+
..
101+
} = kube_schema
102+
else {
103+
return;
104+
};
105+
106+
let SubschemaValidation {
107+
any_of: Some(any_of),
108+
one_of,
109+
} = subschemas.deref_mut()
110+
else {
111+
return;
112+
};
113+
114+
if any_of.len() != 2 {
115+
return;
116+
}
117+
118+
// This is the signature for the null variant, indicating the "other"
119+
// variant is the subschema that needs hoisting
120+
let null = serde_json::json!({
121+
"enum": [null],
122+
"nullable": true
123+
});
124+
125+
// iter through any_of entries, converting them to Value,
126+
// and build a truth table for null matches
127+
let entry_is_null: [bool; 2] = any_of
128+
.iter()
129+
.map(|x| serde_json::to_value(x).expect("schema should be able to convert to JSON"))
130+
.map(|x| x == null)
131+
.collect::<Vec<_>>()
132+
.try_into()
133+
.expect("there should be exactly 2 elements. We checked earlier");
134+
135+
// Get the `any_of` subschema that isn't the null entry
136+
let subschema_to_hoist = match entry_is_null {
137+
[true, false] => &any_of[1],
138+
[false, true] => &any_of[0],
139+
_ => return,
140+
};
141+
142+
// At this point, we can be reasonably sure we need to hoist the non-null
143+
// anyOf subschema up to the schema level, and unset the anyOf field.
144+
// From here, anything that looks wrong will panic instead of return.
145+
// TODO (@NickLarsenNZ): Return errors instead of panicking, leave panicking up to the infallible schemars::Transform
146+
147+
let Schema::Object(to_hoist) = subschema_to_hoist else {
148+
panic!("the non-null anyOf subschema is a bool. That is not expected here");
149+
};
150+
151+
// There should not be any oneOf's adjacent to the anyOf
152+
if one_of.is_some() {
153+
panic!("oneOf is set when there is already an anyOf: {one_of:#?}");
154+
}
155+
156+
// Replace the schema with the non-null subschema
157+
*kube_schema = to_hoist.clone();
158+
159+
// Set the schema to nullable (as we know we matched the null variant earlier)
160+
kube_schema.extensions.insert("nullable".to_owned(), true.into());
161+
}

0 commit comments

Comments
 (0)