Skip to content

Commit cfdfcd1

Browse files
sbernauerNickLarsenNZTechassi
committed
fix: kube-core::Schema hoisting logic (#1)
Fixes kube Schema conversion since schemars Schema's have changed. - Add tests for a variety of enum use-cases: - Tagged vs Untagged - Unit vs Tuple vs Structural variants - With and without doc-comments (descriptions) - Rewrite the hoisting logic - This is annotated with dev-comments to help understand intend and to ease future schemars changes. This also fixes other issues: - Untagged enum variant doc-comments were being applied to field descriptions. - Additional `null` entry added to enums. --------- Co-authored-by: Nick Larsen <[email protected]> Co-authored-by: Sebastian Bernauer <[email protected]> Co-authored-by: Techassi <[email protected]> Signed-off-by: Nick Larsen <[email protected]>
1 parent 7c63f56 commit cfdfcd1

File tree

11 files changed

+2584
-150
lines changed

11 files changed

+2584
-150
lines changed

kube-core/src/schema.rs renamed to kube-core/src/schema/mod.rs

Lines changed: 33 additions & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,36 @@
22
//!
33
//! [`CustomResourceDefinition`]: `k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition`
44
5+
mod transform_any_of;
6+
mod transform_one_of;
7+
mod transform_optional_enum_with_null;
8+
mod transform_properties;
9+
510
// Used in docs
611
#[allow(unused_imports)] use schemars::generate::SchemaSettings;
712

813
use schemars::{transform::Transform, JsonSchema};
914
use serde::{Deserialize, Serialize};
1015
use serde_json::Value;
11-
use std::collections::{btree_map::Entry, BTreeMap, BTreeSet};
16+
use std::{
17+
collections::{BTreeMap, BTreeSet},
18+
sync::LazyLock,
19+
};
20+
21+
use crate::schema::{
22+
transform_any_of::hoist_any_of_subschema_with_a_nullable_variant,
23+
transform_one_of::hoist_one_of_enum_with_unit_variants,
24+
transform_optional_enum_with_null::remove_optional_enum_null_variant,
25+
transform_properties::hoist_properties_for_any_of_subschemas,
26+
};
27+
28+
/// This is the signature for the `null` variant produced by schemars.
29+
static NULL_SCHEMA: LazyLock<Value> = LazyLock::new(|| {
30+
serde_json::json!({
31+
"enum": [null],
32+
"nullable": true
33+
})
34+
});
1235

1336
/// schemars [`Visitor`] that rewrites a [`Schema`] to conform to Kubernetes' "structural schema" rules
1437
///
@@ -248,32 +271,22 @@ enum SingleOrVec<T> {
248271

249272
impl Transform for StructuralSchemaRewriter {
250273
fn transform(&mut self, transform_schema: &mut schemars::Schema) {
274+
// Apply this (self) transform to all subschemas
251275
schemars::transform::transform_subschemas(self, transform_schema);
252276

253277
let mut schema: SchemaObject = match serde_json::from_value(transform_schema.clone().to_value()).ok()
254278
{
279+
// TODO (@NickLarsenNZ): At this point, we are seeing duplicate `title` when printing schema as json.
280+
// This is because `title` is specified under both `extensions` and `other`.
255281
Some(schema) => schema,
256282
None => return,
257283
};
258284

259-
if let Some(subschemas) = &mut schema.subschemas {
260-
if let Some(one_of) = subschemas.one_of.as_mut() {
261-
// Tagged enums are serialized using `one_of`
262-
hoist_subschema_properties(one_of, &mut schema.object, &mut schema.instance_type);
263-
264-
// "Plain" enums are serialized using `one_of` if they have doc tags
265-
hoist_subschema_enum_values(one_of, &mut schema.enum_values, &mut schema.instance_type);
266-
267-
if one_of.is_empty() {
268-
subschemas.one_of = None;
269-
}
270-
}
271-
272-
if let Some(any_of) = &mut subschemas.any_of {
273-
// Untagged enums are serialized using `any_of`
274-
hoist_subschema_properties(any_of, &mut schema.object, &mut schema.instance_type);
275-
}
276-
}
285+
// Hoist parts of the schema to make it valid for k8s
286+
hoist_one_of_enum_with_unit_variants(&mut schema);
287+
hoist_any_of_subschema_with_a_nullable_variant(&mut schema);
288+
hoist_properties_for_any_of_subschemas(&mut schema);
289+
remove_optional_enum_null_variant(&mut schema);
277290

278291
// check for maps without with properties (i.e. flattened maps)
279292
// and allow these to persist dynamically
@@ -297,130 +310,11 @@ impl Transform for StructuralSchemaRewriter {
297310
array.unique_items = None;
298311
}
299312

313+
// Convert back to schemars::Schema
300314
if let Ok(schema) = serde_json::to_value(schema) {
301315
if let Ok(transformed) = serde_json::from_value(schema) {
302316
*transform_schema = transformed;
303317
}
304318
}
305319
}
306320
}
307-
308-
/// Bring all plain enum values up to the root schema,
309-
/// since Kubernetes doesn't allow subschemas to define enum options.
310-
///
311-
/// (Enum here means a list of hard-coded values, not a tagged union.)
312-
fn hoist_subschema_enum_values(
313-
subschemas: &mut Vec<Schema>,
314-
common_enum_values: &mut Option<Vec<serde_json::Value>>,
315-
instance_type: &mut Option<SingleOrVec<InstanceType>>,
316-
) {
317-
subschemas.retain(|variant| {
318-
if let Schema::Object(SchemaObject {
319-
instance_type: variant_type,
320-
enum_values: Some(variant_enum_values),
321-
..
322-
}) = variant
323-
{
324-
if let Some(variant_type) = variant_type {
325-
match instance_type {
326-
None => *instance_type = Some(variant_type.clone()),
327-
Some(tpe) => {
328-
if tpe != variant_type {
329-
panic!("Enum variant set {variant_enum_values:?} has type {variant_type:?} but was already defined as {instance_type:?}. The instance type must be equal for all subschema variants.")
330-
}
331-
}
332-
}
333-
}
334-
common_enum_values
335-
.get_or_insert_with(Vec::new)
336-
.extend(variant_enum_values.iter().cloned());
337-
false
338-
} else {
339-
true
340-
}
341-
})
342-
}
343-
344-
/// Bring all property definitions from subschemas up to the root schema,
345-
/// since Kubernetes doesn't allow subschemas to define properties.
346-
fn hoist_subschema_properties(
347-
subschemas: &mut Vec<Schema>,
348-
common_obj: &mut Option<Box<ObjectValidation>>,
349-
instance_type: &mut Option<SingleOrVec<InstanceType>>,
350-
) {
351-
for variant in subschemas {
352-
if let Schema::Object(SchemaObject {
353-
instance_type: variant_type,
354-
object: Some(variant_obj),
355-
metadata: variant_metadata,
356-
..
357-
}) = variant
358-
{
359-
let common_obj = common_obj.get_or_insert_with(Box::<ObjectValidation>::default);
360-
361-
if let Some(variant_metadata) = variant_metadata {
362-
// Move enum variant description from oneOf clause to its corresponding property
363-
if let Some(description) = std::mem::take(&mut variant_metadata.description) {
364-
if let Some(Schema::Object(variant_object)) =
365-
only_item(variant_obj.properties.values_mut())
366-
{
367-
let metadata = variant_object
368-
.metadata
369-
.get_or_insert_with(Box::<Metadata>::default);
370-
metadata.description = Some(description);
371-
}
372-
}
373-
}
374-
375-
// Move all properties
376-
let variant_properties = std::mem::take(&mut variant_obj.properties);
377-
for (property_name, property) in variant_properties {
378-
match common_obj.properties.entry(property_name) {
379-
Entry::Vacant(entry) => {
380-
entry.insert(property);
381-
}
382-
Entry::Occupied(entry) => {
383-
if &property != entry.get() {
384-
panic!("Property {:?} has the schema {:?} but was already defined as {:?} in another subschema. The schemas for a property used in multiple subschemas must be identical",
385-
entry.key(),
386-
&property,
387-
entry.get());
388-
}
389-
}
390-
}
391-
}
392-
393-
// Kubernetes doesn't allow variants to set additionalProperties
394-
variant_obj.additional_properties = None;
395-
396-
merge_metadata(instance_type, variant_type.take());
397-
}
398-
}
399-
}
400-
401-
fn only_item<I: Iterator>(mut i: I) -> Option<I::Item> {
402-
let item = i.next()?;
403-
if i.next().is_some() {
404-
return None;
405-
}
406-
Some(item)
407-
}
408-
409-
fn merge_metadata(
410-
instance_type: &mut Option<SingleOrVec<InstanceType>>,
411-
variant_type: Option<SingleOrVec<InstanceType>>,
412-
) {
413-
match (instance_type, variant_type) {
414-
(_, None) => {}
415-
(common_type @ None, variant_type) => {
416-
*common_type = variant_type;
417-
}
418-
(Some(common_type), Some(variant_type)) => {
419-
if *common_type != variant_type {
420-
panic!(
421-
"variant defined type {variant_type:?}, conflicting with existing type {common_type:?}"
422-
);
423-
}
424-
}
425-
}
426-
}

0 commit comments

Comments
 (0)