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        // 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}