Skip to content

Commit 4496fa7

Browse files
committed
fix(kube-core): Use the transformer originally written in kube-rs#1839
Schema tests included
1 parent f284865 commit 4496fa7

File tree

1 file changed

+212
-27
lines changed

1 file changed

+212
-27
lines changed

kube-core/src/schema.rs

Lines changed: 212 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,20 @@
33
//! [`CustomResourceDefinition`]: `k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition`
44
55
// Used in docs
6-
#[allow(unused_imports)] use schemars::generate::SchemaSettings;
6+
#[allow(unused_imports)]
7+
use schemars::generate::SchemaSettings;
78

89
use schemars::{
910
JsonSchema,
1011
transform::{Transform, transform_subschemas},
1112
};
1213
use serde::{Deserialize, Serialize};
1314
use serde_json::{Value, json};
14-
use std::collections::{BTreeMap, BTreeSet, btree_map::Entry};
15+
use std::{
16+
collections::{BTreeMap, BTreeSet, btree_map::Entry},
17+
ops::DerefMut as _,
18+
sync::LazyLock,
19+
};
1520

1621
/// schemars [`Visitor`] that rewrites a [`Schema`] to conform to Kubernetes' "structural schema" rules
1722
///
@@ -375,40 +380,220 @@ impl Transform for StructuralSchemaRewriter {
375380
}
376381

377382
impl Transform for OptionalEnum {
378-
fn transform(&mut self, schema: &mut schemars::Schema) {
379-
transform_subschemas(self, schema);
383+
fn transform(&mut self, transform_schema: &mut schemars::Schema) {
384+
transform_subschemas(self, transform_schema);
380385

381-
let Some(obj) = schema.as_object_mut().filter(|o| o.len() == 1) else {
386+
let Some(mut schema) = serde_json::from_value(transform_schema.clone().to_value()).ok() else {
382387
return;
383388
};
384389

385-
let arr = obj
386-
.get("anyOf")
387-
.iter()
388-
.flat_map(|any_of| any_of.as_array())
389-
.last()
390-
.cloned()
391-
.unwrap_or_default();
390+
hoist_any_of_subschema_with_a_nullable_variant(&mut schema);
392391

393-
let [first, second] = arr.as_slice() else {
394-
return;
395-
};
396-
let (Some(first), Some(second)) = (first.as_object(), second.as_object()) else {
397-
return;
398-
};
399-
400-
if first.contains_key("enum")
401-
&& !first.contains_key("nullable")
402-
&& second.get("enum") == Some(&json!([null]))
403-
&& second.get("nullable") == Some(&json!(true))
404-
{
405-
obj.remove("anyOf");
406-
obj.append(&mut first.clone());
407-
obj.insert("nullable".to_string(), Value::Bool(true));
392+
// Convert back to schemars::Schema
393+
if let Ok(schema) = serde_json::to_value(schema) {
394+
if let Ok(transformed) = serde_json::from_value(schema) {
395+
*transform_schema = transformed;
396+
}
408397
}
409398
}
410399
}
411400

401+
/// This is the signature for the `null` variant produced by schemars.
402+
static NULL_SCHEMA: LazyLock<Value> = LazyLock::new(|| {
403+
serde_json::json!({
404+
"enum": [null],
405+
"nullable": true
406+
})
407+
});
408+
409+
fn hoist_any_of_subschema_with_a_nullable_variant(kube_schema: &mut SchemaObject) {
410+
// Run some initial checks in case there is nothing to do
411+
let SchemaObject {
412+
subschemas: Some(subschemas),
413+
..
414+
} = kube_schema
415+
else {
416+
return;
417+
};
418+
419+
let SubschemaValidation {
420+
any_of: Some(any_of),
421+
one_of: None,
422+
} = subschemas.deref_mut()
423+
else {
424+
return;
425+
};
426+
427+
if any_of.len() != 2 {
428+
return;
429+
}
430+
431+
let entry_is_null: [bool; 2] = any_of
432+
.iter()
433+
.map(|schema| serde_json::to_value(schema).expect("schema should be able to convert to JSON"))
434+
.map(|value| value == *NULL_SCHEMA)
435+
.collect::<Vec<_>>()
436+
.try_into()
437+
.expect("there should be exactly 2 elements. We checked earlier");
438+
439+
// Get the `any_of` subschema that isn't the null entry
440+
let subschema_to_hoist = match entry_is_null {
441+
[true, false] => &any_of[1],
442+
[false, true] => &any_of[0],
443+
_ => return,
444+
};
445+
446+
// At this point, we can be reasonably sure we need to hoist the non-null
447+
// anyOf subschema up to the schema level, and unset the anyOf field.
448+
// From here, anything that looks wrong will panic instead of return.
449+
// TODO (@NickLarsenNZ): Return errors instead of panicking, leave panicking up to the
450+
// infallible schemars::Transform
451+
let Schema::Object(to_hoist) = subschema_to_hoist else {
452+
panic!("the non-null anyOf subschema is a bool. That is not expected here");
453+
};
454+
455+
let mut to_hoist = to_hoist.clone();
456+
457+
// Move the metadata into the subschema before hoisting (unless it already has metadata set)
458+
to_hoist.metadata = to_hoist.metadata.or_else(|| kube_schema.metadata.take());
459+
460+
// Replace the schema with the non-null subschema
461+
*kube_schema = to_hoist;
462+
463+
// Set the schema to nullable (as we know we matched the null variant earlier)
464+
kube_schema.extensions.insert("nullable".to_owned(), true.into());
465+
}
466+
467+
#[cfg(test)]
468+
mod tests {
469+
use assert_json_diff::assert_json_eq;
470+
471+
use super::*;
472+
473+
#[test]
474+
fn optional_tagged_enum_with_unit_variants() {
475+
let original_value = serde_json::json!({
476+
"anyOf": [
477+
{
478+
"description": "A very simple enum with empty variants",
479+
"oneOf": [
480+
{
481+
"type": "string",
482+
"enum": [
483+
"C",
484+
"D"
485+
]
486+
},
487+
{
488+
"description": "First variant doc-comment",
489+
"type": "string",
490+
"enum": [
491+
"A"
492+
]
493+
},
494+
{
495+
"description": "Second variant doc-comment",
496+
"type": "string",
497+
"enum": [
498+
"B"
499+
]
500+
}
501+
]
502+
},
503+
{
504+
"enum": [
505+
null
506+
],
507+
"nullable": true
508+
}
509+
]
510+
});
511+
512+
let expected_converted_value = serde_json::json!({
513+
"description": "A very simple enum with empty variants",
514+
"nullable": true,
515+
"oneOf": [
516+
{
517+
"type": "string",
518+
"enum": [
519+
"C",
520+
"D"
521+
]
522+
},
523+
{
524+
"description": "First variant doc-comment",
525+
"type": "string",
526+
"enum": [
527+
"A"
528+
]
529+
},
530+
{
531+
"description": "Second variant doc-comment",
532+
"type": "string",
533+
"enum": [
534+
"B"
535+
]
536+
}
537+
]
538+
});
539+
540+
let original: SchemaObject = serde_json::from_value(original_value).expect("valid JSON");
541+
let expected_converted: SchemaObject =
542+
serde_json::from_value(expected_converted_value).expect("valid JSON");
543+
544+
let mut actual_converted = original.clone();
545+
hoist_any_of_subschema_with_a_nullable_variant(&mut actual_converted);
546+
547+
assert_json_eq!(actual_converted, expected_converted);
548+
}
549+
550+
#[test]
551+
fn optional_tagged_enum_with_unit_variants_but_also_an_existing_description() {
552+
let original_value = serde_json::json!({
553+
"description": "This comment will be lost",
554+
"anyOf": [
555+
{
556+
"description": "A very simple enum with empty variants",
557+
"type": "string",
558+
"enum": [
559+
"C",
560+
"D",
561+
"A",
562+
"B"
563+
]
564+
},
565+
{
566+
"enum": [
567+
null
568+
],
569+
"nullable": true
570+
}
571+
]
572+
});
573+
574+
let expected_converted_value = serde_json::json!({
575+
"description": "A very simple enum with empty variants",
576+
"nullable": true,
577+
"type": "string",
578+
"enum": [
579+
"C",
580+
"D",
581+
"A",
582+
"B"
583+
]
584+
});
585+
586+
let original: SchemaObject = serde_json::from_value(original_value).expect("valid JSON");
587+
let expected_converted: SchemaObject =
588+
serde_json::from_value(expected_converted_value).expect("valid JSON");
589+
590+
let mut actual_converted = original.clone();
591+
hoist_any_of_subschema_with_a_nullable_variant(&mut actual_converted);
592+
593+
assert_json_eq!(actual_converted, expected_converted);
594+
}
595+
}
596+
412597
impl Transform for OptionalIntOrString {
413598
fn transform(&mut self, schema: &mut schemars::Schema) {
414599
transform_subschemas(self, schema);

0 commit comments

Comments
 (0)