section-config, api-macro: add SectionConfig enum support

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
This commit is contained in:
Wolfgang Bumiller 2024-07-31 16:14:20 +02:00
parent 51d78fdd2b
commit 4d96aa52d2
6 changed files with 554 additions and 11 deletions

View File

@ -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

View File

@ -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<TokenStream, Error> {
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<TokenStream, Error> {
@ -114,3 +155,127 @@ pub fn handle_enum(
}
})
}
fn handle_section_config_enum(
mut attribs: JSONObject,
enum_ty: syn::ItemEnum,
) -> Result<TokenStream, Error> {
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 {
<Self as ::proxmox_schema::ApiType>::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
}
}
}
})
}

View File

@ -128,6 +128,7 @@ impl RenameAll {
#[derive(Default)]
pub struct ContainerAttrib {
pub rename_all: Option<RenameAll>,
pub tag: Option<syn::LitStr>,
}
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"),
}
}
}

View File

@ -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);
}

View File

@ -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();

View File

@ -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<Self, serde_json::Error>
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::<Self>(value)
}
_ => {
use serde::ser::Error;
Err(serde_json::Error::custom(
"cannot add type property to non-object",
))
}
}
} else {
serde_json::from_value::<Self>(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<SectionConfigData<Self>, 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<Self>) -> Result<String, Error>
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<T> {
pub sections: HashMap<String, T>,
pub order: Vec<String>,
}
impl<T> Default for SectionConfigData<T> {
fn default() -> Self {
Self {
sections: HashMap::new(),
order: Vec::new(),
}
}
}
impl<T: ApiSectionDataEntry + DeserializeOwned> TryFrom<RawSectionConfigData>
for SectionConfigData<T>
{
type Error = serde_json::Error;
fn try_from(data: RawSectionConfigData) -> Result<Self, serde_json::Error> {
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<T> std::ops::Deref for SectionConfigData<T> {
type Target = HashMap<String, T>;
fn deref(&self) -> &Self::Target {
&self.sections
}
}
impl<T> std::ops::DerefMut for SectionConfigData<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.sections
}
}
impl<T: Serialize + ApiSectionDataEntry> TryFrom<SectionConfigData<T>> for RawSectionConfigData {
type Error = serde_json::Error;
fn try_from(data: SectionConfigData<T>) -> Result<Self, serde_json::Error> {
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<T: Serialize + ApiSectionDataEntry> TryFrom<&SectionConfigData<T>> for RawSectionConfigData {
type Error = serde_json::Error;
fn try_from(data: &SectionConfigData<T>) -> Result<Self, serde_json::Error> {
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<T: ApiSectionDataEntry> From<HashMap<String, T>> for SectionConfigData<T> {
fn from(sections: HashMap<String, T>) -> Self {
Self {
sections,
order: Vec::new(),
}
}
}
/// Creates a data set ordered the same way as the iterator.
impl<T: ApiSectionDataEntry> FromIterator<(String, T)> for SectionConfigData<T> {
fn from_iter<I: IntoIterator<Item = (String, T)>>(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<T> IntoIterator for SectionConfigData<T> {
type IntoIter = IntoIter<T>;
type Item = (String, T);
fn into_iter(self) -> IntoIter<T> {
IntoIter {
sections: self.sections,
order: self.order.into_iter(),
}
}
}
/// Iterates over the sections in their original order.
pub struct IntoIter<T> {
sections: HashMap<String, T>,
order: std::vec::IntoIter<String>,
}
impl<T> Iterator for IntoIter<T> {
type Item = (String, T);
fn next(&mut self) -> Option<Self::Item> {
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<T> {
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<String, T>,
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<Self::Item> {
loop {
let id = self.order.next()?;
if let Some(data) = self.sections.get(id) {
return Some((id, data));
}
}
}
}