api-macro: allow declaring an additional-properties field

Object schemas can now declare a field which causes
'additional_properties' to be set to true and the field being ignored
in the schema.

This allows adding a flattened HashMap<String, Value> to gather the
additional unspecified properties.

    #[api(additional_properties: "rest")]
    struct Something {
        #[serde(flatten)]
        rest: HashMap<String, Value>,
    }

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
This commit is contained in:
Wolfgang Bumiller 2024-09-26 12:41:08 +02:00
parent e72528ca70
commit 2b3c356ece
3 changed files with 97 additions and 0 deletions

View File

@ -357,6 +357,13 @@ impl SchemaItem {
ts.extend(quote_spanned! { obj.span =>
::proxmox_schema::ObjectSchema::new(#description, &[#elems])
});
if obj
.additional_properties
.as_ref()
.is_some_and(|a| a.to_bool())
{
ts.extend(quote_spanned! { obj.span => .additional_properties(true) });
}
}
SchemaItem::Array(array) => {
let description = check_description()?;
@ -516,6 +523,51 @@ impl ObjectEntry {
pub struct SchemaObject {
span: Span,
properties_: Vec<ObjectEntry>,
additional_properties: Option<AdditionalProperties>,
}
#[derive(Clone)]
pub enum AdditionalProperties {
/// `additional_properties: false`.
No,
/// `additional_properties: true`.
Ignored,
/// `additional_properties: "field_name"`.
Field(syn::LitStr),
}
impl TryFrom<JSONValue> for AdditionalProperties {
type Error = syn::Error;
fn try_from(value: JSONValue) -> Result<Self, Self::Error> {
let span = value.span();
if let JSONValue::Expr(syn::Expr::Lit(expr_lit)) = value {
match expr_lit.lit {
syn::Lit::Str(s) => return Ok(Self::Field(s)),
syn::Lit::Bool(b) => {
return Ok(if b.value() { Self::Ignored } else { Self::No });
}
_ => (),
}
}
bail!(
span,
"invalid value for additional_properties, expected boolean or field name"
);
}
}
impl AdditionalProperties {
pub fn to_option_string(&self) -> Option<String> {
match self {
Self::Field(name) => Some(name.value()),
_ => None,
}
}
pub fn to_bool(&self) -> bool {
!matches!(self, Self::No)
}
}
impl SchemaObject {
@ -523,6 +575,7 @@ impl SchemaObject {
Self {
span,
properties_: Vec::new(),
additional_properties: None,
}
}
@ -574,6 +627,10 @@ impl SchemaObject {
fn try_extract_from(obj: &mut JSONObject) -> Result<Self, syn::Error> {
let mut this = Self {
span: obj.span(),
additional_properties: obj
.remove("additional_properties")
.map(AdditionalProperties::try_from)
.transpose()?,
properties_: obj
.remove_required_element("properties")?
.into_object("object field definition")?

View File

@ -141,9 +141,15 @@ fn handle_regular_struct(
// fields if there are any.
let mut schema_fields: HashMap<String, &mut ObjectEntry> = HashMap::new();
let mut additional_properties = None;
// We also keep a reference to the SchemaObject around since we derive missing fields
// automatically.
if let SchemaItem::Object(obj) = &mut schema.item {
additional_properties = obj
.additional_properties
.as_ref()
.and_then(|a| a.to_option_string());
for field in obj.properties_mut() {
schema_fields.insert(field.name.as_str().to_string(), field);
}
@ -178,6 +184,12 @@ fn handle_regular_struct(
}
};
if additional_properties.as_deref() == Some(name.as_ref()) {
// we just *skip* the additional properties field, it is supposed to be a flattened
// `HashMap<String, Value>` collecting all the values that have no schema
continue;
}
match schema_fields.remove(&name) {
Some(field_def) => {
if attrs.flatten {

View File

@ -3,6 +3,8 @@
#![allow(dead_code)]
use std::collections::HashMap;
use proxmox_api_macro::api;
use proxmox_schema as schema;
use proxmox_schema::{ApiType, EnumEntry};
@ -11,6 +13,8 @@ use anyhow::Error;
use serde::Deserialize;
use serde_json::Value;
pub const TEXT_SCHEMA: schema::Schema = schema::StringSchema::new("Text.").schema();
#[api(
type: String,
description: "A string",
@ -186,3 +190,27 @@ fn string_check_schema_test() {
pub struct RenamedAndDescribed {
a_field: String,
}
#[api(
properties: {},
additional_properties: "rest",
)]
#[derive(Deserialize)]
/// Some Description.
pub struct UnspecifiedData {
/// Text.
field: String,
/// Remaining data.
rest: HashMap<String, Value>,
}
#[test]
fn additional_properties_test() {
const TEST_UNSPECIFIED: ::proxmox_schema::Schema =
::proxmox_schema::ObjectSchema::new("Some Description.", &[("field", false, &TEXT_SCHEMA)])
.additional_properties(true)
.schema();
assert_eq!(TEST_UNSPECIFIED, UnspecifiedData::API_SCHEMA);
}