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