Skip to content

Commit e4e239b

Browse files
Transform optional enums to match pre kube 2.0.0 format
Signed-off-by: Danil-Grigorev <[email protected]>
1 parent a12559d commit e4e239b

File tree

3 files changed

+91
-2
lines changed

3 files changed

+91
-2
lines changed

kube-core/src/schema.rs

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
// Used in docs
66
#[allow(unused_imports)] use schemars::generate::SchemaSettings;
77

8-
use schemars::{transform::Transform, JsonSchema};
8+
use schemars::{
9+
transform::{transform_subschemas, Transform},
10+
JsonSchema,
11+
};
912
use serde::{Deserialize, Serialize};
10-
use serde_json::Value;
13+
use serde_json::{json, Value};
1114
use std::collections::{btree_map::Entry, BTreeMap, BTreeSet};
1215

1316
/// 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};
2730
#[derive(Debug, Clone)]
2831
pub struct StructuralSchemaRewriter;
2932

33+
/// Recursively restructures JSON Schema objects so that the Option<Enum> object
34+
/// is returned per k8s CRD schema expectations.
35+
///
36+
/// In kube 2.x the schema output behavior for `Option<Enum>` types changed.
37+
///
38+
/// Previously given an enum like:
39+
///
40+
/// ```rust
41+
/// enum LogLevel {
42+
/// Debug,
43+
/// Info,
44+
/// Error,
45+
/// }
46+
/// ```
47+
///
48+
/// The following would be generated for Optional<LogLevel>:
49+
///
50+
/// ```json
51+
/// { "enum": ["Debug", "Info", "Error"], "type": "string", "nullable": true }
52+
/// ```
53+
///
54+
/// Now, schemars generates `anyOf` for `Option<LogLevel>` like:
55+
///
56+
/// ```json
57+
/// {
58+
/// "anyOf": [
59+
/// { "enum": ["Debug", "Info", "Error"], "type": "string" },
60+
/// { "enum": [null], "nullable": true }
61+
/// ]
62+
/// }
63+
/// ```
64+
///
65+
/// This transform implementation prevents this specific case from happening.
66+
#[derive(Debug, Clone, Default)]
67+
pub struct OptionalEnum;
68+
3069
/// A JSON Schema.
3170
#[allow(clippy::large_enum_variant)]
3271
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
@@ -305,6 +344,41 @@ impl Transform for StructuralSchemaRewriter {
305344
}
306345
}
307346

347+
impl Transform for OptionalEnum {
348+
fn transform(&mut self, schema: &mut schemars::Schema) {
349+
transform_subschemas(self, schema);
350+
351+
let Some(obj) = schema.as_object_mut().filter(|o| o.len() == 1) else {
352+
return;
353+
};
354+
355+
let arr = obj
356+
.get("anyOf")
357+
.iter()
358+
.flat_map(|any_of| any_of.as_array())
359+
.last()
360+
.cloned()
361+
.unwrap_or_default();
362+
363+
let [first, second] = arr.as_slice() else {
364+
return;
365+
};
366+
let (Some(first), Some(second)) = (first.as_object(), second.as_object()) else {
367+
return;
368+
};
369+
370+
if first.contains_key("enum")
371+
&& !first.contains_key("nullable")
372+
&& second.get("enum") == Some(&json!([null]))
373+
&& second.get("nullable") == Some(&json!(true))
374+
{
375+
obj.remove("anyOf");
376+
obj.append(&mut first.clone());
377+
obj.insert("nullable".to_string(), Value::Bool(true));
378+
}
379+
}
380+
}
381+
308382
/// Bring all plain enum values up to the root schema,
309383
/// since Kubernetes doesn't allow subschemas to define enum options.
310384
///

kube-derive/src/custom_resource.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,7 @@ pub(crate) fn derive(input: proc_macro2::TokenStream) -> proc_macro2::TokenStrea
677677
})
678678
.with_transform(#schemars::transform::AddNullable::default())
679679
.with_transform(#kube_core::schema::StructuralSchemaRewriter)
680+
.with_transform(#kube_core::schema::OptionalEnum)
680681
.into_generator();
681682
let schema = generate.into_root_schema_for::<Self>();
682683
}

kube-derive/tests/crd_schema_test.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use schemars::JsonSchema;
99
use serde::{Deserialize, Serialize};
1010
use std::collections::{HashMap, HashSet};
1111

12+
1213
// See `crd_derive_schema` example for how the schema generated from this struct affects defaulting and validation.
1314
#[derive(CustomResource, Serialize, Deserialize, Debug, PartialEq, Clone, KubeSchema)]
1415
#[kube(
@@ -77,6 +78,8 @@ struct FooSpec {
7778

7879
#[x_kube(merge_strategy = ListMerge::Set)]
7980
x_kubernetes_set: Vec<String>,
81+
82+
optional_enum: Option<Gender>,
8083
}
8184

8285
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, JsonSchema)]
@@ -187,6 +190,7 @@ fn test_serialized_matches_expected() {
187190
my_list: vec!["".into()],
188191
set: HashSet::from(["foo".to_owned()]),
189192
x_kubernetes_set: vec![],
193+
optional_enum: Some(Gender::Other),
190194
}))
191195
.unwrap(),
192196
serde_json::json!({
@@ -222,6 +226,7 @@ fn test_serialized_matches_expected() {
222226
"myList": [""],
223227
"set": ["foo"],
224228
"xKubernetesSet": [],
229+
"optionalEnum": "Other",
225230
}
226231
})
227232
)
@@ -410,6 +415,15 @@ fn test_crd_schema_matches_expected() {
410415
},
411416
"x-kubernetes-list-type": "set",
412417
},
418+
"optionalEnum": {
419+
"nullable": true,
420+
"type": "string",
421+
"enum": [
422+
"Female",
423+
"Male",
424+
"Other"
425+
],
426+
}
413427
},
414428
"required": [
415429
"complexEnum",

0 commit comments

Comments
 (0)