diff --git a/examples/crd_derive_schema.rs b/examples/crd_derive_schema.rs index f9aa16206..65c013de9 100644 --- a/examples/crd_derive_schema.rs +++ b/examples/crd_derive_schema.rs @@ -19,19 +19,10 @@ use serde::{Deserialize, Serialize}; )] #[kube(apiextensions = "v1")] pub struct FooSpec { - // Required field + // Non-nullable without default is required. // // There shouldn't be any ambiguity here. - required_field: String, - - // Nullable without default. - // - // By skipping to serialize, the field won't be present in the object. - // If serialized as `null`, the object will have the field set to `null`. - // - // Deserializing works as expected either way. `None` if it's missing or `null`. - #[serde(skip_serializing_if = "Option::is_none")] - nullable: Option, + non_nullable: String, // Non-nullable with default value. // @@ -45,9 +36,20 @@ pub struct FooSpec { // The server prunes `null` for non-nullable field since 1.20 and the default is applied. // To match the server's behavior exactly, we can use a custom deserializer. #[serde(default = "default_value")] - with_default: String, + non_nullable_with_default: String, + + // Nullable without default, skipping None. + // + // By skipping to serialize, the field won't be present in the object. + // If serialized as `null` (next field), the object will have the field set to `null`. + // + // Deserializing works as expected either way. `None` if it's missing or `null`. + #[serde(skip_serializing_if = "Option::is_none")] + nullable_skipped: Option, + // Nullable without default, not skipping None. + nullable: Option, - // Nullable with default value. + // Nullable with default, skipping None. // // By skipping to serialize when `None`, the server will set the the default value specified in the schema. // If serialized as `null`, the server will conserve it and the defaulting does not happen (since 1.20). @@ -56,14 +58,14 @@ pub struct FooSpec { // This is consistent with how the server handles it since 1.20. #[serde(skip_serializing_if = "Option::is_none")] #[serde(default = "default_nullable")] - nullable_with_default: Option, + nullable_skipped_with_default: Option, - // Nullable with default without skipping. + // Nullable with default, not skipping None. // // The default value won't be used unless missing, so this will set the value to `null`. // If the resource is created with `kubectl` and if this field was missing, defaulting will happen. #[serde(default = "default_nullable")] - nullable_with_default_no_skip: Option, + nullable_with_default: Option, } fn default_value() -> String { @@ -82,10 +84,11 @@ fn main() { #[cfg(test)] mod tests { use super::*; + use anyhow::{anyhow, Result}; use futures::{StreamExt, TryStreamExt}; use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition; use kube::{ - api::{Api, DeleteParams, ListParams, PostParams, WatchEvent}, + api::{Api, DeleteParams, ListParams, PostParams, Resource, WatchEvent}, Client, }; @@ -119,30 +122,35 @@ mod tests { "properties": { "spec": { "properties": { - "nullable": { - "nullable": true, - "type": "string" - }, - "nullable_with_default": { - "default": "default_nullable", - "nullable": true, - "type": "string" - }, - "nullable_with_default_no_skip": { - "default": "default_nullable", - "nullable": true, - "type": "string" - }, - "required_field": { - "type": "string" - }, - "with_default": { - "default": "default_value", - "type": "string" - }, + "non_nullable": { + "type": "string" + }, + "non_nullable_with_default": { + "default": "default_value", + "type": "string" + }, + + "nullable_skipped": { + "nullable": true, + "type": "string" + }, + "nullable": { + "nullable": true, + "type": "string" + }, + "nullable_skipped_with_default": { + "default": "default_nullable", + "nullable": true, + "type": "string" + }, + "nullable_with_default": { + "default": "default_nullable", + "nullable": true, + "type": "string" + }, }, "required": [ - "required_field" + "non_nullable" ], "type": "object" } @@ -160,8 +168,6 @@ mod tests { } }); let expected = serde_json::from_value(output).unwrap(); - // println!("{}", serde_yaml::to_string(&crd).unwrap()); - // println!("{}", serde_yaml::to_string(&expected).unwrap()); assert_eq!(crd, expected); } @@ -176,50 +182,106 @@ mod tests { // Test creating Foo resource. let namespace = std::env::var("NAMESPACE").unwrap_or("default".into()); let foos = Api::::namespaced(client.clone(), &namespace); - let bar = Foo::new( - "bar", + // Create with defaults using typed Api first. + // `non_nullable` and `non_nullable_with_default` are set to empty strings. + // Nullables defaults to `None` and only sent if it's not configured to skip. + let bar = Foo::new("bar", FooSpec { ..FooSpec::default() }); + let bar = foos.create(&PostParams::default(), &bar).await.unwrap(); + assert_eq!( + bar.spec, FooSpec { - required_field: "required".into(), - with_default: "".into(), + // Nonnullable without default is required. + non_nullable: String::default(), + // Defaulting didn't happen because an empty string was sent. + non_nullable_with_default: String::default(), + // `nullable_skipped` field does not exist in the object (see below). + nullable_skipped: None, + // `nullable` field exists in the object (see below). nullable: None, + // Defaulting happened because serialization was skipped. + nullable_skipped_with_default: default_nullable(), + // Defaulting did not happen because `null` was sent. + // Deserialization does not apply the default either. nullable_with_default: None, - nullable_with_default_no_skip: None, - }, + } ); - let bar = foos.create(&PostParams::default(), &bar).await.unwrap(); - // Defaulting happens if None is skipped. - assert_eq!(bar.spec.nullable_with_default, Some("default_nullable".into())); - // If `null` is sent, defaulting does not happen. - // Deserialization does not apply the default either. - assert_eq!(bar.spec.nullable_with_default_no_skip, None); - // `nullable` field should not exist on the object, but it's set to `None` when deserializing. - // TODO Any convenient way to check the raw response? - assert_eq!(bar.spec.nullable, None); - // TODO Any convenient way to send invalid/raw data? - // let baz = foos - // .create( - // &PostParams::default(), - // serde_json::json!({ - // "apiVersion": "foos.clux.dev/v1", - // "kind": "Foo", - // "spec": {} - // }), - // ) - // .await; - // // Missing a required field is an error. - // assert!(baz.is_err()); + // Set up dynamic resource to test using raw values. + let resource = Resource::dynamic("Foo") + .group("clux.dev") + .version("v1") + .within(&namespace) + .into_resource(); + + // Test that skipped nullable field without default is not defined. + let val = client + .request::(resource.get("bar").unwrap()) + .await + .unwrap(); + println!("{:?}", val["spec"]); + // `nullable_skipped` field does not exist, but `nullable` does. + let spec = val["spec"].as_object().unwrap(); + assert!(!spec.contains_key("nullable_skipped")); + assert!(spec.contains_key("nullable")); + + // Test defaulting of `non_nullable_with_default` field + let data = serde_json::to_vec(&serde_json::json!({ + "apiVersion": "clux.dev/v1", + "kind": "Foo", + "metadata": { + "name": "baz" + }, + "spec": { + "non_nullable": "a required field", + // `non_nullable_with_default` field is missing + } + })) + .unwrap(); + let val = client + .request::(resource.create(&PostParams::default(), data).unwrap()) + .await + .unwrap(); + println!("{:?}", val["spec"]); + // Defaulting happened for non-nullable field + assert_eq!(val["spec"]["non_nullable_with_default"], default_value()); + + // Missing required field (non-nullable without default) is an error + let data = serde_json::to_vec(&serde_json::json!({ + "apiVersion": "clux.dev/v1", + "kind": "Foo", + "metadata": { + "name": "qux" + }, + "spec": {} + })) + .unwrap(); + let res = client + .request::(resource.create(&PostParams::default(), data).unwrap()) + .await; + assert!(res.is_err()); + match res.err() { + Some(kube::Error::Api(err)) => { + assert_eq!(err.code, 422); + assert_eq!(err.reason, "Invalid"); + assert_eq!(err.status, "Failure"); + assert_eq!( + err.message, + "Foo.clux.dev \"qux\" is invalid: spec.non_nullable: Required value" + ); + } + _ => assert!(false), + } delete_crd(client.clone()).await.unwrap(); } // Create CRD and wait for it to be ready. - async fn create_crd(client: Client) -> anyhow::Result { + async fn create_crd(client: Client) -> Result { let api = Api::::all(client); api.create(&PostParams::default(), &Foo::crd()).await?; // Wait until ready - let timeout_secs = 5; + let timeout_secs = 15; let lp = ListParams::default() .fields("metadata.name=foos.clux.dev") .timeout(timeout_secs); @@ -241,20 +303,17 @@ mod tests { } } - Err(anyhow::anyhow!(format!( - "CR not ready after {} seconds", - timeout_secs - ))) + Err(anyhow!(format!("CRD not ready after {} seconds", timeout_secs))) } // Delete the CRD if it exists and wait until it's deleted. - async fn delete_crd(client: Client) -> anyhow::Result<()> { + async fn delete_crd(client: Client) -> Result<()> { let api = Api::::all(client); if api.get("foos.clux.dev").await.is_ok() { api.delete("foos.clux.dev", &DeleteParams::default()).await?; // Wait until deleted - let timeout_secs = 5; + let timeout_secs = 15; let lp = ListParams::default() .fields("metadata.name=foos.clux.dev") .timeout(timeout_secs); @@ -264,10 +323,7 @@ mod tests { return Ok(()); } } - Err(anyhow::anyhow!(format!( - "CRD not deleted after {} seconds", - timeout_secs - ))) + Err(anyhow!(format!("CRD not deleted after {} seconds", timeout_secs))) } else { Ok(()) }