From 4d96aa52d2e22c3194e8200bb11ed43b6e846e87 Mon Sep 17 00:00:00 2001 From: Wolfgang Bumiller Date: Wed, 31 Jul 2024 16:14:20 +0200 Subject: [PATCH] section-config, api-macro: add SectionConfig enum support Signed-off-by: Wolfgang Bumiller --- proxmox-api-macro/Cargo.toml | 1 + proxmox-api-macro/src/api/enums.rs | 167 ++++++++++++- proxmox-api-macro/src/serde.rs | 31 ++- proxmox-api-macro/tests/section-config.rs | 75 ++++++ proxmox-section-config/src/lib.rs | 2 + proxmox-section-config/src/typed.rs | 289 ++++++++++++++++++++++ 6 files changed, 554 insertions(+), 11 deletions(-) create mode 100644 proxmox-api-macro/tests/section-config.rs create mode 100644 proxmox-section-config/src/typed.rs diff --git a/proxmox-api-macro/Cargo.toml b/proxmox-api-macro/Cargo.toml index 09cae781..2e3c3993 100644 --- a/proxmox-api-macro/Cargo.toml +++ b/proxmox-api-macro/Cargo.toml @@ -22,6 +22,7 @@ syn = { workspace = true , features = [ "extra-traits" ] } futures.workspace = true serde = { workspace = true, features = [ "derive" ] } serde_json.workspace = true +proxmox-section-config.workspace = true [dev-dependencies.proxmox-schema] workspace = true diff --git a/proxmox-api-macro/src/api/enums.rs b/proxmox-api-macro/src/api/enums.rs index 2ad7ac82..e42c886c 100644 --- a/proxmox-api-macro/src/api/enums.rs +++ b/proxmox-api-macro/src/api/enums.rs @@ -4,13 +4,54 @@ use anyhow::Error; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote_spanned; +use syn::spanned::Spanned; use super::Schema; use crate::serde; use crate::util::{self, FieldName, JSONObject, JSONValue, Maybe}; /// Enums, provided they're simple enums, simply get an enum string schema attached to them. -pub fn handle_enum( +pub fn handle_enum(attribs: JSONObject, enum_ty: syn::ItemEnum) -> Result { + let mut first_unit = None; + let mut first_unnamed = None; + let mut first_named = None; + for variant in &enum_ty.variants { + match &variant.fields { + syn::Fields::Unit => first_unit = Some(variant.fields.span()), + syn::Fields::Unnamed(_) => first_unnamed = Some(variant.fields.span()), + syn::Fields::Named(_) => first_named = Some(variant.fields.span()), + } + } + + if first_unit.is_some() { + if let Some(conflict) = first_unnamed.or(first_named) { + bail!( + conflict, + "enums must be either with only unit types or only newtypes" + ); + } + return handle_string_enum(attribs, enum_ty); + } + + if first_unnamed.is_some() { + if let Some(conflict) = first_unit.or(first_named) { + bail!( + conflict, + "enums must be either with only unit types or only newtypes" + ); + } + return handle_section_config_enum(attribs, enum_ty); + } + + if let Some(bad) = first_named { + bail!(bad, "api type enums with named fields are not allowed"); + } + + bail!(enum_ty => "api type enums must not be empty"); +} + +/// Enums, provided they're simple enums, simply get an enum string schema attached to them. +fn handle_string_enum( mut attribs: JSONObject, mut enum_ty: syn::ItemEnum, ) -> Result { @@ -114,3 +155,127 @@ pub fn handle_enum( } }) } + +fn handle_section_config_enum( + mut attribs: JSONObject, + enum_ty: syn::ItemEnum, +) -> Result { + let name = &enum_ty.ident; + + let description: syn::LitStr = match attribs.remove("description") { + Some(desc) => desc.try_into()?, + None => { + let (comment, span) = util::get_doc_comments(&enum_ty.attrs)?; + syn::LitStr::new(comment.trim(), span) + } + }; + + let id_schema = { + let schema: Schema = match attribs.remove("id-schema") { + Some(schema) => schema.try_into()?, + None => { + bail!(name => "missing 'id-schema' property for SectionConfig style enum") + } + }; + + let mut ts = TokenStream::new(); + schema.to_typed_schema(&mut ts)?; + ts + }; + let id_property: syn::LitStr = match attribs.remove("id-property") { + Some(name) => name.try_into()?, + None => bail!(name => "missing 'id-property' property for SectionConfig style enum"), + }; + + let container_attrs = serde::ContainerAttrib::try_from(&enum_ty.attrs[..])?; + let Some(tag) = container_attrs.tag.as_ref() else { + bail!(name => r#"SectionConfig enum needs a `#[serde(tag = "...")]` container attribute"#); + }; + + let mut variants = TokenStream::new(); + let mut register_sections = TokenStream::new(); + let mut to_type = TokenStream::new(); + for variant in &enum_ty.variants { + let field = match &variant.fields { + syn::Fields::Unnamed(field) if field.unnamed.len() == 1 => &field.unnamed[0], + _ => bail!(variant => "SectionConfig style enum can only have newtype variants"), + }; + + let attrs = serde::VariantAttrib::try_from(&variant.attrs[..])?; + let variant_string = if let Some(renamed) = attrs.rename { + renamed + } else if let Some(rename_all) = container_attrs.rename_all { + let name = rename_all.apply_to_variant(&variant.ident.to_string()); + syn::LitStr::new(&name, variant.ident.span()) + } else { + let name = &variant.ident; + syn::LitStr::new(&name.to_string(), name.span()) + }; + + let variant_ident = &variant.ident; + let ty = &field.ty; + variants.extend(quote_spanned! { variant.ident.span() => + ( + #variant_string, + &<#ty as ::proxmox_schema::ApiType>::API_SCHEMA, + ), + }); + register_sections.extend(quote_spanned! { variant.ident.span() => + this.register_plugin(::proxmox_section_config::SectionConfigPlugin::new( + #variant_string.to_string(), + Some(#id_property.to_string()), + const { + match &<#ty as ::proxmox_schema::ApiType>::API_SCHEMA { + ::proxmox_schema::Schema::Object(schema) => schema, + ::proxmox_schema::Schema::OneOf(schema) => schema, + _ => panic!("enum requires an object schema"), + } + } + )); + }); + to_type.extend(quote_spanned! { variant.ident.span() => + Self::#variant_ident(_) => #variant_string, + }); + } + + Ok(quote_spanned! { name.span() => + #enum_ty + + impl ::proxmox_schema::ApiType for #name { + const API_SCHEMA: ::proxmox_schema::Schema = + ::proxmox_schema::OneOfSchema::new( + #description, + &(#tag, false, &#id_schema.schema()), + &[#variants], + ) + .schema(); + } + + impl ::proxmox_section_config::typed::ApiSectionDataEntry for #name { + const INTERNALLY_TAGGED: Option<&'static str> = Some(#tag); + + fn section_config() -> &'static ::proxmox_section_config::SectionConfig { + static CONFIG: ::std::sync::OnceLock<::proxmox_section_config::SectionConfig> = + ::std::sync::OnceLock::new(); + + CONFIG.get_or_init(|| { + let id_schema = const { + ::API_SCHEMA + .unwrap_one_of_schema() + .type_property_entry + .2 + }; + let mut this = ::proxmox_section_config::SectionConfig::new(id_schema); + #register_sections + this + }) + } + + fn section_type(&self) -> &'static str { + match self { + #to_type + } + } + } + }) +} diff --git a/proxmox-api-macro/src/serde.rs b/proxmox-api-macro/src/serde.rs index 2821f8d5..62430f16 100644 --- a/proxmox-api-macro/src/serde.rs +++ b/proxmox-api-macro/src/serde.rs @@ -128,6 +128,7 @@ impl RenameAll { #[derive(Default)] pub struct ContainerAttrib { pub rename_all: Option, + pub tag: Option, } impl TryFrom<&[syn::Attribute]> for ContainerAttrib { @@ -147,18 +148,28 @@ impl TryFrom<&[syn::Attribute]> for ContainerAttrib { for arg in args { if let syn::Meta::NameValue(var) = arg { - if !var.path.is_ident("rename_all") { - continue; - } - match &var.value { - syn::Expr::Lit(lit) => { - let rename_all = RenameAll::try_from(&lit.lit)?; - if this.rename_all.is_some() && this.rename_all != Some(rename_all) { - error!(var.value => "multiple conflicting 'rename_all' attributes"); + if var.path.is_ident("rename_all") { + match &var.value { + syn::Expr::Lit(lit) => { + let rename_all = RenameAll::try_from(&lit.lit)?; + if this.rename_all.is_some() && this.rename_all != Some(rename_all) + { + error!(var.value => "multiple conflicting 'rename_all' attributes"); + } + this.rename_all = Some(rename_all); } - this.rename_all = Some(rename_all); + _ => error!(var.value => "invalid 'rename_all' value type"), + } + } else if var.path.is_ident("tag") { + match &var.value { + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(lit), + .. + }) => { + this.tag = Some(lit.clone()); + } + _ => error!(var.value => "invalid 'tag' value type"), } - _ => error!(var.value => "invalid 'rename_all' value type"), } } } diff --git a/proxmox-api-macro/tests/section-config.rs b/proxmox-api-macro/tests/section-config.rs new file mode 100644 index 00000000..3f5b3d1e --- /dev/null +++ b/proxmox-api-macro/tests/section-config.rs @@ -0,0 +1,75 @@ +use serde::{Deserialize, Serialize}; + +use proxmox_api_macro::api; +use proxmox_section_config::typed::ApiSectionDataEntry; + +#[api] +/// Type A. +#[derive(Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct TypeA { + /// The id. + id: String, + + /// Some name. + name: String, +} + +#[api] +/// Type B. +#[derive(Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct TypeB { + /// The id. + id: String, + + /// An age. + age: u64, +} + +#[api( + "id-property": "id", + "id-schema": { + type: String, + description: "A config ID", + max_length: 16, + }, +)] +#[derive(Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(tag = "type")] +pub enum Config { + A(TypeA), + B(TypeB), +} + +#[test] +fn test_config() { + let content = "\ + A: the-a\n\ + \tname The Name\n\ + \n\ + B: the-b\n\ + \tage 42\n\ + "; + + let data = Config::parse_section_config("a_test_file.cfg", content) + .expect("failed to parse test section config"); + + assert_eq!(data.len(), 2); + assert_eq!( + data["the-a"], + Config::A(TypeA { + id: "the-a".to_string(), + name: "The Name".to_string(), + }) + ); + assert_eq!( + data["the-b"], + Config::B(TypeB { + id: "the-b".to_string(), + age: 42, + }) + ); + + let raw = Config::write_section_config("a_test_output_file.cfg", &data) + .expect("failed to write out test section config"); + assert_eq!(raw, content); +} diff --git a/proxmox-section-config/src/lib.rs b/proxmox-section-config/src/lib.rs index b61ed7f2..633ca49e 100644 --- a/proxmox-section-config/src/lib.rs +++ b/proxmox-section-config/src/lib.rs @@ -33,6 +33,8 @@ use proxmox_lang::try_block; use proxmox_schema::format::{dump_properties, wrap_text, ParameterDisplayStyle}; use proxmox_schema::*; +pub mod typed; + /// Used for additional properties when the schema allows them. const ADDITIONAL_PROPERTY_SCHEMA: Schema = StringSchema::new("Additional property").schema(); diff --git a/proxmox-section-config/src/typed.rs b/proxmox-section-config/src/typed.rs new file mode 100644 index 00000000..e6609b90 --- /dev/null +++ b/proxmox-section-config/src/typed.rs @@ -0,0 +1,289 @@ +//! Support for `enum` typed section configs. + +use std::collections::HashMap; + +use anyhow::Error; +use serde::{de::DeserializeOwned, Serialize}; +use serde_json::{json, Value}; + +use crate::SectionConfig; +use crate::SectionConfigData as RawSectionConfigData; + +/// Implement this for an enum to allow it to be used as a section config. +pub trait ApiSectionDataEntry: Sized { + /// If the serde representation is internally tagged, this should be the name of the type + /// property. + const INTERNALLY_TAGGED: Option<&'static str> = None; + + /// Get the `SectionConfig` configuration for this enum. + fn section_config() -> &'static SectionConfig; + + /// Maps an enum value to its type name. + fn section_type(&self) -> &'static str; + + /// Provided. If necessary, insert the "type" property into an object and then deserialize it. + fn from_value(ty: String, mut value: Value) -> Result + where + Self: serde::de::DeserializeOwned, + { + if let Some(tag) = Self::INTERNALLY_TAGGED { + match &mut value { + Value::Object(obj) => { + obj.insert(tag.to_string(), ty.into()); + serde_json::from_value::(value) + } + _ => { + use serde::ser::Error; + Err(serde_json::Error::custom( + "cannot add type property to non-object", + )) + } + } + } else { + serde_json::from_value::(json!({ ty: value })) + } + } + + /// Turn this entry into a pair of `(type, value)`. + /// Works for externally tagged objects and internally tagged objects, provided the + /// `INTERNALLY_TAGGED` value is set. + fn into_pair(self) -> Result<(String, Value), serde_json::Error> + where + Self: Serialize, + { + to_pair(serde_json::to_value(self)?, Self::INTERNALLY_TAGGED) + } + + /// Turn this entry into a pair of `(type, value)`. + /// Works for externally tagged objects and internally tagged objects, provided the + /// `INTERNALLY_TAGGED` value is set. + fn to_pair(&self) -> Result<(String, Value), serde_json::Error> + where + Self: Serialize, + { + to_pair(serde_json::to_value(self)?, Self::INTERNALLY_TAGGED) + } + + /// Provided. Shortcut for `Self::section_config().parse(filename, data)?.try_into()`. + fn parse_section_config(filename: &str, data: &str) -> Result, Error> + where + Self: serde::de::DeserializeOwned, + { + Ok(Self::section_config().parse(filename, data)?.try_into()?) + } + + /// Provided. Shortcut for `Self::section_config().write(filename, &data.try_into()?)`. + fn write_section_config(filename: &str, data: &SectionConfigData) -> Result + where + Self: Serialize, + { + Self::section_config().write(filename, &data.try_into()?) + } +} + +/// Turn an object into a `(type, value)` pair. +/// +/// For internally tagged objects (`tag` is `Some`), the type is *extracted* first. It is then no +/// longer present in the object itself. +/// +/// Otherwise, an externally typed object is expected, which means a map with a single entry, with +/// the type being the key. +/// +/// Otherwise this will fail. +fn to_pair(value: Value, tag: Option<&'static str>) -> Result<(String, Value), serde_json::Error> { + use serde::ser::Error; + + match (value, tag) { + (Value::Object(mut obj), Some(tag)) => { + let id = obj + .remove(tag) + .ok_or_else(|| Error::custom(format!("tag {tag:?} missing in object")))?; + match id { + Value::String(id) => Ok((id, Value::Object(obj))), + _ => Err(Error::custom(format!( + "tag {tag:?} has invalid value (not a string)" + ))), + } + } + (Value::Object(obj), None) if obj.len() == 1 => Ok( + obj.into_iter().next().unwrap(), // unwrap: we checked the length + ), + _ => Err(Error::custom("unexpected serialization method")), + } +} + +/// Typed variant of [`SectionConfigData`](proxmox_section_config::SectionConfigData). +/// This dereferences to the section hash for convenience. +#[derive(Debug, Clone)] +pub struct SectionConfigData { + pub sections: HashMap, + pub order: Vec, +} + +impl Default for SectionConfigData { + fn default() -> Self { + Self { + sections: HashMap::new(), + order: Vec::new(), + } + } +} + +impl TryFrom + for SectionConfigData +{ + type Error = serde_json::Error; + + fn try_from(data: RawSectionConfigData) -> Result { + let sections = + data.sections + .into_iter() + .try_fold(HashMap::new(), |mut acc, (id, (ty, value))| { + acc.insert(id, T::from_value(ty, value)?); + Ok::<_, serde_json::Error>(acc) + })?; + Ok(Self { + sections, + order: data.order, + }) + } +} + +impl std::ops::Deref for SectionConfigData { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.sections + } +} + +impl std::ops::DerefMut for SectionConfigData { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.sections + } +} + +impl TryFrom> for RawSectionConfigData { + type Error = serde_json::Error; + + fn try_from(data: SectionConfigData) -> Result { + let sections = + data.sections + .into_iter() + .try_fold(HashMap::new(), |mut acc, (id, value)| { + acc.insert(id, value.into_pair()?); + Ok::<_, serde_json::Error>(acc) + })?; + + Ok(Self { + sections, + order: data.order, + }) + } +} + +impl TryFrom<&SectionConfigData> for RawSectionConfigData { + type Error = serde_json::Error; + + fn try_from(data: &SectionConfigData) -> Result { + let sections = data + .sections + .iter() + .try_fold(HashMap::new(), |mut acc, (id, value)| { + acc.insert(id.clone(), value.to_pair()?); + Ok::<_, serde_json::Error>(acc) + })?; + + Ok(Self { + sections, + order: data.order.clone(), + }) + } +} + +/// Creates an unordered data set. +impl From> for SectionConfigData { + fn from(sections: HashMap) -> Self { + Self { + sections, + order: Vec::new(), + } + } +} + +/// Creates a data set ordered the same way as the iterator. +impl FromIterator<(String, T)> for SectionConfigData { + fn from_iter>(iter: I) -> Self { + let mut sections = HashMap::new(); + let mut order = Vec::new(); + + for (key, value) in iter { + order.push(key.clone()); + sections.insert(key, value); + } + + Self { sections, order } + } +} + +impl IntoIterator for SectionConfigData { + type IntoIter = IntoIter; + type Item = (String, T); + + fn into_iter(self) -> IntoIter { + IntoIter { + sections: self.sections, + order: self.order.into_iter(), + } + } +} + +/// Iterates over the sections in their original order. +pub struct IntoIter { + sections: HashMap, + order: std::vec::IntoIter, +} + +impl Iterator for IntoIter { + type Item = (String, T); + + fn next(&mut self) -> Option { + loop { + let id = self.order.next()?; + if let Some(data) = self.sections.remove(&id) { + return Some((id, data)); + } + } + } +} + +impl<'a, T> IntoIterator for &'a SectionConfigData { + type IntoIter = Iter<'a, T>; + type Item = (&'a str, &'a T); + + fn into_iter(self) -> Iter<'a, T> { + Iter { + sections: &self.sections, + order: self.order.iter(), + } + } +} + +/// Iterates over the sections in their original order. +pub struct Iter<'a, T> { + sections: &'a HashMap, + order: std::slice::Iter<'a, String>, +} + +impl<'a, T> Iterator for Iter<'a, T> { + type Item = (&'a str, &'a T); + + fn next(&mut self) -> Option { + loop { + let id = self.order.next()?; + if let Some(data) = self.sections.get(id) { + return Some((id, data)); + } + } + } +}