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 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 if first.contains_key("enum")
408 && !first.contains_key("nullable")
409 && second.get("enum") == Some(&json!([null]))
410 && second.get("nullable") == Some(&json!(true))
411 {
412 obj.remove("anyOf");
413 obj.append(&mut first.clone());
414 obj.insert("nullable".to_string(), Value::Bool(true));
415 }
416 }
417}
418
419impl Transform for OptionalIntOrString {
420 fn transform(&mut self, schema: &mut schemars::Schema) {
421 transform_subschemas(self, schema);
422
423 let Some(obj) = schema.as_object_mut() else {
424 return;
425 };
426
427 // Get required fields list
428 let required: BTreeSet<String> = obj
429 .get("required")
430 .and_then(|v| v.as_array())
431 .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
432 .unwrap_or_default();
433
434 // Get mutable properties
435 let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) else {
436 return;
437 };
438
439 // For each property that is NOT required and has x-kubernetes-int-or-string,
440 // add nullable: true if not already present
441 for (name, prop_schema) in properties.iter_mut() {
442 if required.contains(name) {
443 continue;
444 }
445
446 let Some(prop_obj) = prop_schema.as_object_mut() else {
447 continue;
448 };
449
450 if prop_obj.get("x-kubernetes-int-or-string") == Some(&json!(true))
451 && !prop_obj.contains_key("nullable")
452 {
453 prop_obj.insert("nullable".to_string(), Value::Bool(true));
454 }
455 }
456 }
457}
458
459/// Bring all plain enum values up to the root schema,
460/// since Kubernetes doesn't allow subschemas to define enum options.
461///
462/// (Enum here means a list of hard-coded values, not a tagged union.)
463fn hoist_subschema_enum_values(
464 subschemas: &mut Vec<Schema>,
465 common_enum_values: &mut Option<Vec<serde_json::Value>>,
466 instance_type: &mut Option<SingleOrVec<InstanceType>>,
467) {
468 subschemas.retain(|variant| {
469 if let Schema::Object(SchemaObject {
470 instance_type: variant_type,
471 enum_values: Some(variant_enum_values),
472 ..
473 }) = variant
474 {
475 if let Some(variant_type) = variant_type {
476 match instance_type {
477 None => *instance_type = Some(variant_type.clone()),
478 Some(tpe) => {
479 if tpe != variant_type {
480 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.")
481 }
482 }
483 }
484 }
485 common_enum_values
486 .get_or_insert_with(Vec::new)
487 .extend(variant_enum_values.iter().cloned());
488 false
489 } else {
490 true
491 }
492 })
493}
494
495/// Bring all property definitions from subschemas up to the root schema,
496/// since Kubernetes doesn't allow subschemas to define properties.
497fn hoist_subschema_properties(
498 subschemas: &mut Vec<Schema>,
499 common_obj: &mut Option<Box<ObjectValidation>>,
500 instance_type: &mut Option<SingleOrVec<InstanceType>>,
501) {
502 for variant in subschemas {
503 if let Schema::Object(SchemaObject {
504 instance_type: variant_type,
505 object: Some(variant_obj),
506 metadata: variant_metadata,
507 ..
508 }) = variant
509 {
510 let common_obj = common_obj.get_or_insert_with(Box::<ObjectValidation>::default);
511
512 // Move enum variant description from oneOf clause to its corresponding property
513 if let Some(variant_metadata) = variant_metadata
514 && let Some(description) = std::mem::take(&mut variant_metadata.description)
515 && let Some(Schema::Object(variant_object)) = only_item(variant_obj.properties.values_mut())
516 {
517 let metadata = variant_object
518 .metadata
519 .get_or_insert_with(Box::<Metadata>::default);
520 metadata.description = Some(description);
521 }
522
523 // Move all properties
524 let variant_properties = std::mem::take(&mut variant_obj.properties);
525 for (property_name, property) in variant_properties {
526 match common_obj.properties.entry(property_name) {
527 Entry::Vacant(entry) => {
528 entry.insert(property);
529 }
530 Entry::Occupied(entry) => {
531 if &property != entry.get() {
532 panic!(
533 "Property {:?} has the schema {:?} but was already defined as {:?} in another subschema. The schemas for a property used in multiple subschemas must be identical",
534 entry.key(),
535 &property,
536 entry.get()
537 );
538 }
539 }
540 }
541 }
542
543 // Kubernetes doesn't allow variants to set additionalProperties
544 variant_obj.additional_properties = None;
545
546 merge_metadata(instance_type, variant_type.take());
547 }
548 }
549}
550
551fn only_item<I: Iterator>(mut i: I) -> Option<I::Item> {
552 let item = i.next()?;
553 if i.next().is_some() {
554 return None;
555 }
556 Some(item)
557}
558
559fn merge_metadata(
560 instance_type: &mut Option<SingleOrVec<InstanceType>>,
561 variant_type: Option<SingleOrVec<InstanceType>>,
562) {
563 match (instance_type, variant_type) {
564 (_, None) => {}
565 (common_type @ None, variant_type) => {
566 *common_type = variant_type;
567 }
568 (Some(common_type), Some(variant_type)) => {
569 if *common_type != variant_type {
570 panic!(
571 "variant defined type {variant_type:?}, conflicting with existing type {common_type:?}"
572 );
573 }
574 }
575 }
576}