diff --git a/Cargo.lock b/Cargo.lock index c8fd63dba..0b6637a52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1657,6 +1657,8 @@ dependencies = [ "regex", "rstest", "rstest_reuse", + "serde", + "serde_yaml", "snafu 0.8.5", "syn 2.0.101", ] diff --git a/crates/k8s-version/CHANGELOG.md b/crates/k8s-version/CHANGELOG.md index 727bf0158..85e828ddf 100644 --- a/crates/k8s-version/CHANGELOG.md +++ b/crates/k8s-version/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Add support for serialization and deserialization via `serde`. This feature is enabled via the + `serde` feature flag ([#1034]). + +[#1034]: https://github.com/stackabletech/operator-rs/pull/1034 + ## [0.1.2] - 2024-09-19 ### Changed diff --git a/crates/k8s-version/Cargo.toml b/crates/k8s-version/Cargo.toml index 1bebaf206..5be8d3207 100644 --- a/crates/k8s-version/Cargo.toml +++ b/crates/k8s-version/Cargo.toml @@ -8,10 +8,12 @@ repository.workspace = true [features] darling = ["dep:darling"] +serde = ["dep:serde"] [dependencies] darling = { workspace = true, optional = true } regex.workspace = true +serde = { workspace = true, optional = true } snafu.workspace = true [dev-dependencies] @@ -19,4 +21,5 @@ rstest.workspace = true rstest_reuse.workspace = true quote.workspace = true proc-macro2.workspace = true +serde_yaml.workspace = true syn.workspace = true diff --git a/crates/k8s-version/src/api_version.rs b/crates/k8s-version/src/api_version/mod.rs similarity index 99% rename from crates/k8s-version/src/api_version.rs rename to crates/k8s-version/src/api_version/mod.rs index f3157647e..5f046a869 100644 --- a/crates/k8s-version/src/api_version.rs +++ b/crates/k8s-version/src/api_version/mod.rs @@ -6,6 +6,9 @@ use snafu::{ResultExt, Snafu}; use crate::{Group, ParseGroupError, ParseVersionError, Version}; +#[cfg(feature = "serde")] +mod serde; + /// Error variants which can be encountered when creating a new [`ApiVersion`] /// from unparsed input. #[derive(Debug, PartialEq, Snafu)] diff --git a/crates/k8s-version/src/api_version/serde.rs b/crates/k8s-version/src/api_version/serde.rs new file mode 100644 index 000000000..f9754c842 --- /dev/null +++ b/crates/k8s-version/src/api_version/serde.rs @@ -0,0 +1,62 @@ +use std::str::FromStr; + +use serde::{Deserialize, Serialize, de::Visitor}; + +use crate::ApiVersion; + +impl<'de> Deserialize<'de> for ApiVersion { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct ApiVersionVisitor; + + impl<'de> Visitor<'de> for ApiVersionVisitor { + type Value = ApiVersion; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a valid Kubernetes API version") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + ApiVersion::from_str(v).map_err(serde::de::Error::custom) + } + } + + deserializer.deserialize_str(ApiVersionVisitor) + } +} + +impl Serialize for ApiVersion { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +#[cfg(feature = "serde")] +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize() { + let _: ApiVersion = + serde_yaml::from_str("extensions.k8s.io/v1alpha1").expect("api version is valid"); + } + + #[test] + fn serialize() { + let api_version = + ApiVersion::from_str("extensions.k8s.io/v1alpha1").expect("api version is valid"); + assert_eq!( + "extensions.k8s.io/v1alpha1\n", + serde_yaml::to_string(&api_version).expect("api version must serialize") + ); + } +} diff --git a/crates/k8s-version/src/group.rs b/crates/k8s-version/src/group.rs index ebe7c912b..ee15f9911 100644 --- a/crates/k8s-version/src/group.rs +++ b/crates/k8s-version/src/group.rs @@ -35,6 +35,7 @@ pub enum ParseGroupError { /// ### See /// /// - +#[cfg_attr(feature = "serde", derive(::serde::Deserialize, ::serde::Serialize))] #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd)] pub struct Group(String); @@ -63,3 +64,23 @@ impl Deref for Group { &self.0 } } + +#[cfg(feature = "serde")] +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize() { + let _: Group = serde_yaml::from_str("extensions.k8s.io").expect("group is valid"); + } + + #[test] + fn serialize() { + let group = Group("extensions.k8s.io".into()); + assert_eq!( + "extensions.k8s.io\n", + serde_yaml::to_string(&group).expect("group must serialize") + ); + } +} diff --git a/crates/k8s-version/src/level/darling.rs b/crates/k8s-version/src/level/darling.rs new file mode 100644 index 000000000..41aaad4f5 --- /dev/null +++ b/crates/k8s-version/src/level/darling.rs @@ -0,0 +1,34 @@ +use std::str::FromStr; + +use darling::FromMeta; + +use crate::Level; + +impl FromMeta for Level { + fn from_string(value: &str) -> darling::Result { + Self::from_str(value).map_err(darling::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use quote::quote; + use rstest::rstest; + + use super::*; + + fn parse_meta(tokens: proc_macro2::TokenStream) -> ::std::result::Result { + let attribute: syn::Attribute = syn::parse_quote!(#[#tokens]); + Ok(attribute.meta) + } + + #[rstest] + #[case(quote!(ignore = "alpha12"), Level::Alpha(12))] + #[case(quote!(ignore = "alpha1"), Level::Alpha(1))] + #[case(quote!(ignore = "beta1"), Level::Beta(1))] + fn from_meta(#[case] input: proc_macro2::TokenStream, #[case] expected: Level) { + let meta = parse_meta(input).expect("valid attribute tokens"); + let version = Level::from_meta(&meta).expect("level must parse from attribute"); + assert_eq!(version, expected); + } +} diff --git a/crates/k8s-version/src/level.rs b/crates/k8s-version/src/level/mod.rs similarity index 82% rename from crates/k8s-version/src/level.rs rename to crates/k8s-version/src/level/mod.rs index 15a49b02b..1e10006d9 100644 --- a/crates/k8s-version/src/level.rs +++ b/crates/k8s-version/src/level/mod.rs @@ -7,11 +7,15 @@ use std::{ sync::LazyLock, }; -#[cfg(feature = "darling")] -use darling::FromMeta; use regex::Regex; use snafu::{OptionExt, ResultExt, Snafu}; +#[cfg(feature = "serde")] +mod serde; + +#[cfg(feature = "darling")] +mod darling; + static LEVEL_REGEX: LazyLock = LazyLock::new(|| { Regex::new(r"^(?P[a-z]+)(?P\d+)$").expect("failed to compile level regex") }); @@ -148,28 +152,13 @@ impl Display for Level { } } -#[cfg(feature = "darling")] -impl FromMeta for Level { - fn from_string(value: &str) -> darling::Result { - Self::from_str(value).map_err(darling::Error::custom) - } -} - #[cfg(test)] mod test { - #[cfg(feature = "darling")] - use quote::quote; use rstest::rstest; use rstest_reuse::*; use super::*; - #[cfg(feature = "darling")] - fn parse_meta(tokens: proc_macro2::TokenStream) -> ::std::result::Result { - let attribute: syn::Attribute = syn::parse_quote!(#[#tokens]); - Ok(attribute.meta) - } - #[template] #[rstest] #[case(Level::Beta(1), Level::Alpha(1), Ordering::Greater)] @@ -191,15 +180,4 @@ mod test { fn partial_ord(input: Level, other: Level, expected: Ordering) { assert_eq!(input.partial_cmp(&other), Some(expected)) } - - #[cfg(feature = "darling")] - #[rstest] - #[case(quote!(ignore = "alpha12"), Level::Alpha(12))] - #[case(quote!(ignore = "alpha1"), Level::Alpha(1))] - #[case(quote!(ignore = "beta1"), Level::Beta(1))] - fn from_meta(#[case] input: proc_macro2::TokenStream, #[case] expected: Level) { - let meta = parse_meta(input).expect("valid attribute tokens"); - let version = Level::from_meta(&meta).expect("level must parse from attribute"); - assert_eq!(version, expected); - } } diff --git a/crates/k8s-version/src/level/serde.rs b/crates/k8s-version/src/level/serde.rs new file mode 100644 index 000000000..59bda5702 --- /dev/null +++ b/crates/k8s-version/src/level/serde.rs @@ -0,0 +1,60 @@ +use std::str::FromStr; + +use serde::{Deserialize, Serialize, de::Visitor}; + +use crate::Level; + +impl<'de> Deserialize<'de> for Level { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct LevelVisitor; + + impl<'de> Visitor<'de> for LevelVisitor { + type Value = Level; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a valid Kubernetes API version level") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + Level::from_str(v).map_err(serde::de::Error::custom) + } + } + + deserializer.deserialize_str(LevelVisitor) + } +} + +impl Serialize for Level { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +#[cfg(feature = "serde")] +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize() { + let _: Level = serde_yaml::from_str("alpha1").expect("level is valid"); + } + + #[test] + fn serialize() { + let api_version = Level::from_str("alpha1").expect("level is valid"); + assert_eq!( + "alpha1\n", + serde_yaml::to_string(&api_version).expect("level must serialize") + ); + } +} diff --git a/crates/k8s-version/src/version/darling.rs b/crates/k8s-version/src/version/darling.rs new file mode 100644 index 000000000..3c2ae3216 --- /dev/null +++ b/crates/k8s-version/src/version/darling.rs @@ -0,0 +1,37 @@ +use std::str::FromStr; + +use darling::FromMeta; + +use crate::Version; + +impl FromMeta for Version { + fn from_string(value: &str) -> darling::Result { + Self::from_str(value).map_err(darling::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use quote::quote; + use rstest::rstest; + + use super::*; + use crate::Level; + + fn parse_meta(tokens: proc_macro2::TokenStream) -> ::std::result::Result { + let attribute: syn::Attribute = syn::parse_quote!(#[#tokens]); + Ok(attribute.meta) + } + + #[cfg(feature = "darling")] + #[rstest] + #[case(quote!(ignore = "v1alpha12"), Version { major: 1, level: Some(Level::Alpha(12)) })] + #[case(quote!(ignore = "v1alpha1"), Version { major: 1, level: Some(Level::Alpha(1)) })] + #[case(quote!(ignore = "v1beta1"), Version { major: 1, level: Some(Level::Beta(1)) })] + #[case(quote!(ignore = "v1"), Version { major: 1, level: None })] + fn from_meta(#[case] input: proc_macro2::TokenStream, #[case] expected: Version) { + let meta = parse_meta(input).expect("valid attribute tokens"); + let version = Version::from_meta(&meta).expect("version must parse from attribute"); + assert_eq!(version, expected); + } +} diff --git a/crates/k8s-version/src/version.rs b/crates/k8s-version/src/version/mod.rs similarity index 81% rename from crates/k8s-version/src/version.rs rename to crates/k8s-version/src/version/mod.rs index ab6a2cc90..a145e8a31 100644 --- a/crates/k8s-version/src/version.rs +++ b/crates/k8s-version/src/version/mod.rs @@ -1,12 +1,16 @@ use std::{cmp::Ordering, fmt::Display, num::ParseIntError, str::FromStr, sync::LazyLock}; -#[cfg(feature = "darling")] -use darling::FromMeta; use regex::Regex; use snafu::{OptionExt, ResultExt, Snafu}; use crate::{Level, ParseLevelError}; +#[cfg(feature = "serde")] +mod serde; + +#[cfg(feature = "darling")] +mod darling; + static VERSION_REGEX: LazyLock = LazyLock::new(|| { Regex::new(r"^v(?P\d+)(?P[a-z0-9][a-z0-9-]{0,60}[a-z0-9])?$") .expect("failed to compile version regex") @@ -103,13 +107,6 @@ impl Display for Version { } } -#[cfg(feature = "darling")] -impl FromMeta for Version { - fn from_string(value: &str) -> darling::Result { - Self::from_str(value).map_err(darling::Error::custom) - } -} - impl Version { pub fn new(major: u64, level: Option) -> Self { Self { major, level } @@ -118,19 +115,11 @@ impl Version { #[cfg(test)] mod test { - #[cfg(feature = "darling")] - use quote::quote; use rstest::rstest; use rstest_reuse::{apply, template}; use super::*; - #[cfg(feature = "darling")] - fn parse_meta(tokens: proc_macro2::TokenStream) -> ::std::result::Result { - let attribute: syn::Attribute = syn::parse_quote!(#[#tokens]); - Ok(attribute.meta) - } - #[template] #[rstest] #[case(Version {major: 1, level: Some(Level::Beta(1))}, Version {major: 1, level: Some(Level::Alpha(1))}, Ordering::Greater)] @@ -167,16 +156,4 @@ mod test { fn partial_ord(input: Version, other: Version, expected: Ordering) { assert_eq!(input.partial_cmp(&other), Some(expected)) } - - #[cfg(feature = "darling")] - #[rstest] - #[case(quote!(ignore = "v1alpha12"), Version { major: 1, level: Some(Level::Alpha(12)) })] - #[case(quote!(ignore = "v1alpha1"), Version { major: 1, level: Some(Level::Alpha(1)) })] - #[case(quote!(ignore = "v1beta1"), Version { major: 1, level: Some(Level::Beta(1)) })] - #[case(quote!(ignore = "v1"), Version { major: 1, level: None })] - fn from_meta(#[case] input: proc_macro2::TokenStream, #[case] expected: Version) { - let meta = parse_meta(input).expect("valid attribute tokens"); - let version = Version::from_meta(&meta).expect("version must parse from attribute"); - assert_eq!(version, expected); - } } diff --git a/crates/k8s-version/src/version/serde.rs b/crates/k8s-version/src/version/serde.rs new file mode 100644 index 000000000..88c7d98c3 --- /dev/null +++ b/crates/k8s-version/src/version/serde.rs @@ -0,0 +1,60 @@ +use std::str::FromStr; + +use serde::{Deserialize, Serialize, de::Visitor}; + +use crate::Version; + +impl<'de> Deserialize<'de> for Version { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct VersionVisitor; + + impl<'de> Visitor<'de> for VersionVisitor { + type Value = Version; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a valid Kubernetes API version") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + Version::from_str(v).map_err(serde::de::Error::custom) + } + } + + deserializer.deserialize_str(VersionVisitor) + } +} + +impl Serialize for Version { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +#[cfg(feature = "serde")] +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize() { + let _: Version = serde_yaml::from_str("v1alpha1").expect("version is valid"); + } + + #[test] + fn serialize() { + let api_version = Version::from_str("v1alpha1").expect("version is valid"); + assert_eq!( + "v1alpha1\n", + serde_yaml::to_string(&api_version).expect("version must serialize") + ); + } +}