From 295c43199d999fc51faa3f2dbb0dd859a63cf48e Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Wed, 8 Oct 2025 16:06:27 +0200 Subject: [PATCH 01/11] Add some tests for CRD generation for enums --- kube-derive/src/custom_resource.rs | 22 +- kube-derive/tests/crd_complex_enum_tests.rs | 519 ++++++++++++++++++ kube-derive/tests/crd_mixed_enum_test.rs | 234 ++++++++ ...num_test.rs => crd_top_level_enum_test.rs} | 0 kube-derive/tests/ui/union_fails.stderr | 4 +- 5 files changed, 767 insertions(+), 12 deletions(-) create mode 100644 kube-derive/tests/crd_complex_enum_tests.rs create mode 100644 kube-derive/tests/crd_mixed_enum_test.rs rename kube-derive/tests/{crd_enum_test.rs => crd_top_level_enum_test.rs} (100%) diff --git a/kube-derive/src/custom_resource.rs b/kube-derive/src/custom_resource.rs index 443653576..bc5a173a9 100644 --- a/kube-derive/src/custom_resource.rs +++ b/kube-derive/src/custom_resource.rs @@ -904,15 +904,17 @@ mod tests { #[test] fn test_derive_crd() { - let path = env::current_dir().unwrap().join("tests").join("crd_enum_test.rs"); - let file = fs::File::open(path).unwrap(); - runtime_macros::emulate_derive_macro_expansion(file, &[("CustomResource", derive)]).unwrap(); - - let path = env::current_dir() - .unwrap() - .join("tests") - .join("crd_schema_test.rs"); - let file = fs::File::open(path).unwrap(); - runtime_macros::emulate_derive_macro_expansion(file, &[("CustomResource", derive)]).unwrap(); + let files = [ + "crd_complex_enum_tests.rs", + "crd_mixed_enum_test.rs", + "crd_schema_test.rs", + "crd_top_level_enum_test.rs", + ]; + + for file in files { + let path = env::current_dir().unwrap().join("tests").join(file); + let file = fs::File::open(path).unwrap(); + runtime_macros::emulate_derive_macro_expansion(file, &[("CustomResource", derive)]).unwrap(); + } } } diff --git a/kube-derive/tests/crd_complex_enum_tests.rs b/kube-derive/tests/crd_complex_enum_tests.rs new file mode 100644 index 000000000..111b39bf9 --- /dev/null +++ b/kube-derive/tests/crd_complex_enum_tests.rs @@ -0,0 +1,519 @@ +#![allow(missing_docs)] +use assert_json_diff::assert_json_eq; +use kube::{CustomResource, CustomResourceExt}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +/// A very simple enum with empty variants +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] +enum NormalEnum { + /// First variant + A, + /// Second variant + B, +} + +/// An untagged enum with a nested enum inside +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] +#[serde(untagged)] +enum UntaggedEnum { + /// Used in case the `one` field of tpye [`u32`] is present + A { one: String }, + /// Used in case the `two` field of type [`NormalEnum`] is present + B { two: NormalEnum }, + /// Used in case no fields are present + C {}, +} + +/// Put a [`UntaggedEnum`] behind `#[serde(flatten)]` +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] +struct FlattenedUntaggedEnum { + #[serde(flatten)] + inner: UntaggedEnum, +} + +#[derive(CustomResource, Serialize, Deserialize, Debug, Clone, JsonSchema)] +#[kube(group = "clux.dev", version = "v1", kind = "NormalEnumTest")] +struct NormalEnumTestSpec { + foo: NormalEnum, +} + +#[derive(CustomResource, Serialize, Deserialize, Debug, Clone, JsonSchema)] +#[kube(group = "clux.dev", version = "v1", kind = "OptionalEnumTest")] +struct OptionalEnumTestSpec { + foo: Option, +} + +#[derive(CustomResource, Serialize, Deserialize, Debug, Clone, JsonSchema)] +#[kube(group = "clux.dev", version = "v1", kind = "UntaggedEnumTest")] +struct UntaggedEnumTestSpec { + foo: UntaggedEnum, +} + +#[derive(CustomResource, Serialize, Deserialize, Debug, Clone, JsonSchema)] +#[kube(group = "clux.dev", version = "v1", kind = "OptionalUntaggedEnumTest")] +struct OptionalUntaggedEnumTestSpec { + foo: Option, +} + +#[derive(CustomResource, Serialize, Deserialize, Debug, Clone, JsonSchema)] +#[kube(group = "clux.dev", version = "v1", kind = "FlattenedUntaggedEnumTest")] +struct FlattenedUntaggedEnumTestSpec { + foo: FlattenedUntaggedEnum, +} + +/// Use `cargo test --package kube-derive print_crds -- --nocapture` to get the CRDs as YAML. +/// Afterwards you can use `kubectl apply -f -` to see if they are valid CRDs. +#[test] +fn print_crds() { + println!("{}", serde_yaml::to_string(&NormalEnumTest::crd()).unwrap()); + println!("---"); + println!("{}", serde_yaml::to_string(&OptionalEnumTest::crd()).unwrap()); + println!("---"); + println!("{}", serde_yaml::to_string(&UntaggedEnumTest::crd()).unwrap()); + println!("---"); + println!( + "{}", + serde_yaml::to_string(&OptionalUntaggedEnumTest::crd()).unwrap() + ); + println!("---"); + println!( + "{}", + serde_yaml::to_string(&FlattenedUntaggedEnumTest::crd()).unwrap() + ); +} + +#[test] +fn normal_enum() { + assert_json_eq!( + NormalEnumTest::crd(), + json!( + { + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": { + "name": "normalenumtests.clux.dev" + }, + "spec": { + "group": "clux.dev", + "names": { + "categories": [], + "kind": "NormalEnumTest", + "plural": "normalenumtests", + "shortNames": [], + "singular": "normalenumtest" + }, + "scope": "Cluster", + "versions": [ + { + "additionalPrinterColumns": [], + "name": "v1", + "schema": { + "openAPIV3Schema": { + "description": "Auto-generated derived type for NormalEnumTestSpec via `CustomResource`", + "properties": { + "spec": { + "properties": { + "foo": { + "description": "A very simple enum with empty variants", + "enum": [ + "A", + "B" + ], + "type": "string" + } + }, + "required": [ + "foo" + ], + "type": "object" + } + }, + "required": [ + "spec" + ], + "title": "NormalEnumTest", + "type": "object" + } + }, + "served": true, + "storage": true, + "subresources": {} + } + ] + } + } + ) + ); +} + +#[test] +fn optional_enum() { + assert_json_eq!( + OptionalEnumTest::crd(), + json!( + { + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": { + "name": "optionalenumtests.clux.dev" + }, + "spec": { + "group": "clux.dev", + "names": { + "categories": [], + "kind": "OptionalEnumTest", + "plural": "optionalenumtests", + "shortNames": [], + "singular": "optionalenumtest" + }, + "scope": "Cluster", + "versions": [ + { + "additionalPrinterColumns": [], + "name": "v1", + "schema": { + "openAPIV3Schema": { + "description": "Auto-generated derived type for OptionalEnumTestSpec via `CustomResource`", + "properties": { + "spec": { + "properties": { + "foo": { + "anyOf": [ + { + "description": "A very simple enum with empty variants", + "enum": [ + "A", + "B" + ], + "type": "string" + }, + { + "enum": [ + null + ], + "nullable": true + } + ] + } + }, + "type": "object" + } + }, + "required": [ + "spec" + ], + "title": "OptionalEnumTest", + "type": "object" + } + }, + "served": true, + "storage": true, + "subresources": {} + } + ] + } + } + ) + ); + + // The CustomResourceDefinition "optionalenumtests.clux.dev" is invalid: + // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].anyOf[0].description: Forbidden: must be empty to be structural + // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].anyOf[0].type: Forbidden: must be empty to be structural + // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].anyOf[1].nullable: Forbidden: must be false to be structural + // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].type: Required value: must not be empty for specified object fields + panic!("This CRD is currently not accepted by Kubernetes!"); +} + +#[test] +fn untagged_enum() { + assert_json_eq!( + UntaggedEnumTest::crd(), + json!( + { + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": { + "name": "untaggedenumtests.clux.dev" + }, + "spec": { + "group": "clux.dev", + "names": { + "categories": [], + "kind": "UntaggedEnumTest", + "plural": "untaggedenumtests", + "shortNames": [], + "singular": "untaggedenumtest" + }, + "scope": "Cluster", + "versions": [ + { + "additionalPrinterColumns": [], + "name": "v1", + "schema": { + "openAPIV3Schema": { + "description": "Auto-generated derived type for UntaggedEnumTestSpec via `CustomResource`", + "properties": { + "spec": { + "properties": { + "foo": { + "anyOf": [ + { + "required": [ + "one" + ] + }, + { + "required": [ + "two" + ] + }, + { + "description": "Used in case no fields are present", + "type": "object" + } + ], + "description": "An untagged enum with a nested enum inside", + "properties": { + "one": { + "description": "Used in case the `one` field of tpye [`u32`] is present", + "type": "string" + }, + "two": { + "description": "Used in case the `two` field of type [`NormalEnum`] is present", + "enum": [ + "A", + "B" + ], + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "foo" + ], + "type": "object" + } + }, + "required": [ + "spec" + ], + "title": "UntaggedEnumTest", + "type": "object" + } + }, + "served": true, + "storage": true, + "subresources": {} + } + ] + } + } + ) + ); + + // The CustomResourceDefinition "untaggedenumtests.clux.dev" is invalid: + // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].anyOf[2].description: Forbidden: must be empty to be structural + // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].anyOf[2].type: Forbidden: must be empty to be structural + panic!("This CRD is currently not accepted by Kubernetes!"); +} + +#[test] +fn optional_untagged_enum() { + assert_json_eq!( + OptionalUntaggedEnumTest::crd(), + json!( + { + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": { + "name": "optionaluntaggedenumtests.clux.dev" + }, + "spec": { + "group": "clux.dev", + "names": { + "categories": [], + "kind": "OptionalUntaggedEnumTest", + "plural": "optionaluntaggedenumtests", + "shortNames": [], + "singular": "optionaluntaggedenumtest" + }, + "scope": "Cluster", + "versions": [ + { + "additionalPrinterColumns": [], + "name": "v1", + "schema": { + "openAPIV3Schema": { + "description": "Auto-generated derived type for OptionalUntaggedEnumTestSpec via `CustomResource`", + "properties": { + "spec": { + "properties": { + "foo": { + "anyOf": [ + { + "anyOf": [ + { + "required": [ + "one" + ] + }, + { + "required": [ + "two" + ] + }, + { + "description": "Used in case no fields are present", + "type": "object" + } + ] + }, + { + "enum": [ + null + ], + "nullable": true + } + ], + "properties": { + "one": { + "description": "Used in case the `one` field of tpye [`u32`] is present", + "type": "string" + }, + "two": { + "description": "Used in case the `two` field of type [`NormalEnum`] is present", + "enum": [ + "A", + "B" + ], + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "spec" + ], + "title": "OptionalUntaggedEnumTest", + "type": "object" + } + }, + "served": true, + "storage": true, + "subresources": {} + } + ] + } + } + ) + ); + + // The CustomResourceDefinition "optionaluntaggedenumtests.clux.dev" is invalid: + // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].anyOf[0].anyOf[2].description: Forbidden: must be empty to be structural + // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].anyOf[0].anyOf[2].type: Forbidden: must be empty to be structural + // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].anyOf[1].nullable: Forbidden: must be false to be structural + panic!("This CRD is currently not accepted by Kubernetes!"); +} + +#[test] +fn flattened_untagged_enum() { + assert_json_eq!( + FlattenedUntaggedEnumTest::crd(), + json!( + { + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": { + "name": "flatteneduntaggedenumtests.clux.dev" + }, + "spec": { + "group": "clux.dev", + "names": { + "categories": [], + "kind": "FlattenedUntaggedEnumTest", + "plural": "flatteneduntaggedenumtests", + "shortNames": [], + "singular": "flatteneduntaggedenumtest" + }, + "scope": "Cluster", + "versions": [ + { + "additionalPrinterColumns": [], + "name": "v1", + "schema": { + "openAPIV3Schema": { + "description": "Auto-generated derived type for FlattenedUntaggedEnumTestSpec via `CustomResource`", + "properties": { + "spec": { + "properties": { + "foo": { + "anyOf": [ + { + "required": [ + "one" + ] + }, + { + "required": [ + "two" + ] + }, + { + "description": "Used in case no fields are present", + "type": "object" + } + ], + "description": "Put a [`UntaggedEnum`] behind `#[serde(flatten)]`,", + "properties": { + "one": { + "description": "Used in case the `one` field of tpye [`u32`] is present", + "type": "string" + }, + "two": { + "description": "Used in case the `two` field of type [`NormalEnum`] is present", + "enum": [ + "A", + "B" + ], + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "foo" + ], + "type": "object" + } + }, + "required": [ + "spec" + ], + "title": "FlattenedUntaggedEnumTest", + "type": "object" + } + }, + "served": true, + "storage": true, + "subresources": {} + } + ] + } + } + ) + ); + + // The CustomResourceDefinition "flatteneduntaggedenumtests.clux.dev" is invalid: + // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].anyOf[2].description: Forbidden: must be empty to be structural + // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].anyOf[2].type: Forbidden: must be empty to be structural + panic!("This CRD is currently not accepted by Kubernetes!"); +} diff --git a/kube-derive/tests/crd_mixed_enum_test.rs b/kube-derive/tests/crd_mixed_enum_test.rs new file mode 100644 index 000000000..08dfb8249 --- /dev/null +++ b/kube-derive/tests/crd_mixed_enum_test.rs @@ -0,0 +1,234 @@ +#![allow(missing_docs)] +use assert_json_diff::assert_json_eq; +use kube::{CustomResource, CustomResourceExt}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +/// This enum is invalid, as "plain" (string) variants are mixed with object variants +#[derive(CustomResource, Serialize, Deserialize, Debug, Clone, JsonSchema)] +#[kube(group = "clux.dev", version = "v1", kind = "InvalidEnum1")] +enum InvalidEnum1Spec { + /// Unit variant (represented as string) + A, + /// Takes an [`u32`] (represented as object) + B(u32), +} + +/// This enum is invalid, as "plain" (string) variants are mixed with object variants +#[derive(CustomResource, Serialize, Deserialize, Debug, Clone, JsonSchema)] +#[kube(group = "clux.dev", version = "v1", kind = "InvalidEnum2")] +enum InvalidEnum2Spec { + /// Unit variant (represented as string) + A, + /// Takes a single field (represented as object) + B { inner: u32 }, +} + +/// This enum is valid, as all variants are objects +#[derive(CustomResource, Serialize, Deserialize, Debug, Clone, JsonSchema)] +#[kube(group = "clux.dev", version = "v1", kind = "ValidEnum3")] +enum ValidEnum3Spec { + /// Takes an [`String`] (represented as object) + A(String), + /// Takes an [`u32`] (represented as object) + B(u32), +} + +// This enum intentionally has no documentation to increase test coverage! +#[derive(CustomResource, Serialize, Deserialize, Debug, Clone, JsonSchema)] +#[kube(group = "clux.dev", version = "v1", kind = "ValidEnum4")] +enum ValidEnum4Spec { + A(String), + B { inner: u32 }, +} + +/// Use `cargo test --package kube-derive print_crds -- --nocapture` to get the CRDs as YAML. +/// Afterwards you can use `kubectl apply -f -` to see if they are valid CRDs. +#[test] +fn print_crds() { + println!("{}", serde_yaml::to_string(&ValidEnum3::crd()).unwrap()); + println!("---"); + println!("{}", serde_yaml::to_string(&ValidEnum4::crd()).unwrap()); +} + +#[test] +#[should_panic = "Enum variant set [String(\"A\")] has type Single(String) but was already defined as Some(Single(Object)). The instance type must be equal for all subschema variants."] +fn invalid_enum_1() { + InvalidEnum1::crd(); +} + +#[test] +#[should_panic = "Enum variant set [String(\"A\")] has type Single(String) but was already defined as Some(Single(Object)). The instance type must be equal for all subschema variants."] +fn invalid_enum_2() { + InvalidEnum2::crd(); +} + +#[test] +fn valid_enum_3() { + assert_json_eq!( + ValidEnum3::crd(), + json!( + { + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": { + "name": "validenum3s.clux.dev" + }, + "spec": { + "group": "clux.dev", + "names": { + "categories": [], + "kind": "ValidEnum3", + "plural": "validenum3s", + "shortNames": [], + "singular": "validenum3" + }, + "scope": "Cluster", + "versions": [ + { + "additionalPrinterColumns": [], + "name": "v1", + "schema": { + "openAPIV3Schema": { + "description": "Auto-generated derived type for ValidEnum3Spec via `CustomResource`", + "properties": { + "spec": { + "description": "This enum is valid, as all variants are objects", + "oneOf": [ + { + "required": [ + "A" + ] + }, + { + "required": [ + "B" + ] + } + ], + "properties": { + "A": { + "description": "Takes an [`String`] (represented as object)", + "type": "string" + }, + "B": { + "description": "Takes an [`u32`] (represented as object)", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + } + }, + "type": "object" + } + }, + "required": [ + "spec" + ], + "title": "ValidEnum3", + "type": "object" + } + }, + "served": true, + "storage": true, + "subresources": {} + } + ] + } + } + ) + ); +} + +#[test] +fn valid_enum_4() { + assert_json_eq!( + ValidEnum4::crd(), + json!( + { + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": { + "name": "validenum4s.clux.dev" + }, + "spec": { + "group": "clux.dev", + "names": { + "categories": [], + "kind": "ValidEnum4", + "plural": "validenum4s", + "shortNames": [], + "singular": "validenum4" + }, + "scope": "Cluster", + "versions": [ + { + "additionalPrinterColumns": [], + "name": "v1", + "schema": { + "openAPIV3Schema": { + "description": "Auto-generated derived type for ValidEnum4Spec via `CustomResource`", + "properties": { + "spec": { + "oneOf": [ + { + "required": [ + "A" + ] + }, + { + "required": [ + "B" + ] + } + ], + "properties": { + "A": { + "type": "string" + }, + "B": { + "properties": { + "inner": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "inner" + ], + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "spec" + ], + "title": "ValidEnum4", + "type": "object" + } + }, + "served": true, + "storage": true, + "subresources": {} + } + ] + } + } + ) + ); +} + +#[test] +#[should_panic = "Enum variant set [String(\"A\")] has type Single(String) but was already defined as Some(Single(Object)). The instance type must be equal for all subschema variants."] +fn struct_with_enum_1() { + #[derive(CustomResource, Serialize, Deserialize, Debug, Clone, JsonSchema)] + #[kube(group = "clux.dev", version = "v1", kind = "Foo")] + struct FooSpec { + foo: InvalidEnum1, + } + + Foo::crd(); +} diff --git a/kube-derive/tests/crd_enum_test.rs b/kube-derive/tests/crd_top_level_enum_test.rs similarity index 100% rename from kube-derive/tests/crd_enum_test.rs rename to kube-derive/tests/crd_top_level_enum_test.rs diff --git a/kube-derive/tests/ui/union_fails.stderr b/kube-derive/tests/ui/union_fails.stderr index 3bc077716..ae002ca36 100644 --- a/kube-derive/tests/ui/union_fails.stderr +++ b/kube-derive/tests/ui/union_fails.stderr @@ -7,7 +7,7 @@ error: Unions can not #[derive(CustomResource)] error: Serde does not support derive for unions --> tests/ui/union_fails.rs:8:1 | -8 | / union FooSpec { -9 | | int: u32, + 8 | / union FooSpec { + 9 | | int: u32, 10 | | } | |_^ From 13db30ccaa045095898bf69300830373b4b7d92a Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 9 Oct 2025 14:27:40 +0200 Subject: [PATCH 02/11] Update assertions to valid CRDs from kube 1.1.0 --- kube-derive/tests/crd_complex_enum_tests.rs | 87 +++++---------------- 1 file changed, 19 insertions(+), 68 deletions(-) diff --git a/kube-derive/tests/crd_complex_enum_tests.rs b/kube-derive/tests/crd_complex_enum_tests.rs index 111b39bf9..834cd4246 100644 --- a/kube-derive/tests/crd_complex_enum_tests.rs +++ b/kube-derive/tests/crd_complex_enum_tests.rs @@ -180,22 +180,13 @@ fn optional_enum() { "spec": { "properties": { "foo": { - "anyOf": [ - { - "description": "A very simple enum with empty variants", - "enum": [ - "A", - "B" - ], - "type": "string" - }, - { - "enum": [ - null - ], - "nullable": true - } - ] + "description": "A very simple enum with empty variants", + "enum": [ + "A", + "B" + ], + "nullable": true, + "type": "string" } }, "type": "object" @@ -217,13 +208,6 @@ fn optional_enum() { } ) ); - - // The CustomResourceDefinition "optionalenumtests.clux.dev" is invalid: - // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].anyOf[0].description: Forbidden: must be empty to be structural - // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].anyOf[0].type: Forbidden: must be empty to be structural - // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].anyOf[1].nullable: Forbidden: must be false to be structural - // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].type: Required value: must not be empty for specified object fields - panic!("This CRD is currently not accepted by Kubernetes!"); } #[test] @@ -269,10 +253,7 @@ fn untagged_enum() { "two" ] }, - { - "description": "Used in case no fields are present", - "type": "object" - } + {} ], "description": "An untagged enum with a nested enum inside", "properties": { @@ -314,11 +295,6 @@ fn untagged_enum() { } ) ); - - // The CustomResourceDefinition "untaggedenumtests.clux.dev" is invalid: - // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].anyOf[2].description: Forbidden: must be empty to be structural - // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].anyOf[2].type: Forbidden: must be empty to be structural - panic!("This CRD is currently not accepted by Kubernetes!"); } #[test] @@ -355,30 +331,19 @@ fn optional_untagged_enum() { "foo": { "anyOf": [ { - "anyOf": [ - { - "required": [ - "one" - ] - }, - { - "required": [ - "two" - ] - }, - { - "description": "Used in case no fields are present", - "type": "object" - } + "required": [ + "one" ] }, { - "enum": [ - null - ], - "nullable": true - } + "required": [ + "two" + ] + }, + {} ], + "description": "An untagged enum with a nested enum inside", + "nullable": true, "properties": { "one": { "description": "Used in case the `one` field of tpye [`u32`] is present", @@ -415,12 +380,6 @@ fn optional_untagged_enum() { } ) ); - - // The CustomResourceDefinition "optionaluntaggedenumtests.clux.dev" is invalid: - // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].anyOf[0].anyOf[2].description: Forbidden: must be empty to be structural - // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].anyOf[0].anyOf[2].type: Forbidden: must be empty to be structural - // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].anyOf[1].nullable: Forbidden: must be false to be structural - panic!("This CRD is currently not accepted by Kubernetes!"); } #[test] @@ -466,12 +425,9 @@ fn flattened_untagged_enum() { "two" ] }, - { - "description": "Used in case no fields are present", - "type": "object" - } + {} ], - "description": "Put a [`UntaggedEnum`] behind `#[serde(flatten)]`,", + "description": "Put a [`UntaggedEnum`] behind `#[serde(flatten)]`", "properties": { "one": { "description": "Used in case the `one` field of tpye [`u32`] is present", @@ -511,9 +467,4 @@ fn flattened_untagged_enum() { } ) ); - - // The CustomResourceDefinition "flatteneduntaggedenumtests.clux.dev" is invalid: - // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].anyOf[2].description: Forbidden: must be empty to be structural - // * spec.validation.openAPIV3Schema.properties[spec].properties[foo].anyOf[2].type: Forbidden: must be empty to be structural - panic!("This CRD is currently not accepted by Kubernetes!"); } From fb77413dee574962dbd67227613833d50748f8e8 Mon Sep 17 00:00:00 2001 From: Nick Larsen Date: Thu, 9 Oct 2025 14:37:39 +0200 Subject: [PATCH 03/11] test: Add some un-documented variants to the NormalEnum --- kube-derive/tests/crd_complex_enum_tests.rs | 216 ++++++++++---------- 1 file changed, 112 insertions(+), 104 deletions(-) diff --git a/kube-derive/tests/crd_complex_enum_tests.rs b/kube-derive/tests/crd_complex_enum_tests.rs index 834cd4246..4520fa6ef 100644 --- a/kube-derive/tests/crd_complex_enum_tests.rs +++ b/kube-derive/tests/crd_complex_enum_tests.rs @@ -12,6 +12,10 @@ enum NormalEnum { A, /// Second variant B, + + // No doc-comments on these variants + C, + D, } /// An untagged enum with a nested enum inside @@ -89,61 +93,63 @@ fn normal_enum() { assert_json_eq!( NormalEnumTest::crd(), json!( - { - "apiVersion": "apiextensions.k8s.io/v1", - "kind": "CustomResourceDefinition", - "metadata": { - "name": "normalenumtests.clux.dev" + { + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": { + "name": "normalenumtests.clux.dev" + }, + "spec": { + "group": "clux.dev", + "names": { + "categories": [], + "kind": "NormalEnumTest", + "plural": "normalenumtests", + "shortNames": [], + "singular": "normalenumtest" }, - "spec": { - "group": "clux.dev", - "names": { - "categories": [], - "kind": "NormalEnumTest", - "plural": "normalenumtests", - "shortNames": [], - "singular": "normalenumtest" - }, - "scope": "Cluster", - "versions": [ - { - "additionalPrinterColumns": [], - "name": "v1", - "schema": { - "openAPIV3Schema": { - "description": "Auto-generated derived type for NormalEnumTestSpec via `CustomResource`", - "properties": { - "spec": { - "properties": { - "foo": { - "description": "A very simple enum with empty variants", - "enum": [ - "A", - "B" - ], - "type": "string" - } - }, - "required": [ - "foo" - ], - "type": "object" - } - }, - "required": [ - "spec" - ], - "title": "NormalEnumTest", - "type": "object" - } - }, - "served": true, - "storage": true, - "subresources": {} - } - ] - } + "scope": "Cluster", + "versions": [ + { + "additionalPrinterColumns": [], + "name": "v1", + "schema": { + "openAPIV3Schema": { + "description": "Auto-generated derived type for NormalEnumTestSpec via `CustomResource`", + "properties": { + "spec": { + "properties": { + "foo": { + "description": "A very simple enum with empty variants", + "enum": [ + "C", + "D", + "A", + "B" + ], + "type": "string" + } + }, + "required": [ + "foo" + ], + "type": "object" + } + }, + "required": [ + "spec" + ], + "title": "NormalEnumTest", + "type": "object" + } + }, + "served": true, + "storage": true, + "subresources": {} + } + ] } + } ) ); } @@ -153,59 +159,61 @@ fn optional_enum() { assert_json_eq!( OptionalEnumTest::crd(), json!( - { - "apiVersion": "apiextensions.k8s.io/v1", - "kind": "CustomResourceDefinition", - "metadata": { - "name": "optionalenumtests.clux.dev" + { + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": { + "name": "optionalenumtests.clux.dev" + }, + "spec": { + "group": "clux.dev", + "names": { + "categories": [], + "kind": "OptionalEnumTest", + "plural": "optionalenumtests", + "shortNames": [], + "singular": "optionalenumtest" }, - "spec": { - "group": "clux.dev", - "names": { - "categories": [], - "kind": "OptionalEnumTest", - "plural": "optionalenumtests", - "shortNames": [], - "singular": "optionalenumtest" - }, - "scope": "Cluster", - "versions": [ - { - "additionalPrinterColumns": [], - "name": "v1", - "schema": { - "openAPIV3Schema": { - "description": "Auto-generated derived type for OptionalEnumTestSpec via `CustomResource`", - "properties": { - "spec": { - "properties": { - "foo": { - "description": "A very simple enum with empty variants", - "enum": [ - "A", - "B" - ], - "nullable": true, - "type": "string" - } - }, - "type": "object" - } - }, - "required": [ - "spec" - ], - "title": "OptionalEnumTest", - "type": "object" - } - }, - "served": true, - "storage": true, - "subresources": {} - } - ] - } + "scope": "Cluster", + "versions": [ + { + "additionalPrinterColumns": [], + "name": "v1", + "schema": { + "openAPIV3Schema": { + "description": "Auto-generated derived type for OptionalEnumTestSpec via `CustomResource`", + "properties": { + "spec": { + "properties": { + "foo": { + "description": "A very simple enum with empty variants", + "enum": [ + "C", + "D", + "A", + "B" + ], + "nullable": true, + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "spec" + ], + "title": "OptionalEnumTest", + "type": "object" + } + }, + "served": true, + "storage": true, + "subresources": {} + } + ] } + } ) ); } From 0cb62f1da0064044975a2e662509ea4126b733c4 Mon Sep 17 00:00:00 2001 From: Nick Larsen Date: Thu, 9 Oct 2025 15:32:23 +0200 Subject: [PATCH 04/11] test: Partial new impl for one_of hoisting --- kube-core/src/schema.rs | 109 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/kube-core/src/schema.rs b/kube-core/src/schema.rs index 479918a8e..c91ef5d61 100644 --- a/kube-core/src/schema.rs +++ b/kube-core/src/schema.rs @@ -8,7 +8,10 @@ use schemars::{transform::Transform, JsonSchema}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::{btree_map::Entry, BTreeMap, BTreeSet}; +use std::{ + collections::{btree_map::Entry, BTreeMap, BTreeSet}, + ops::{Deref, Not}, +}; /// schemars [`Visitor`] that rewrites a [`Schema`] to conform to Kubernetes' "structural schema" rules /// @@ -246,10 +249,114 @@ enum SingleOrVec { Vec(Vec), } +#[cfg(test)] +mod test { + use assert_json_diff::assert_json_eq; + use schemars::{json_schema, schema_for, schema_for_value, JsonSchema}; + use serde::{de::Expected, Deserialize, Serialize}; + use serde_json::json; + + /// A very simple enum with empty variants + #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] + enum NormalEnum { + /// First variant + A, + /// Second variant + B, + + // No doc-comments on these variants + C, + D, + } + + #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] + enum B {} + + #[test] + fn hoisting_a_schema() { + let incoming = json_schema!( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "A very simple enum with empty variants", + "oneOf": [ + { + "enum": [ + "C", + "D" + ], + "type": "string" + }, + { + "const": "A", + "description": "First variant", + "type": "string" + }, + { + "const": "B", + "description": "Second variant", + "type": "string" + } + ], + "title": "NormalEnum" + } + ); + + // Initial check that the text schema above is correct for NormalEnum + assert_json_eq!(schema_for!(NormalEnum), incoming); + + let expected = json_schema!( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "A very simple enum with empty variants", + "type": "string", + "enum": [ + "C", + "D", + "A", + "B" + ], + "title": "NormalEnum" + } + ); + + // let actual = hoist + + // assert_json_eq!(expected, actual); + } +} + +fn hoist_one_of_enum(incoming: Schema) -> Schema { + let Schema::Object(SchemaObject { + subschemas: Some(subschemas), + .. + }) = &incoming + else { + return incoming; + }; + + let SubschemaValidation { + one_of: Some(one_of), .. + } = subschemas.deref() + else { + return incoming; + }; + + if one_of.is_empty() { + return incoming; + } + + // now the meat. Need to get the oneOf variants up into `enum` + // panic if the types differ + + + todo!("finish it") +} + impl Transform for StructuralSchemaRewriter { fn transform(&mut self, transform_schema: &mut schemars::Schema) { schemars::transform::transform_subschemas(self, transform_schema); + // TODO (@NickLarsenNZ): Replace with conversion function let mut schema: SchemaObject = match serde_json::from_value(transform_schema.clone().to_value()).ok() { Some(schema) => schema, From 85eaf36d089b15665727d9b63d82472cd3597059 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 9 Oct 2025 16:42:28 +0200 Subject: [PATCH 05/11] Finish enum oneOf hoisting --- kube-core/src/schema.rs | 158 +++++++++++++++++++++++----------------- 1 file changed, 90 insertions(+), 68 deletions(-) diff --git a/kube-core/src/schema.rs b/kube-core/src/schema.rs index c91ef5d61..ab6c87e8b 100644 --- a/kube-core/src/schema.rs +++ b/kube-core/src/schema.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{ collections::{btree_map::Entry, BTreeMap, BTreeSet}, - ops::{Deref, Not}, + }; /// schemars [`Visitor`] that rewrites a [`Schema`] to conform to Kubernetes' "structural schema" rules @@ -252,9 +252,10 @@ enum SingleOrVec { #[cfg(test)] mod test { use assert_json_diff::assert_json_eq; - use schemars::{json_schema, schema_for, schema_for_value, JsonSchema}; - use serde::{de::Expected, Deserialize, Serialize}; - use serde_json::json; + use schemars::{json_schema, schema_for, JsonSchema}; + use serde::{Deserialize, Serialize}; + + use super::*; /// A very simple enum with empty variants #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] @@ -274,82 +275,103 @@ mod test { #[test] fn hoisting_a_schema() { - let incoming = json_schema!( - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "A very simple enum with empty variants", - "oneOf": [ - { - "enum": [ - "C", - "D" + let schemars_schema = schema_for!(NormalEnum); + let mut kube_schema: crate::schema::Schema = + schemars_schema_to_kube_schema(schemars_schema.clone()).unwrap(); + + assert_json_eq!( + schemars_schema, + json_schema!( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "A very simple enum with empty variants", + "oneOf": [ + { + "enum": [ + "C", + "D" + ], + "type": "string" + }, + { + "const": "A", + "description": "First variant", + "type": "string" + }, + { + "const": "B", + "description": "Second variant", + "type": "string" + } ], - "type": "string" - }, - { - "const": "A", - "description": "First variant", - "type": "string" - }, - { - "const": "B", - "description": "Second variant", - "type": "string" + "title": "NormalEnum" } - ], - "title": "NormalEnum" - } + ) ); - - // Initial check that the text schema above is correct for NormalEnum - assert_json_eq!(schema_for!(NormalEnum), incoming); - - let expected = json_schema!( - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "A very simple enum with empty variants", - "type": "string", - "enum": [ - "C", - "D", - "A", - "B" - ], - "title": "NormalEnum" - } + hoist_one_of_enum(&mut kube_schema); + assert_json_eq!( + kube_schema, + json_schema!( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "A very simple enum with empty variants", + "type": "string", + "enum": [ + "C", + "D", + "A", + "B" + ], + "title": "NormalEnum" + } + ) ); - - // let actual = hoist - - // assert_json_eq!(expected, actual); } } -fn hoist_one_of_enum(incoming: Schema) -> Schema { - let Schema::Object(SchemaObject { +#[cfg(test)] +fn schemars_schema_to_kube_schema(incoming: schemars::Schema) -> Result { + serde_json::from_value(incoming.to_value()) +} + +#[cfg(test)] +fn hoist_one_of_enum(schema: &mut Schema) { + if let Schema::Object(SchemaObject { subschemas: Some(subschemas), + instance_type: object_type, + enum_values: object_ebum, .. - }) = &incoming - else { - return incoming; - }; - - let SubschemaValidation { + }) = schema && let SubschemaValidation { one_of: Some(one_of), .. - } = subschemas.deref() - else { - return incoming; - }; - - if one_of.is_empty() { - return incoming; - } - - // now the meat. Need to get the oneOf variants up into `enum` - // panic if the types differ + } = &**subschemas + && !one_of.is_empty() + { + let mut types = one_of.iter().map(|obj| match obj { + Schema::Object(SchemaObject { instance_type: Some(type_), ..}) => type_, + Schema::Object(_) => panic!("oneOf variants need to define a type!: {obj:?}"), + Schema::Bool(_) => panic!("oneOf variants can not be of type boolean"), + }); + let enums = one_of.iter().flat_map(|obj| match obj { + Schema::Object(SchemaObject {enum_values: Some(enum_), ..}) => enum_.clone(), + Schema::Object(SchemaObject {other, ..})=> match other.get("const") { + Some(const_) => vec![const_.clone()], + None => panic!("oneOf variant did not provide \"enum\" or \"const\": {obj:?}"), + }, + Schema::Bool(_) => panic!("oneOf variants can not be of type boolean"), + }); + + let first_type = types + .next() + .expect("oneOf must have at least one variant - we already checked that"); + if types.any(|t| t != first_type) { + panic!("All oneOf variants must have the same type"); + } + *object_type = Some(first_type.clone()); + *object_ebum = Some(enums.collect()); + subschemas.one_of = None; - todo!("finish it") + } } impl Transform for StructuralSchemaRewriter { From 285ab646422f6dc0cc3d5ff8051b7c78a71dbb6e Mon Sep 17 00:00:00 2001 From: Nick Larsen Date: Fri, 10 Oct 2025 13:02:24 +0200 Subject: [PATCH 06/11] adjust and document the hoist_one_of_enum function Note: As described on the function, it is overly documented to express intent where it can be a little unclear. Ideally where would be some clearer official Kubernetes documentation on the differences between regular OpenAPI 3 schemas, and what Kubernetes accepts. --- kube-core/src/schema.rs | 109 +++++++++++++++++++++++++++++----------- 1 file changed, 81 insertions(+), 28 deletions(-) diff --git a/kube-core/src/schema.rs b/kube-core/src/schema.rs index ab6c87e8b..8c2affd8d 100644 --- a/kube-core/src/schema.rs +++ b/kube-core/src/schema.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{ collections::{btree_map::Entry, BTreeMap, BTreeSet}, - + ops::Deref as _, }; /// schemars [`Visitor`] that rewrites a [`Schema`] to conform to Kubernetes' "structural schema" rules @@ -252,7 +252,7 @@ enum SingleOrVec { #[cfg(test)] mod test { use assert_json_diff::assert_json_eq; - use schemars::{json_schema, schema_for, JsonSchema}; + use schemars::{json_schema, schema_for, JsonSchema}; use serde::{Deserialize, Serialize}; use super::*; @@ -276,8 +276,6 @@ mod test { #[test] fn hoisting_a_schema() { let schemars_schema = schema_for!(NormalEnum); - let mut kube_schema: crate::schema::Schema = - schemars_schema_to_kube_schema(schemars_schema.clone()).unwrap(); assert_json_eq!( schemars_schema, @@ -308,9 +306,14 @@ mod test { } ) ); - hoist_one_of_enum(&mut kube_schema); + + + let kube_schema: crate::schema::Schema = + schemars_schema_to_kube_schema(schemars_schema.clone()).unwrap(); + + let hoisted_kube_schema = hoist_one_of_enum(kube_schema); assert_json_eq!( - kube_schema, + hoisted_kube_schema, json_schema!( { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -334,44 +337,94 @@ fn schemars_schema_to_kube_schema(incoming: schemars::Schema) -> Result Schema { + // Run some initial checks in case there is nothing to do + let Schema::Object(SchemaObject { subschemas: Some(subschemas), - instance_type: object_type, - enum_values: object_ebum, .. - }) = schema && let SubschemaValidation { + }) = &incoming + else { + return incoming; + }; + + let SubschemaValidation { one_of: Some(one_of), .. - } = &**subschemas - && !one_of.is_empty() + } = subschemas.deref() + else { + return incoming; + }; + + if one_of.is_empty() { + return incoming; + } + + // At this point, we need to create a new Schema and hoist the `oneOf` + // variants' `enum`/`const` values up into a parent `enum`. + let mut new_schema = incoming.clone(); + if let Schema::Object(SchemaObject { + subschemas: Some(new_subschemas), + instance_type: new_instance_type, + enum_values: new_enum_values, + .. + }) = &mut new_schema { + // For each `oneOf`, get the `type`. + // Panic if it has no `type`, or if the entry is a boolean. let mut types = one_of.iter().map(|obj| match obj { - Schema::Object(SchemaObject { instance_type: Some(type_), ..}) => type_, + Schema::Object(SchemaObject { + instance_type: Some(r#type), + .. + }) => r#type, + // TODO (@NickLarsenNZ): Is it correct that JSON Schema oneOf must have a type? Schema::Object(_) => panic!("oneOf variants need to define a type!: {obj:?}"), Schema::Bool(_) => panic!("oneOf variants can not be of type boolean"), }); - let enums = one_of.iter().flat_map(|obj| match obj { - Schema::Object(SchemaObject {enum_values: Some(enum_), ..}) => enum_.clone(), - Schema::Object(SchemaObject {other, ..})=> match other.get("const") { - Some(const_) => vec![const_.clone()], - None => panic!("oneOf variant did not provide \"enum\" or \"const\": {obj:?}"), - }, - Schema::Bool(_) => panic!("oneOf variants can not be of type boolean"), - }); - let first_type = types + // Get the first `type` value, then panic if any subsequent `type` values differ. + let hoisted_instance_type = types .next() .expect("oneOf must have at least one variant - we already checked that"); - if types.any(|t| t != first_type) { + // TODO (@NickLarsenNZ): Didn't sbernauer say that the types + if types.any(|t| t != hoisted_instance_type) { panic!("All oneOf variants must have the same type"); } - *object_type = Some(first_type.clone()); - *object_ebum = Some(enums.collect()); - subschemas.one_of = None; + *new_instance_type = Some(hoisted_instance_type.clone()); + + // For each `oneOf` entry, iterate over the `enum` and `const` values. + // Panic on an entry that doesn't contain an `enum` or `const`. + let new_enums = one_of.iter().flat_map(|obj| match obj { + Schema::Object(SchemaObject { + enum_values: Some(r#enum), + .. + }) => r#enum.clone(), + // Warning: The `const` check below must come after the enum check above. + // Otherwise it will panic on a valid entry with an `enum`. + Schema::Object(SchemaObject { other, .. }) => match other.get("const") { + Some(r#const) => vec![r#const.clone()], + None => panic!("oneOf variant did not provide \"enum\" or \"const\": {obj:?}"), + }, + Schema::Bool(_) => panic!("oneOf variants can not be of type boolean"), + }); + + // Just in case there were existing enum values, add to them. + // TODO (@NickLarsenNZ): Check if `oneOf` and `enum` are mutually exclusive for a valid spec. + new_enum_values.get_or_insert_default().extend(new_enums); + // We can clear out the existing oneOf's, since they will be hoisted below. + new_subschemas.one_of = None; } + + new_schema } impl Transform for StructuralSchemaRewriter { From e256f3ab553bd56b2013486de676121651c13757 Mon Sep 17 00:00:00 2001 From: Nick Larsen Date: Fri, 10 Oct 2025 14:05:58 +0200 Subject: [PATCH 07/11] test: Add more basic testing to ensure hoisting is not performed when there are no oneOfs at the top level of the schema --- kube-core/src/schema.rs | 56 +++++++++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/kube-core/src/schema.rs b/kube-core/src/schema.rs index 8c2affd8d..b738323be 100644 --- a/kube-core/src/schema.rs +++ b/kube-core/src/schema.rs @@ -257,7 +257,14 @@ mod test { use super::*; - /// A very simple enum with empty variants + /// A very simple enum with unit variants, and no comments + #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] + enum NormalEnumNoComments { + A, + B, + } + + /// A very simple enum with unit variants, and comments #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] enum NormalEnum { /// First variant @@ -270,19 +277,49 @@ mod test { D, } - #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] - enum B {} + #[test] + fn schema_for_enum_without_comments() { + let schemars_schema = schema_for!(NormalEnumNoComments); + + assert_json_eq!( + schemars_schema, + // replace the json_schema with this to get the full output. + // serde_json::json!(42) + json_schema!( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "A very simple enum with unit variants, and no comments", + "enum": [ + "A", + "B" + ], + "title": "NormalEnumNoComments", + "type": "string" + } + ) + ); + + let kube_schema: crate::schema::Schema = + schemars_schema_to_kube_schema(schemars_schema.clone()).unwrap(); + + let hoisted_kube_schema = hoist_one_of_enum(kube_schema.clone()); + + // No hoisting needed + assert_json_eq!(hoisted_kube_schema, kube_schema); + } #[test] - fn hoisting_a_schema() { + fn schema_for_enum_with_comments() { let schemars_schema = schema_for!(NormalEnum); assert_json_eq!( schemars_schema, + // replace the json_schema with this to get the full output. + // serde_json::json!(42) json_schema!( { "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "A very simple enum with empty variants", + "description": "A very simple enum with unit variants, and comments", "oneOf": [ { "enum": [ @@ -311,13 +348,18 @@ mod test { let kube_schema: crate::schema::Schema = schemars_schema_to_kube_schema(schemars_schema.clone()).unwrap(); - let hoisted_kube_schema = hoist_one_of_enum(kube_schema); + let hoisted_kube_schema = hoist_one_of_enum(kube_schema.clone()); + + assert_ne!( + hoisted_kube_schema, kube_schema, + "Hoisting was performed, so hoisted_kube_schema != kube_schema" + ); assert_json_eq!( hoisted_kube_schema, json_schema!( { "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "A very simple enum with empty variants", + "description": "A very simple enum with unit variants, and comments", "type": "string", "enum": [ "C", From 35034c5c9a35a3cddb1f960e8af305ec1d672179 Mon Sep 17 00:00:00 2001 From: Nick Larsen Date: Fri, 10 Oct 2025 15:08:47 +0200 Subject: [PATCH 08/11] hoist anyOf optional enums --- kube-core/src/schema.rs | 338 ++++++++++++++++++++++++---------------- 1 file changed, 205 insertions(+), 133 deletions(-) diff --git a/kube-core/src/schema.rs b/kube-core/src/schema.rs index b738323be..db0d534c1 100644 --- a/kube-core/src/schema.rs +++ b/kube-core/src/schema.rs @@ -7,7 +7,7 @@ use schemars::{transform::Transform, JsonSchema}; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{json, Value}; use std::{ collections::{btree_map::Entry, BTreeMap, BTreeSet}, ops::Deref as _, @@ -249,130 +249,130 @@ enum SingleOrVec { Vec(Vec), } -#[cfg(test)] -mod test { - use assert_json_diff::assert_json_eq; - use schemars::{json_schema, schema_for, JsonSchema}; - use serde::{Deserialize, Serialize}; - - use super::*; - - /// A very simple enum with unit variants, and no comments - #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] - enum NormalEnumNoComments { - A, - B, - } - - /// A very simple enum with unit variants, and comments - #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] - enum NormalEnum { - /// First variant - A, - /// Second variant - B, - - // No doc-comments on these variants - C, - D, - } - - #[test] - fn schema_for_enum_without_comments() { - let schemars_schema = schema_for!(NormalEnumNoComments); - - assert_json_eq!( - schemars_schema, - // replace the json_schema with this to get the full output. - // serde_json::json!(42) - json_schema!( - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "A very simple enum with unit variants, and no comments", - "enum": [ - "A", - "B" - ], - "title": "NormalEnumNoComments", - "type": "string" - } - ) - ); - - let kube_schema: crate::schema::Schema = - schemars_schema_to_kube_schema(schemars_schema.clone()).unwrap(); - - let hoisted_kube_schema = hoist_one_of_enum(kube_schema.clone()); - - // No hoisting needed - assert_json_eq!(hoisted_kube_schema, kube_schema); - } - - #[test] - fn schema_for_enum_with_comments() { - let schemars_schema = schema_for!(NormalEnum); - - assert_json_eq!( - schemars_schema, - // replace the json_schema with this to get the full output. - // serde_json::json!(42) - json_schema!( - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "A very simple enum with unit variants, and comments", - "oneOf": [ - { - "enum": [ - "C", - "D" - ], - "type": "string" - }, - { - "const": "A", - "description": "First variant", - "type": "string" - }, - { - "const": "B", - "description": "Second variant", - "type": "string" - } - ], - "title": "NormalEnum" - } - ) - ); - - - let kube_schema: crate::schema::Schema = - schemars_schema_to_kube_schema(schemars_schema.clone()).unwrap(); - - let hoisted_kube_schema = hoist_one_of_enum(kube_schema.clone()); - - assert_ne!( - hoisted_kube_schema, kube_schema, - "Hoisting was performed, so hoisted_kube_schema != kube_schema" - ); - assert_json_eq!( - hoisted_kube_schema, - json_schema!( - { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "A very simple enum with unit variants, and comments", - "type": "string", - "enum": [ - "C", - "D", - "A", - "B" - ], - "title": "NormalEnum" - } - ) - ); - } -} +// #[cfg(test)] +// mod test { +// use assert_json_diff::assert_json_eq; +// use schemars::{json_schema, schema_for, JsonSchema}; +// use serde::{Deserialize, Serialize}; + +// use super::*; + +// /// A very simple enum with unit variants, and no comments +// #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] +// enum NormalEnumNoComments { +// A, +// B, +// } + +// /// A very simple enum with unit variants, and comments +// #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] +// enum NormalEnum { +// /// First variant +// A, +// /// Second variant +// B, + +// // No doc-comments on these variants +// C, +// D, +// } + +// #[test] +// fn schema_for_enum_without_comments() { +// let schemars_schema = schema_for!(NormalEnumNoComments); + +// assert_json_eq!( +// schemars_schema, +// // replace the json_schema with this to get the full output. +// // serde_json::json!(42) +// json_schema!( +// { +// "$schema": "https://json-schema.org/draft/2020-12/schema", +// "description": "A very simple enum with unit variants, and no comments", +// "enum": [ +// "A", +// "B" +// ], +// "title": "NormalEnumNoComments", +// "type": "string" +// } +// ) +// ); + +// let kube_schema: crate::schema::Schema = +// schemars_schema_to_kube_schema(schemars_schema.clone()).unwrap(); + +// let hoisted_kube_schema = hoist_one_of_enum(kube_schema.clone()); + +// // No hoisting needed +// assert_json_eq!(hoisted_kube_schema, kube_schema); +// } + +// #[test] +// fn schema_for_enum_with_comments() { +// let schemars_schema = schema_for!(NormalEnum); + +// assert_json_eq!( +// schemars_schema, +// // replace the json_schema with this to get the full output. +// // serde_json::json!(42) +// json_schema!( +// { +// "$schema": "https://json-schema.org/draft/2020-12/schema", +// "description": "A very simple enum with unit variants, and comments", +// "oneOf": [ +// { +// "enum": [ +// "C", +// "D" +// ], +// "type": "string" +// }, +// { +// "const": "A", +// "description": "First variant", +// "type": "string" +// }, +// { +// "const": "B", +// "description": "Second variant", +// "type": "string" +// } +// ], +// "title": "NormalEnum" +// } +// ) +// ); + + +// let kube_schema: crate::schema::Schema = +// schemars_schema_to_kube_schema(schemars_schema.clone()).unwrap(); + +// let hoisted_kube_schema = hoist_one_of_enum(kube_schema.clone()); + +// assert_ne!( +// hoisted_kube_schema, kube_schema, +// "Hoisting was performed, so hoisted_kube_schema != kube_schema" +// ); +// assert_json_eq!( +// hoisted_kube_schema, +// json_schema!( +// { +// "$schema": "https://json-schema.org/draft/2020-12/schema", +// "description": "A very simple enum with unit variants, and comments", +// "type": "string", +// "enum": [ +// "C", +// "D", +// "A", +// "B" +// ], +// "title": "NormalEnum" +// } +// ) +// ); +// } +// } #[cfg(test)] fn schemars_schema_to_kube_schema(incoming: schemars::Schema) -> Result { @@ -388,12 +388,12 @@ fn schemars_schema_to_kube_schema(incoming: schemars::Schema) -> Result Schema { +fn hoist_one_of_enum(incoming: SchemaObject) -> SchemaObject { // Run some initial checks in case there is nothing to do - let Schema::Object(SchemaObject { + let SchemaObject { subschemas: Some(subschemas), .. - }) = &incoming + } = &incoming else { return incoming; }; @@ -412,12 +412,12 @@ fn hoist_one_of_enum(incoming: Schema) -> Schema { // At this point, we need to create a new Schema and hoist the `oneOf` // variants' `enum`/`const` values up into a parent `enum`. let mut new_schema = incoming.clone(); - if let Schema::Object(SchemaObject { + if let SchemaObject { subschemas: Some(new_subschemas), instance_type: new_instance_type, enum_values: new_enum_values, .. - }) = &mut new_schema + } = &mut new_schema { // For each `oneOf`, get the `type`. // Panic if it has no `type`, or if the entry is a boolean. @@ -469,17 +469,89 @@ fn hoist_one_of_enum(incoming: Schema) -> Schema { new_schema } +// if anyOf with 2 entries, and one is nullable with enum that is [null], +// then hoist nullable, description, type, enum from the other entry. +// set anyOf to None +fn hoist_any_of_option_enum(incoming: SchemaObject) -> SchemaObject { + // Run some initial checks in case there is nothing to do + let SchemaObject { + subschemas: Some(subschemas), + .. + } = &incoming + else { + return incoming; + }; + + let SubschemaValidation { + any_of: Some(any_of), .. + } = subschemas.deref() + else { + return incoming; + }; + + if any_of.len() != 2 { + return incoming; + }; + + // This is the signature of an Optional enum that needs hoisting + let null = json!({ + "enum": [null], + "nullable": true + + }); + + // iter through any_of for matching null + let results: [bool; 2] = any_of + .iter() + .map(|x| serde_json::to_value(x).expect("schema should be able to convert to JSON")) + .map(|x| x == null) + .collect::>() + .try_into() + .expect("there should be exactly 2 elements. We checked earlier"); + + let to_hoist = match results { + [true, true] => panic!("Too many nulls, not enough drinks"), + [true, false] => &any_of[1], + [false, true] => &any_of[0], + [false, false] => return incoming, + }; + + // my goodness! + let Schema::Object(to_hoist) = to_hoist else { + panic!("Somehow we have stumbled across a bool schema"); + }; + + let mut new_schema = incoming.clone(); + + let mut new_metadata = incoming.metadata.clone().unwrap_or_default(); + new_metadata.description = to_hoist.metadata.as_ref().and_then(|m| m.description.clone()); + + new_schema.metadata = Some(new_metadata); + new_schema.instance_type = to_hoist.instance_type.clone(); + new_schema.enum_values = to_hoist.enum_values.clone(); + new_schema.other["nullable"] = true.into(); + + new_schema + .subschemas + .as_mut() + .expect("we have asserted that there is any_of") + .any_of = None; + + new_schema +} + + impl Transform for StructuralSchemaRewriter { fn transform(&mut self, transform_schema: &mut schemars::Schema) { schemars::transform::transform_subschemas(self, transform_schema); // TODO (@NickLarsenNZ): Replace with conversion function - let mut schema: SchemaObject = match serde_json::from_value(transform_schema.clone().to_value()).ok() - { + let schema: SchemaObject = match serde_json::from_value(transform_schema.clone().to_value()).ok() { Some(schema) => schema, None => return, }; - + let schema = hoist_one_of_enum(schema); + let mut schema = hoist_any_of_option_enum(schema); if let Some(subschemas) = &mut schema.subschemas { if let Some(one_of) = subschemas.one_of.as_mut() { // Tagged enums are serialized using `one_of` From 3850a1937347047f470883dfe80e4951fd8529fb Mon Sep 17 00:00:00 2001 From: Nick Larsen Date: Fri, 10 Oct 2025 15:12:21 +0200 Subject: [PATCH 09/11] left a todo for empty structural variants in untagged enums --- kube-core/src/schema.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/kube-core/src/schema.rs b/kube-core/src/schema.rs index db0d534c1..7a1d1d155 100644 --- a/kube-core/src/schema.rs +++ b/kube-core/src/schema.rs @@ -551,7 +551,9 @@ impl Transform for StructuralSchemaRewriter { None => return, }; let schema = hoist_one_of_enum(schema); - let mut schema = hoist_any_of_option_enum(schema); + let schema = hoist_any_of_option_enum(schema); + // todo: let schema = strip_any_of_empty_object_entry(schema); + let mut schema = schema; if let Some(subschemas) = &mut schema.subschemas { if let Some(one_of) = subschemas.one_of.as_mut() { // Tagged enums are serialized using `one_of` From c2fbe7085f76b21fb0743f51c241a8de8a59fccd Mon Sep 17 00:00:00 2001 From: Nick Larsen Date: Fri, 10 Oct 2025 15:24:10 +0200 Subject: [PATCH 10/11] test: Add missing enum entries to expected schemas --- kube-derive/tests/crd_complex_enum_tests.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/kube-derive/tests/crd_complex_enum_tests.rs b/kube-derive/tests/crd_complex_enum_tests.rs index 4520fa6ef..a25f01cad 100644 --- a/kube-derive/tests/crd_complex_enum_tests.rs +++ b/kube-derive/tests/crd_complex_enum_tests.rs @@ -272,6 +272,8 @@ fn untagged_enum() { "two": { "description": "Used in case the `two` field of type [`NormalEnum`] is present", "enum": [ + "C", + "D", "A", "B" ], @@ -360,6 +362,8 @@ fn optional_untagged_enum() { "two": { "description": "Used in case the `two` field of type [`NormalEnum`] is present", "enum": [ + "C", + "D", "A", "B" ], @@ -444,6 +448,8 @@ fn flattened_untagged_enum() { "two": { "description": "Used in case the `two` field of type [`NormalEnum`] is present", "enum": [ + "C", + "D", "A", "B" ], From 8abef367d4a6c436fc65101a2de8a96142e91da6 Mon Sep 17 00:00:00 2001 From: Nick Larsen Date: Fri, 10 Oct 2025 15:26:05 +0200 Subject: [PATCH 11/11] test: Add empty object handling to existing hoisting function Note: At this point, there is some strange interaction with the new hoisting functions and the existing code whereby "properties" disappear. --- kube-core/src/schema.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/kube-core/src/schema.rs b/kube-core/src/schema.rs index 7a1d1d155..c426da3b4 100644 --- a/kube-core/src/schema.rs +++ b/kube-core/src/schema.rs @@ -497,7 +497,6 @@ fn hoist_any_of_option_enum(incoming: SchemaObject) -> SchemaObject { let null = json!({ "enum": [null], "nullable": true - }); // iter through any_of for matching null @@ -692,6 +691,17 @@ fn hoist_subschema_properties( variant_obj.additional_properties = None; merge_metadata(instance_type, variant_type.take()); + } else if let Schema::Object(SchemaObject { + object: None, + instance_type: variant_type, + metadata, + .. + }) = variant + { + if *variant_type == Some(SingleOrVec::Single(Box::new(InstanceType::Object))) { + *variant_type = None; + *metadata = None; + } } } }