kube_core/schema.rs
1//! Utilities for managing [`CustomResourceDefinition`] schemas
2//!
3//! [`CustomResourceDefinition`]: `k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition`
4
5// Used in docs
6#[allow(unused_imports)] use schemars::generate::SchemaSettings;
7
8use schemars::{
9 JsonSchema,
10 transform::{Transform, transform_subschemas},
11};
12use serde::{Deserialize, Serialize};
13use serde_json::{Value, json};
14use std::collections::{BTreeMap, BTreeSet, btree_map::Entry};
15
16/// schemars [`Visitor`] that rewrites a [`Schema`] to conform to Kubernetes' "structural schema" rules
17///
18/// The following two transformations are applied
19/// * Rewrite enums from `oneOf` to `object`s with multiple variants ([schemars#84](https://github.com/GREsau/schemars/issues/84))
20/// * Rewrite untagged enums from `anyOf` to `object`s with multiple variants ([kube#1028](https://github.com/kube-rs/kube/pull/1028))
21/// * Rewrite `additionalProperties` from `#[serde(flatten)]` to `x-kubernetes-preserve-unknown-fields` ([kube#844](https://github.com/kube-rs/kube/issues/844))
22///
23/// This is used automatically by `kube::derive`'s `#[derive(CustomResource)]`,
24/// but it can also be used manually with [`SchemaSettings::with_transform`].
25///
26/// # Panics
27///
28/// The [`Visitor`] functions may panic if the transform could not be applied. For example,
29/// there must not be any overlapping properties between `oneOf` branches.
30#[derive(Debug, Clone)]
31pub struct StructuralSchemaRewriter;
32
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)]
67pub struct OptionalEnum;
68
69/// Recursively restructures JSON Schema objects so that the `Option<T>` object
70/// where `T` uses `x-kubernetes-int-or-string` is returned per k8s CRD schema expectations.
71///
72/// In kube 2.x with k8s-openapi 0.26.x, the schema output behavior for `Option<Quantity>`
73/// and similar `x-kubernetes-int-or-string` types changed.
74///
75/// Previously given an optional Quantity field:
76///
77/// ```json
78/// { "nullable": true, "type": "string" }
79/// ```
80///
81/// Now, schemars generates `anyOf` for `Option<Quantity>` like:
82///
83/// ```json
84/// {
85/// "anyOf": [
86/// { "x-kubernetes-int-or-string": true },
87/// { "enum": [null], "nullable": true }
88/// ]
89/// }
90/// ```
91///
92/// This transform converts it to:
93///
94/// ```json
95/// { "x-kubernetes-int-or-string": true, "nullable": true }
96/// ```
97#[derive(Debug, Clone, Default)]
98pub struct OptionalIntOrString;
99
100/// A JSON Schema.
101#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
102#[serde(untagged)]
103enum Schema {
104 /// A trivial boolean JSON Schema.
105 ///
106 /// The schema `true` matches everything (always passes validation), whereas the schema `false`
107 /// matches nothing (always fails validation).
108 Bool(bool),
109 /// A JSON Schema object.
110 Object(SchemaObject),
111}
112
113/// A JSON Schema object.
114#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
115#[serde(rename_all = "camelCase", default)]
116struct SchemaObject {
117 /// Properties which annotate the [`SchemaObject`] which typically have no effect when an object is being validated against the schema.
118 #[serde(flatten, deserialize_with = "skip_if_default")]
119 metadata: Option<Box<Metadata>>,
120 /// The `type` keyword.
121 ///
122 /// See [JSON Schema Validation 6.1.1. "type"](https://tools.ietf.org/html/draft-handrews-json-schema-validation-02#section-6.1.1)
123 /// and [JSON Schema 4.2.1. Instance Data Model](https://tools.ietf.org/html/draft-handrews-json-schema-02#section-4.2.1).
124 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
125 instance_type: Option<SingleOrVec<InstanceType>>,
126 /// The `format` keyword.
127 ///
128 /// See [JSON Schema Validation 7. A Vocabulary for Semantic Content With "format"](https://tools.ietf.org/html/draft-handrews-json-schema-validation-02#section-7).
129 #[serde(skip_serializing_if = "Option::is_none")]
130 format: Option<String>,
131 /// The `enum` keyword.
132 ///
133 /// See [JSON Schema Validation 6.1.2. "enum"](https://tools.ietf.org/html/draft-handrews-json-schema-validation-02#section-6.1.2)
134 #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
135 enum_values: Option<Vec<Value>>,
136 /// Properties of the [`SchemaObject`] which define validation assertions in terms of other schemas.
137 #[serde(flatten, deserialize_with = "skip_if_default")]
138 subschemas: Option<Box<SubschemaValidation>>,
139 /// Properties of the [`SchemaObject`] which define validation assertions for arrays.
140 #[serde(flatten, deserialize_with = "skip_if_default")]
141 array: Option<Box<ArrayValidation>>,
142 /// Properties of the [`SchemaObject`] which define validation assertions for objects.
143 #[serde(flatten, deserialize_with = "skip_if_default")]
144 object: Option<Box<ObjectValidation>>,
145 /// Arbitrary extra properties which are not part of the JSON Schema specification, or which `schemars` does not support.
146 #[serde(flatten)]
147 extensions: BTreeMap<String, Value>,
148 /// Arbitrary data.
149 #[serde(flatten)]
150 other: Value,
151}
152
153// Deserializing "null" to `Option<Value>` directly results in `None`,
154// this function instead makes it deserialize to `Some(Value::Null)`.
155fn allow_null<'de, D>(de: D) -> Result<Option<Value>, D::Error>
156where
157 D: serde::Deserializer<'de>,
158{
159 Value::deserialize(de).map(Option::Some)
160}
161
162fn skip_if_default<'de, D, T>(deserializer: D) -> Result<Option<Box<T>>, D::Error>
163where
164 D: serde::Deserializer<'de>,
165 T: Deserialize<'de> + Default + PartialEq,
166{
167 let value = T::deserialize(deserializer)?;
168 if value == T::default() {
169 Ok(None)
170 } else {
171 Ok(Some(Box::new(value)))
172 }
173}
174
175/// Properties which annotate a [`SchemaObject`] which typically have no effect when an object is being validated against the schema.
176#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
177#[serde(rename_all = "camelCase", default)]
178struct Metadata {
179 /// The `description` keyword.
180 ///
181 /// See [JSON Schema Validation 9.1. "title" and "description"](https://tools.ietf.org/html/draft-handrews-json-schema-validation-02#section-9.1).
182 #[serde(skip_serializing_if = "Option::is_none")]
183 description: Option<String>,
184 /// The `default` keyword.
185 ///
186 /// See [JSON Schema Validation 9.2. "default"](https://tools.ietf.org/html/draft-handrews-json-schema-validation-02#section-9.2).
187 #[serde(skip_serializing_if = "Option::is_none", deserialize_with = "allow_null")]
188 default: Option<Value>,
189}
190
191/// Properties of a [`SchemaObject`] which define validation assertions in terms of other schemas.
192#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
193#[serde(rename_all = "camelCase", default)]
194struct SubschemaValidation {
195 /// The `anyOf` keyword.
196 ///
197 /// See [JSON Schema 9.2.1.2. "anyOf"](https://tools.ietf.org/html/draft-handrews-json-schema-02#section-9.2.1.2).
198 #[serde(skip_serializing_if = "Option::is_none")]
199 any_of: Option<Vec<Schema>>,
200 /// The `oneOf` keyword.
201 ///
202 /// See [JSON Schema 9.2.1.3. "oneOf"](https://tools.ietf.org/html/draft-handrews-json-schema-02#section-9.2.1.3).
203 #[serde(skip_serializing_if = "Option::is_none")]
204 one_of: Option<Vec<Schema>>,
205}
206
207/// Properties of a [`SchemaObject`] which define validation assertions for arrays.
208#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
209#[serde(rename_all = "camelCase", default)]
210struct ArrayValidation {
211 /// The `items` keyword.
212 ///
213 /// See [JSON Schema 9.3.1.1. "items"](https://tools.ietf.org/html/draft-handrews-json-schema-02#section-9.3.1.1).
214 #[serde(skip_serializing_if = "Option::is_none")]
215 items: Option<SingleOrVec<Schema>>,
216 /// The `additionalItems` keyword.
217 ///
218 /// See [JSON Schema 9.3.1.2. "additionalItems"](https://tools.ietf.org/html/draft-handrews-json-schema-02#section-9.3.1.2).
219 #[serde(skip_serializing_if = "Option::is_none")]
220 additional_items: Option<Box<Schema>>,
221 /// The `maxItems` keyword.
222 ///
223 /// See [JSON Schema Validation 6.4.1. "maxItems"](https://tools.ietf.org/html/draft-handrews-json-schema-validation-02#section-6.4.1).
224 #[serde(skip_serializing_if = "Option::is_none")]
225 max_items: Option<u32>,
226 /// The `minItems` keyword.
227 ///
228 /// See [JSON Schema Validation 6.4.2. "minItems"](https://tools.ietf.org/html/draft-handrews-json-schema-validation-02#section-6.4.2).
229 #[serde(skip_serializing_if = "Option::is_none")]
230 min_items: Option<u32>,
231 /// The `uniqueItems` keyword.
232 ///
233 /// See [JSON Schema Validation 6.4.3. "uniqueItems"](https://tools.ietf.org/html/draft-handrews-json-schema-validation-02#section-6.4.3).
234 #[serde(skip_serializing_if = "Option::is_none")]
235 unique_items: Option<bool>,
236 /// The `contains` keyword.
237 ///
238 /// See [JSON Schema 9.3.1.4. "contains"](https://tools.ietf.org/html/draft-handrews-json-schema-02#section-9.3.1.4).
239 #[serde(skip_serializing_if = "Option::is_none")]
240 contains: Option<Box<Schema>>,
241}
242
243/// Properties of a [`SchemaObject`] which define validation assertions for objects.
244#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
245#[serde(rename_all = "camelCase", default)]
246struct ObjectValidation {
247 /// The `maxProperties` keyword.
248 ///
249 /// See [JSON Schema Validation 6.5.1. "maxProperties"](https://tools.ietf.org/html/draft-handrews-json-schema-validation-02#section-6.5.1).
250 #[serde(skip_serializing_if = "Option::is_none")]
251 max_properties: Option<u32>,
252 /// The `minProperties` keyword.
253 ///
254 /// See [JSON Schema Validation 6.5.2. "minProperties"](https://tools.ietf.org/html/draft-handrews-json-schema-validation-02#section-6.5.2).
255 #[serde(skip_serializing_if = "Option::is_none")]
256 min_properties: Option<u32>,
257 /// The `required` keyword.
258 ///
259 /// See [JSON Schema Validation 6.5.3. "required"](https://tools.ietf.org/html/draft-handrews-json-schema-validation-02#section-6.5.3).
260 #[serde(skip_serializing_if = "BTreeSet::is_empty")]
261 required: BTreeSet<String>,
262 /// The `properties` keyword.
263 ///
264 /// See [JSON Schema 9.3.2.1. "properties"](https://tools.ietf.org/html/draft-handrews-json-schema-02#section-9.3.2.1).
265 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
266 properties: BTreeMap<String, Schema>,
267 /// The `patternProperties` keyword.
268 ///
269 /// See [JSON Schema 9.3.2.2. "patternProperties"](https://tools.ietf.org/html/draft-handrews-json-schema-02#section-9.3.2.2).
270 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
271 pattern_properties: BTreeMap<String, Schema>,
272 /// The `additionalProperties` keyword.
273 ///
274 /// See [JSON Schema 9.3.2.3. "additionalProperties"](https://tools.ietf.org/html/draft-handrews-json-schema-02#section-9.3.2.3).
275 #[serde(skip_serializing_if = "Option::is_none")]
276 additional_properties: Option<Box<Schema>>,
277 /// The `propertyNames` keyword.
278 ///
279 /// See [JSON Schema 9.3.2.5. "propertyNames"](https://tools.ietf.org/html/draft-handrews-json-schema-02#section-9.3.2.5).
280 #[serde(skip_serializing_if = "Option::is_none")]
281 property_names: Option<Box<Schema>>,
282}
283
284/// The possible types of values in JSON Schema documents.
285///
286/// See [JSON Schema 4.2.1. Instance Data Model](https://tools.ietf.org/html/draft-handrews-json-schema-02#section-4.2.1).
287#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, JsonSchema)]
288#[serde(rename_all = "camelCase")]
289enum InstanceType {
290 /// Represents the JSON null type.
291 Null,
292 /// Represents the JSON boolean type.
293 Boolean,
294 /// Represents the JSON object type.
295 Object,
296 /// Represents the JSON array type.
297 Array,
298 /// Represents the JSON number type (floating point).
299 Number,
300 /// Represents the JSON string type.
301 String,
302 /// Represents the JSON integer type.
303 Integer,
304}
305
306/// A type which can be serialized as a single item, or multiple items.
307///
308/// In some contexts, a `Single` may be semantically distinct from a `Vec` containing only item.
309#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, JsonSchema)]
310#[serde(untagged)]
311enum SingleOrVec<T> {
312 /// Represents a single item.
313 Single(Box<T>),
314 /// Represents a vector of items.
315 Vec(Vec<T>),
316}
317
318impl Transform for StructuralSchemaRewriter {
319 fn transform(&mut self, transform_schema: &mut schemars::Schema) {
320 schemars::transform::transform_subschemas(self, transform_schema);
321
322 let mut schema: SchemaObject = match serde_json::from_value(transform_schema.clone().to_value()).ok()
323 {
324 Some(schema) => schema,
325 None => return,
326 };
327
328 if let Some(subschemas) = &mut schema.subschemas {
329 if let Some(one_of) = subschemas.one_of.as_mut() {
330 // Tagged enums are serialized using `one_of`
331 hoist_subschema_properties(one_of, &mut schema.object, &mut schema.instance_type, true);
332
333 // "Plain" enums are serialized using `one_of` if they have doc tags
334 hoist_subschema_enum_values(one_of, &mut schema.enum_values, &mut schema.instance_type);
335
336 if one_of.is_empty() {
337 subschemas.one_of = None;
338 }
339 }
340
341 if let Some(any_of) = &mut subschemas.any_of {
342 // Untagged enums are serialized using `any_of`
343 // Variant descriptions are not pushed into properties (because they are not for the field).
344 hoist_subschema_properties(any_of, &mut schema.object, &mut schema.instance_type, false);
345 }
346 }
347
348 if let Some(object) = &mut schema.object
349 && !object.properties.is_empty()
350 {
351 // check for maps without with properties (i.e. flattened maps)
352 // and allow these to persist dynamically
353 match object.additional_properties.as_deref() {
354 Some(&Schema::Bool(true)) => {
355 object.additional_properties = None;
356 schema
357 .extensions
358 .insert("x-kubernetes-preserve-unknown-fields".into(), true.into());
359 }
360 Some(&Schema::Bool(false)) => {
361 object.additional_properties = None;
362 }
363 _ => {}
364 }
365 }
366
367 // As of version 1.30 Kubernetes does not support setting `uniqueItems` to `true`,
368 // so we need to remove this fields.
369 // Users can still set `x-kubernetes-list-type=set` in case they want the apiserver
370 // to do validation, but we can't make an assumption about the Set contents here.
371 // See https://kubernetes.io/docs/reference/using-api/server-side-apply/ for details.
372 if let Some(array) = &mut schema.array {
373 array.unique_items = None;
374 }
375
376 if let Ok(schema) = serde_json::to_value(schema)
377 && let Ok(transformed) = serde_json::from_value(schema)
378 {
379 *transform_schema = transformed;
380 }
381 }
382}
383
384impl Transform for OptionalEnum {
385 fn transform(&mut self, schema: &mut schemars::Schema) {
386 transform_subschemas(self, schema);
387
388 let Some(obj) = schema.as_object_mut() else {
389 return;
390 };
391
392 let arr = obj
393 .get("anyOf")
394 .iter()
395 .flat_map(|any_of| any_of.as_array())
396 .last()
397 .cloned()
398 .unwrap_or_default();
399
400 let [first, second] = arr.as_slice() else {
401 return;
402 };
403 let (Some(first), Some(second)) = (first.as_object(), second.as_object()) else {
404 return;
405 };
406
407 // Check if this is an Option<T> pattern:
408 // anyOf with two elements where second is { "enum": [null], "nullable": true }
409 if !first.contains_key("nullable")
410 && second.get("enum") == Some(&json!([null]))
411 && second.get("nullable") == Some(&json!(true))
412 {
413 // Remove anyOf and hoist first element's properties to root
414 obj.remove("anyOf");
415 obj.append(&mut first.clone());
416 obj.insert("nullable".to_string(), Value::Bool(true));
417 }
418 }
419}
420
421impl Transform for OptionalIntOrString {
422 fn transform(&mut self, schema: &mut schemars::Schema) {
423 transform_subschemas(self, schema);
424
425 let Some(obj) = schema.as_object_mut() else {
426 return;
427 };
428
429 // Get required fields list
430 let required: BTreeSet<String> = obj
431 .get("required")
432 .and_then(|v| v.as_array())
433 .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
434 .unwrap_or_default();
435
436 // Get mutable properties
437 let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) else {
438 return;
439 };
440
441 // For each property that is NOT required and has x-kubernetes-int-or-string,
442 // add nullable: true if not already present
443 for (name, prop_schema) in properties.iter_mut() {
444 if required.contains(name) {
445 continue;
446 }
447
448 let Some(prop_obj) = prop_schema.as_object_mut() else {
449 continue;
450 };
451
452 if prop_obj.get("x-kubernetes-int-or-string") == Some(&json!(true))
453 && !prop_obj.contains_key("nullable")
454 {
455 prop_obj.insert("nullable".to_string(), Value::Bool(true));
456 }
457 }
458 }
459}
460
461/// Bring all plain enum values up to the root schema,
462/// since Kubernetes doesn't allow subschemas to define enum options.
463///
464/// (Enum here means a list of hard-coded values, not a tagged union.)
465fn hoist_subschema_enum_values(
466 subschemas: &mut Vec<Schema>,
467 common_enum_values: &mut Option<Vec<serde_json::Value>>,
468 instance_type: &mut Option<SingleOrVec<InstanceType>>,
469) {
470 subschemas.retain(|variant| {
471 if let Schema::Object(SchemaObject {
472 instance_type: variant_type,
473 enum_values: Some(variant_enum_values),
474 ..
475 }) = variant
476 {
477 if let Some(variant_type) = variant_type {
478 match instance_type {
479 None => *instance_type = Some(variant_type.clone()),
480 Some(tpe) => {
481 if tpe != variant_type {
482 panic!("Enum variant set {variant_enum_values:?} has type {variant_type:?} but was already defined as {instance_type:?}. The instance type must be equal for all subschema variants.")
483 }
484 }
485 }
486 }
487 common_enum_values
488 .get_or_insert_with(Vec::new)
489 .extend(variant_enum_values.iter().cloned());
490 false
491 } else {
492 true
493 }
494 })
495}
496
497/// Bring all property definitions from subschemas up to the root schema,
498/// since Kubernetes doesn't allow subschemas to define properties.
499fn hoist_subschema_properties(
500 subschemas: &mut Vec<Schema>,
501 common_obj: &mut Option<Box<ObjectValidation>>,
502 instance_type: &mut Option<SingleOrVec<InstanceType>>,
503 push_description_to_property: bool,
504) {
505 for variant in subschemas {
506 if let Schema::Object(SchemaObject {
507 instance_type: variant_type,
508 object: Some(variant_obj),
509 metadata: variant_metadata,
510 ..
511 }) = variant
512 {
513 let common_obj = common_obj.get_or_insert_with(Box::<ObjectValidation>::default);
514
515 // Move enum variant description from oneOf clause to its corresponding property
516 if let Some(variant_metadata) = variant_metadata
517 && let Some(description) = std::mem::take(&mut variant_metadata.description)
518 && let Some(Schema::Object(variant_object)) = only_item(variant_obj.properties.values_mut())
519 {
520 let metadata = variant_object
521 .metadata
522 .get_or_insert_with(Box::<Metadata>::default);
523 if push_description_to_property {
524 metadata.description = Some(description);
525 }
526 }
527
528 // Move all properties
529 let variant_properties = std::mem::take(&mut variant_obj.properties);
530 for (property_name, property) in variant_properties {
531 match common_obj.properties.entry(property_name) {
532 Entry::Vacant(entry) => {
533 entry.insert(property);
534 }
535 Entry::Occupied(entry) => {
536 if &property != entry.get() {
537 panic!(
538 "Property {:?} has the schema {:?} but was already defined as {:?} in another subschema. The schemas for a property used in multiple subschemas must be identical",
539 entry.key(),
540 &property,
541 entry.get()
542 );
543 }
544 }
545 }
546 }
547
548 // Kubernetes doesn't allow variants to set additionalProperties
549 variant_obj.additional_properties = None;
550
551 merge_metadata(instance_type, variant_type.take());
552 }
553 // Removes the type/description from oneOf and anyOf subschemas
554 else if let Schema::Object(SchemaObject {
555 metadata: variant_metadata,
556 instance_type: variant_type,
557 enum_values: None,
558 subschemas: None,
559 array: None,
560 object: None,
561 ..
562 }) = variant
563 {
564 std::mem::take(&mut *variant_type);
565 std::mem::take(&mut *variant_metadata);
566 }
567 }
568}
569
570fn only_item<I: Iterator>(mut i: I) -> Option<I::Item> {
571 let item = i.next()?;
572 if i.next().is_some() {
573 return None;
574 }
575 Some(item)
576}
577
578fn merge_metadata(
579 instance_type: &mut Option<SingleOrVec<InstanceType>>,
580 variant_type: Option<SingleOrVec<InstanceType>>,
581) {
582 match (instance_type, variant_type) {
583 (_, None) => {}
584 (common_type @ None, variant_type) => {
585 *common_type = variant_type;
586 }
587 (Some(common_type), Some(variant_type)) => {
588 if *common_type != variant_type {
589 panic!(
590 "variant defined type {variant_type:?}, conflicting with existing type {common_type:?}"
591 );
592 }
593 }
594 }
595}