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#[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}