diff --git a/kube-core/src/schema.rs b/kube-core/src/schema.rs index 479918a8e..6ea510653 100644 --- a/kube-core/src/schema.rs +++ b/kube-core/src/schema.rs @@ -5,9 +5,12 @@ // Used in docs #[allow(unused_imports)] use schemars::generate::SchemaSettings; -use schemars::{transform::Transform, JsonSchema}; +use schemars::{ + transform::{transform_subschemas, Transform}, + JsonSchema, +}; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{json, Value}; use std::collections::{btree_map::Entry, BTreeMap, BTreeSet}; /// schemars [`Visitor`] that rewrites a [`Schema`] to conform to Kubernetes' "structural schema" rules @@ -27,6 +30,42 @@ use std::collections::{btree_map::Entry, BTreeMap, BTreeSet}; #[derive(Debug, Clone)] pub struct StructuralSchemaRewriter; +/// Recursively restructures JSON Schema objects so that the Option object +/// is returned per k8s CRD schema expectations. +/// +/// In kube 2.x the schema output behavior for `Option` types changed. +/// +/// Previously given an enum like: +/// +/// ```rust +/// enum LogLevel { +/// Debug, +/// Info, +/// Error, +/// } +/// ``` +/// +/// The following would be generated for Optional: +/// +/// ```json +/// { "enum": ["Debug", "Info", "Error"], "type": "string", "nullable": true } +/// ``` +/// +/// Now, schemars generates `anyOf` for `Option` like: +/// +/// ```json +/// { +/// "anyOf": [ +/// { "enum": ["Debug", "Info", "Error"], "type": "string" }, +/// { "enum": [null], "nullable": true } +/// ] +/// } +/// ``` +/// +/// This transform implementation prevents this specific case from happening. +#[derive(Debug, Clone, Default)] +pub struct OptionalEnum; + /// A JSON Schema. #[allow(clippy::large_enum_variant)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] @@ -305,6 +344,41 @@ impl Transform for StructuralSchemaRewriter { } } +impl Transform for OptionalEnum { + fn transform(&mut self, schema: &mut schemars::Schema) { + transform_subschemas(self, schema); + + let Some(obj) = schema.as_object_mut().filter(|o| o.len() == 1) else { + return; + }; + + let arr = obj + .get("anyOf") + .iter() + .flat_map(|any_of| any_of.as_array()) + .last() + .cloned() + .unwrap_or_default(); + + let [first, second] = arr.as_slice() else { + return; + }; + let (Some(first), Some(second)) = (first.as_object(), second.as_object()) else { + return; + }; + + if first.contains_key("enum") + && !first.contains_key("nullable") + && second.get("enum") == Some(&json!([null])) + && second.get("nullable") == Some(&json!(true)) + { + obj.remove("anyOf"); + obj.append(&mut first.clone()); + obj.insert("nullable".to_string(), Value::Bool(true)); + } + } +} + /// Bring all plain enum values up to the root schema, /// since Kubernetes doesn't allow subschemas to define enum options. /// diff --git a/kube-derive/src/custom_resource.rs b/kube-derive/src/custom_resource.rs index 977ca712b..49d34cfa4 100644 --- a/kube-derive/src/custom_resource.rs +++ b/kube-derive/src/custom_resource.rs @@ -677,6 +677,7 @@ pub(crate) fn derive(input: proc_macro2::TokenStream) -> proc_macro2::TokenStrea }) .with_transform(#schemars::transform::AddNullable::default()) .with_transform(#kube_core::schema::StructuralSchemaRewriter) + .with_transform(#kube_core::schema::OptionalEnum) .into_generator(); let schema = generate.into_root_schema_for::(); } diff --git a/kube-derive/tests/crd_schema_test.rs b/kube-derive/tests/crd_schema_test.rs index c00009ff6..43b285c08 100644 --- a/kube-derive/tests/crd_schema_test.rs +++ b/kube-derive/tests/crd_schema_test.rs @@ -9,6 +9,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; + // See `crd_derive_schema` example for how the schema generated from this struct affects defaulting and validation. #[derive(CustomResource, Serialize, Deserialize, Debug, PartialEq, Clone, KubeSchema)] #[kube( @@ -77,6 +78,8 @@ struct FooSpec { #[x_kube(merge_strategy = ListMerge::Set)] x_kubernetes_set: Vec, + + optional_enum: Option, } #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, JsonSchema)] @@ -187,6 +190,7 @@ fn test_serialized_matches_expected() { my_list: vec!["".into()], set: HashSet::from(["foo".to_owned()]), x_kubernetes_set: vec![], + optional_enum: Some(Gender::Other), })) .unwrap(), serde_json::json!({ @@ -222,6 +226,7 @@ fn test_serialized_matches_expected() { "myList": [""], "set": ["foo"], "xKubernetesSet": [], + "optionalEnum": "Other", } }) ) @@ -410,6 +415,15 @@ fn test_crd_schema_matches_expected() { }, "x-kubernetes-list-type": "set", }, + "optionalEnum": { + "nullable": true, + "type": "string", + "enum": [ + "Female", + "Male", + "Other" + ], + } }, "required": [ "complexEnum",