Skip to main content

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}