diff --git a/kube-core/src/schema.rs b/kube-core/src/schema.rs index 7543d2ec8..845032ab2 100644 --- a/kube-core/src/schema.rs +++ b/kube-core/src/schema.rs @@ -8,7 +8,7 @@ use std::collections::btree_map::Entry; #[allow(unused_imports)] use schemars::gen::SchemaSettings; use schemars::{ - schema::{Metadata, ObjectValidation, Schema, SchemaObject}, + schema::{InstanceType, Metadata, ObjectValidation, Schema, SchemaObject, SingleOrVec}, visit::Visitor, }; @@ -78,21 +78,7 @@ impl Visitor for StructuralSchemaRewriter { // Kubernetes doesn't allow variants to set additionalProperties variant_obj.additional_properties = None; - // Try to merge metadata - match (&mut schema.instance_type, variant_type.take()) { - (_, None) => {} - (common_type @ None, variant_type) => { - *common_type = variant_type; - } - (Some(common_type), Some(variant_type)) => { - if *common_type != variant_type { - panic!( - "variant defined type {:?}, conflicting with existing type {:?}", - variant_type, common_type - ); - } - } - } + merge_metadata(&mut schema.instance_type, variant_type.take()); } } } @@ -108,6 +94,49 @@ impl Visitor for StructuralSchemaRewriter { .insert("x-kubernetes-preserve-unknown-fields".into(), true.into()); } } + + // Support untagged enums + if let Some(any_of) = schema + .subschemas + .as_mut() + .and_then(|subschemas| subschemas.any_of.as_mut()) + { + let common_obj = schema + .object + .get_or_insert_with(|| Box::new(ObjectValidation::default())); + + for variant in any_of { + if let Schema::Object(SchemaObject { + instance_type: variant_type, + object: Some(variant_obj), + .. + }) = variant + { + let variant_properties = std::mem::take(&mut variant_obj.properties); + + for (property_name, property) in variant_properties { + match common_obj.properties.entry(property_name) { + Entry::Occupied(entry) => { + if &property != entry.get() { + panic!("Property {:?} has the schema {:?} but was already defined as {:?} in another untagged enum variant. The schemas for a property used in multiple untagged enum variants must be identical", + entry.key(), + &property, + entry.get()); + } + } + Entry::Vacant(entry) => { + entry.insert(property); + } + } + + // Kubernetes doesn't allow variants to set additionalProperties + variant_obj.additional_properties = None; + + merge_metadata(&mut schema.instance_type, variant_type.take()); + } + } + } + } } } @@ -118,3 +147,23 @@ fn only_item(mut i: I) -> Option { } Some(item) } + +fn merge_metadata( + instance_type: &mut Option>, + variant_type: Option>, +) { + match (instance_type, variant_type) { + (_, None) => {} + (common_type @ None, variant_type) => { + *common_type = variant_type; + } + (Some(common_type), Some(variant_type)) => { + if *common_type != variant_type { + panic!( + "variant defined type {:?}, conflicting with existing type {:?}", + variant_type, common_type + ); + } + } + } +} diff --git a/kube-derive/tests/crd_schema_test.rs b/kube-derive/tests/crd_schema_test.rs index 40d4f8c63..5b8807bd8 100644 --- a/kube-derive/tests/crd_schema_test.rs +++ b/kube-derive/tests/crd_schema_test.rs @@ -1,3 +1,5 @@ +#![recursion_limit = "256"] + use chrono::{DateTime, NaiveDateTime, Utc}; use kube_derive::CustomResource; use schemars::JsonSchema; @@ -37,8 +39,11 @@ struct FooSpec { // Using feature `chrono` timestamp: DateTime, - /// This is a complex enum + /// This is a complex enum with a description complex_enum: ComplexEnum, + + /// This is a untagged enum with a description + untagged_enum_person: UntaggedEnumPerson, } fn default_value() -> String { @@ -69,6 +74,40 @@ enum ComplexEnum { VariantThree {}, } +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(untagged)] +enum UntaggedEnumPerson { + SexAndAge(SexAndAge), + SexAndDateOfBirth(SexAndDateOfBirth), +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, JsonSchema)] +#[serde(rename_all = "camelCase")] +struct SexAndAge { + /// Sex of the person + sex: Sex, + /// Age of the person in years + age: i32, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, JsonSchema)] +#[serde(rename_all = "camelCase")] +struct SexAndDateOfBirth { + /// Sex of the person + sex: Sex, + /// Date of birth of the person as ISO 8601 date + date_of_birth: String, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, JsonSchema)] +#[serde(rename_all = "PascalCase")] +enum Sex { + Female, + Male, + Other, +} + #[test] fn test_crd_name() { use kube::core::CustomResourceExt; @@ -93,6 +132,10 @@ fn test_serialized_matches_expected() { nullable_with_default: None, timestamp: DateTime::from_utc(NaiveDateTime::from_timestamp(0, 0), Utc), complex_enum: ComplexEnum::VariantOne { int: 23 }, + untagged_enum_person: UntaggedEnumPerson::SexAndAge(SexAndAge { + age: 42, + sex: Sex::Male, + }) })) .unwrap(), serde_json::json!({ @@ -111,6 +154,10 @@ fn test_serialized_matches_expected() { "variantOne": { "int": 23 } + }, + "untaggedEnumPerson": { + "age": 42, + "sex": "Male" } } }) @@ -220,13 +267,42 @@ fn test_crd_schema_matches_expected() { "required": ["variantThree"] } ], - "description": "This is a complex enum" + "description": "This is a complex enum with a description" + }, + "untaggedEnumPerson": { + "type": "object", + "properties": { + "age": { + "type": "integer", + "format": "int32", + "description": "Age of the person in years" + }, + "dateOfBirth": { + "type": "string", + "description": "Date of birth of the person as ISO 8601 date" + }, + "sex": { + "type": "string", + "enum": ["Female", "Male", "Other"], + "description": "Sex of the person" + } + }, + "anyOf": [ + { + "required": ["age", "sex"] + }, + { + "required": ["dateOfBirth", "sex"] + } + ], + "description": "This is a untagged enum with a description" } }, "required": [ "complexEnum", "nonNullable", - "timestamp" + "timestamp", + "untaggedEnumPerson" ], "type": "object" }