Skip to content

Commit

Permalink
Implement as a JsonSchema generator via derive(ValidateSchema)
Browse files Browse the repository at this point in the history
Signed-off-by: Danil-Grigorev <[email protected]>
  • Loading branch information
Danil-Grigorev committed Dec 1, 2024
1 parent 4292898 commit 4e7691f
Show file tree
Hide file tree
Showing 6 changed files with 241 additions and 184 deletions.
90 changes: 61 additions & 29 deletions examples/crd_derive_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@ use kube::{
Api, ApiResource, DeleteParams, DynamicObject, GroupVersionKind, Patch, PatchParams, PostParams,
WatchEvent, WatchParams,
},
cel_validate,
runtime::wait::{await_condition, conditions},
CELValidate, Client, CustomResource, CustomResourceExt,
Client, CustomResource, CustomResourceExt, ValidateSchema,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

// This example shows how the generated schema affects defaulting and validation.
Expand All @@ -20,9 +18,7 @@ use serde::{Deserialize, Serialize};
// - https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#defaulting
// - https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#defaulting-and-nullable

#[derive(
CustomResource, CELValidate, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone, JsonSchema,
)]
#[derive(CustomResource, ValidateSchema, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone)]
#[kube(
group = "clux.dev",
version = "v1",
Expand All @@ -31,6 +27,8 @@ use serde::{Deserialize, Serialize};
derive = "PartialEq",
derive = "Default"
)]
#[serde(rename_all = "camelCase")]
#[cel_validate(rule = Rule::new("self.nonNullable == oldSelf.nonNullable"))]
pub struct FooSpec {
// Non-nullable without default is required.
//
Expand Down Expand Up @@ -92,18 +90,16 @@ pub struct FooSpec {
// Field with CEL validation
#[serde(default = "default_legal")]
#[cel_validate(
method = cel_validate,
rule = Rule{rule: "self != 'illegal'".into(), message: Some(Message::Expression("'string cannot be illegal'".into())), reason: Some(Reason::FieldValueForbidden), ..Default::default()},
rule = Rule{rule: "self != 'not legal'".into(), reason: Some(Reason::FieldValueInvalid), ..Default::default()}
rule = Rule::new("self != 'illegal'").message(Message::Expression("'string cannot be illegal'".into())).reason(Reason::FieldValueForbidden),
rule = Rule::new("self != 'not legal'").reason(Reason::FieldValueInvalid),
)]
#[schemars(schema_with = "cel_validate")]
cel_validated: Option<String>,

#[cel_validate(rule = Rule::new("self == oldSelf").message("is immutable"))]
foo_sub_spec: Option<FooSubSpec>,
}

#[cel_validate]
#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone, JsonSchema)]
#[derive(ValidateSchema, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone)]
pub struct FooSubSpec {
#[cel_validate(rule = "self != 'not legal'".into())]
field: String,
Expand Down Expand Up @@ -192,23 +188,23 @@ async fn main() -> Result<()> {
// Test defaulting of `non_nullable_with_default` field
let data = DynamicObject::new("baz", &api_resource).data(serde_json::json!({
"spec": {
"non_nullable": "a required field",
"nonNullable": "a required field",
// `non_nullable_with_default` field is missing

// listable values to patch later to verify merge strategies
"default_listable": vec![2],
"set_listable": vec![2],
"defaultListable": vec![2],
"setListable": vec![2],
}
}));
let val = dynapi.create(&PostParams::default(), &data).await?.data;
println!("{:?}", val["spec"]);
// Defaulting happened for non-nullable field
assert_eq!(val["spec"]["non_nullable_with_default"], default_value());
assert_eq!(val["spec"]["nonNullableWithDefault"], default_value());

// Listables
assert_eq!(serde_json::to_string(&val["spec"]["default_listable"])?, "[2]");
assert_eq!(serde_json::to_string(&val["spec"]["set_listable"])?, "[2]");
assert_eq!(serde_json::to_string(&val["spec"]["cel_validated"])?, "\"legal\"");
assert_eq!(serde_json::to_string(&val["spec"]["defaultListable"])?, "[2]");
assert_eq!(serde_json::to_string(&val["spec"]["setListable"])?, "[2]");
assert_eq!(serde_json::to_string(&val["spec"]["celValidated"])?, "\"legal\"");

// Missing required field (non-nullable without default) is an error
let data = DynamicObject::new("qux", &api_resource).data(serde_json::json!({
Expand All @@ -222,7 +218,7 @@ async fn main() -> Result<()> {
assert_eq!(err.reason, "Invalid");
assert_eq!(err.status, "Failure");
assert!(err.message.contains("clux.dev \"qux\" is invalid"));
assert!(err.message.contains("spec.non_nullable: Required value"));
assert!(err.message.contains("spec.nonNullable: Required value"));
}
_ => panic!(),
}
Expand All @@ -233,8 +229,8 @@ async fn main() -> Result<()> {
"apiVersion": "clux.dev/v1",
"kind": "Foo",
"spec": {
"default_listable": vec![3],
"set_listable": vec![3]
"defaultListable": vec![3],
"setListable": vec![3]
}
});
let pres = foos.patch("baz", &ssapply, &Patch::Apply(patch)).await?;
Expand All @@ -247,7 +243,7 @@ async fn main() -> Result<()> {
"apiVersion": "clux.dev/v1",
"kind": "Foo",
"spec": {
"cel_validated": Some("illegal")
"celValidated": Some("illegal")
}
});
let cel_res = foos.patch("baz", &ssapply, &Patch::Apply(cel_patch)).await;
Expand All @@ -258,7 +254,7 @@ async fn main() -> Result<()> {
assert_eq!(err.reason, "Invalid");
assert_eq!(err.status, "Failure");
assert!(err.message.contains("Foo.clux.dev \"baz\" is invalid"));
assert!(err.message.contains("spec.cel_validated: Forbidden"));
assert!(err.message.contains("spec.celValidated: Forbidden"));
assert!(err.message.contains("string cannot be illegal"));
}
_ => panic!(),
Expand All @@ -269,7 +265,7 @@ async fn main() -> Result<()> {
"apiVersion": "clux.dev/v1",
"kind": "Foo",
"spec": {
"cel_validated": Some("not legal")
"celValidated": Some("not legal")
}
});
let cel_res = foos.patch("baz", &ssapply, &Patch::Apply(cel_patch)).await;
Expand All @@ -280,7 +276,7 @@ async fn main() -> Result<()> {
assert_eq!(err.reason, "Invalid");
assert_eq!(err.status, "Failure");
assert!(err.message.contains("Foo.clux.dev \"baz\" is invalid"));
assert!(err.message.contains("spec.cel_validated: Invalid value"));
assert!(err.message.contains("spec.celValidated: Invalid value"));
assert!(err.message.contains("failed rule: self != 'not legal'"));
}
_ => panic!(),
Expand All @@ -290,7 +286,7 @@ async fn main() -> Result<()> {
"apiVersion": "clux.dev/v1",
"kind": "Foo",
"spec": {
"foo_sub_spec": {
"fooSubSpec": {
"field": Some("not legal"),
}
}
Expand All @@ -303,18 +299,54 @@ async fn main() -> Result<()> {
assert_eq!(err.reason, "Invalid");
assert_eq!(err.status, "Failure");
assert!(err.message.contains("Foo.clux.dev \"baz\" is invalid"));
assert!(err.message.contains("spec.foo_sub_spec.field: Invalid value"));
assert!(err.message.contains("spec.fooSubSpec.field: Invalid value"));
assert!(err.message.contains("failed rule: self != 'not legal'"));
}
_ => panic!(),
}

let cel_patch = serde_json::json!({
"apiVersion": "clux.dev/v1",
"kind": "Foo",
"spec": {
"fooSubSpec": {
"field": Some("legal"),
}
}
});
let cel_res = foos.patch("baz", &ssapply, &Patch::Apply(cel_patch)).await;
assert!(cel_res.is_ok());

let cel_patch = serde_json::json!({
"apiVersion": "clux.dev/v1",
"kind": "Foo",
"spec": {
"fooSubSpec": {
"field": Some("legal"),
"other": "different",
}
}
});
let cel_res = foos.patch("baz", &ssapply, &Patch::Apply(cel_patch)).await;
assert!(cel_res.is_err());
match cel_res.err() {
Some(kube::Error::Api(err)) => {
assert_eq!(err.code, 422);
assert_eq!(err.reason, "Invalid");
assert_eq!(err.status, "Failure");
assert!(err.message.contains("Foo.clux.dev \"baz\" is invalid"));
assert!(err.message.contains("spec.fooSubSpec: Invalid value"));
assert!(err.message.contains("Invalid value: \"object\": is immutable"));
}
_ => panic!(),
}

// cel validation happy:
let cel_patch_ok = serde_json::json!({
"apiVersion": "clux.dev/v1",
"kind": "Foo",
"spec": {
"cel_validated": Some("legal")
"celValidated": Some("legal")
}
});
foos.patch("baz", &ssapply, &Patch::Apply(cel_patch_ok)).await?;
Expand Down
95 changes: 89 additions & 6 deletions kube-core/src/cel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub struct Rule {
pub message: Option<Message>,
/// fieldPath represents the field path returned when the validation fails.
/// It must be a relative JSON path, scoped to the location of the field in the schema
#[serde(skip_serializing_if = "Option::is_none")]
pub field_path: Option<String>,
/// reason is a machine-readable value providing more detail about why a field failed the validation.
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -162,7 +163,7 @@ impl FromStr for Reason {
}

/// Validate takes schema and applies a set of validation rules to it. The rules are stored
/// under the "x-kubernetes-validations".
/// on the top level under the "x-kubernetes-validations".
///
/// ```rust
/// use schemars::schema::Schema;
Expand All @@ -175,7 +176,7 @@ impl FromStr for Reason {
/// field_path: Some("spec.host".into()),
/// ..Default::default()
/// }];
/// let schema = validate(&mut schema, rules)?;
/// validate(&mut schema, rules)?;
/// assert_eq!(
/// serde_json::to_string(&schema).unwrap(),
/// r#"{"x-kubernetes-validations":[{"fieldPath":"spec.host","message":"must be a URL with the host matching spec.host","rule":"self.spec.host == self.url.host"}]}"#,
Expand All @@ -184,16 +185,98 @@ impl FromStr for Reason {
///```
#[cfg(feature = "schema")]
#[cfg_attr(docsrs, doc(cfg(feature = "schema")))]
pub fn validate(s: &mut Schema, rules: Vec<Rule>) -> Result<Schema, serde_json::Error> {
let rules = serde_json::to_value(rules)?;
pub fn validate(s: &mut Schema, rules: Vec<Rule>) -> Result<(), serde_json::Error> {
match s {
Schema::Bool(_) => (),
Schema::Object(schema_object) => {
schema_object
.extensions
.insert("x-kubernetes-validations".into(), rules);
.insert("x-kubernetes-validations".into(), serde_json::to_value(rules)?);
}
};
Ok(())
}

/// Validate property mutates property under property_index of the schema
/// with the provided set of validation rules.
///
/// ```rust
/// use schemars::JsonSchema;
/// use kube::core::{Rule, validate_property};
///
/// #[derive(JsonSchema)]
/// struct MyStruct {
/// field: Option<String>,
/// }
///
/// let gen = &mut schemars::gen::SchemaSettings::openapi3().into_generator();
/// let mut schema = MyStruct::json_schema(gen);
/// let rules = vec![Rule::new("self != oldSelf")];
/// validate_property(&mut schema, 0, rules)?;
/// assert_eq!(
/// serde_json::to_string(&schema).unwrap(),
/// r#"{"type":"object","properties":{"field":{"type":"string","nullable":true,"x-kubernetes-validations":[{"rule":"self != oldSelf"}]}}}"#
/// );
/// # Ok::<(), serde_json::Error>(())
///```
#[cfg(feature = "schema")]
#[cfg_attr(docsrs, doc(cfg(feature = "schema")))]
pub fn validate_property(
s: &mut Schema,
property_index: usize,
rules: Vec<Rule>,
) -> Result<(), serde_json::Error> {
match s {
Schema::Bool(_) => (),
Schema::Object(schema_object) => {
let obj = schema_object.object();
for (n, (_, schema)) in obj.properties.iter_mut().enumerate() {
if n == property_index {
return validate(schema, rules);
}
}
}
};

Ok(s.clone())
Ok(())
}

/// Merge schema properties in order to pass overrides or extension properties from the other schema.
///
/// ```rust
/// use schemars::JsonSchema;
/// use kube::core::{Rule, merge_properties};
///
/// #[derive(JsonSchema)]
/// struct MyStruct {
/// a: Option<bool>,
/// }
///
/// #[derive(JsonSchema)]
/// struct MySecondStruct {
/// a: bool,
/// b: Option<bool>,
/// }
/// let gen = &mut schemars::gen::SchemaSettings::openapi3().into_generator();
/// let mut first = MyStruct::json_schema(gen);
/// let mut second = MySecondStruct::json_schema(gen);
/// merge_properties(&mut first, &mut second);
///
/// assert_eq!(
/// serde_json::to_string(&first).unwrap(),
/// r#"{"type":"object","properties":{"a":{"type":"boolean"},"b":{"type":"boolean","nullable":true}}}"#
/// );
/// # Ok::<(), serde_json::Error>(())
#[cfg(feature = "schema")]
#[cfg_attr(docsrs, doc(cfg(feature = "schema")))]
pub fn merge_properties(s: &mut Schema, merge: &mut Schema) {
match s {
schemars::schema::Schema::Bool(_) => (),
schemars::schema::Schema::Object(schema_object) => {
let obj = schema_object.object();
for (k, v) in &merge.clone().into_object().object().properties {
obj.properties.insert(k.clone(), v.clone());
}
}
}
}
3 changes: 2 additions & 1 deletion kube-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ pub use crd::CustomResourceExt;
pub mod cel;
pub use cel::{Message, Reason, Rule};

#[cfg(feature = "schema")] pub use cel::validate;
#[cfg(feature = "schema")]
pub use cel::{merge_properties, validate, validate_property};

pub mod gvk;
pub use gvk::{GroupVersion, GroupVersionKind, GroupVersionResource};
Expand Down
Loading

0 comments on commit 4e7691f

Please sign in to comment.