[pbs-devel] [RFC PATCH] schema: allow serializing rust Schema to perl JsonSchema
Gabriel Goller
g.goller at proxmox.com
Wed May 7 18:31:14 CEST 2025
Implement serde::Serialize on the rust Schema, so that we can serialize
it and use it as a JsonSchema in perl. This allows us to write a single
Schema in rust and reuse it in perl for the api properties.
The interesting bits (custom impls) are:
* Recursive oneOf type-property resolver
* oneOf and allOf implementation
* ApiStringFormat skip of ApiStringVerifyFn (which won't work obviously)
Signed-off-by: Gabriel Goller <g.goller at proxmox.com>
---
This is kinda hard to test, because nothing actually fails when the properties
are wrong and the whole allOf, oneOf and ApiStringFormat is a bit
untransparent. So some properties could be wrongly serialized, but I
think I got everything right. Looking over all the properties would be
appreciated!
Cargo.toml | 1 +
proxmox-schema/Cargo.toml | 4 +-
proxmox-schema/src/const_regex.rs | 12 ++
proxmox-schema/src/schema.rs | 242 ++++++++++++++++++++++++++++--
4 files changed, 246 insertions(+), 13 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index 2ca0ea618707..a0d760ae8fc9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -104,6 +104,7 @@ regex = "1.5"
serde = "1.0"
serde_cbor = "0.11.1"
serde_json = "1.0"
+serde_with = "3.8.1"
serde_plain = "1.0"
syn = { version = "2", features = [ "full", "visit-mut" ] }
tar = "0.4"
diff --git a/proxmox-schema/Cargo.toml b/proxmox-schema/Cargo.toml
index c8028aa52bd0..48ebf3a9005e 100644
--- a/proxmox-schema/Cargo.toml
+++ b/proxmox-schema/Cargo.toml
@@ -15,8 +15,9 @@ rust-version.workspace = true
anyhow.workspace = true
const_format = { workspace = true, optional = true }
regex.workspace = true
-serde.workspace = true
+serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
+serde_with.workspace = true
textwrap = "0.16"
# the upid type needs this for 'getpid'
@@ -27,7 +28,6 @@ proxmox-api-macro = { workspace = true, optional = true }
[dev-dependencies]
url.workspace = true
-serde = { workspace = true, features = [ "derive" ] }
proxmox-api-macro.workspace = true
[features]
diff --git a/proxmox-schema/src/const_regex.rs b/proxmox-schema/src/const_regex.rs
index 8ddc41abedeb..56f6c27fa1de 100644
--- a/proxmox-schema/src/const_regex.rs
+++ b/proxmox-schema/src/const_regex.rs
@@ -1,5 +1,7 @@
use std::fmt;
+use serde::Serialize;
+
/// Helper to represent const regular expressions
///
/// The current Regex::new() function is not `const_fn`. Unless that
@@ -13,6 +15,16 @@ pub struct ConstRegexPattern {
pub regex_obj: fn() -> &'static regex::Regex,
}
+impl Serialize for ConstRegexPattern {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ // Get the compiled regex and serialize its pattern as a string
+ serializer.serialize_str((self.regex_obj)().as_str())
+ }
+}
+
impl fmt::Debug for ConstRegexPattern {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:?}", self.regex_string)
diff --git a/proxmox-schema/src/schema.rs b/proxmox-schema/src/schema.rs
index ddbbacd462a4..11461eaf6ace 100644
--- a/proxmox-schema/src/schema.rs
+++ b/proxmox-schema/src/schema.rs
@@ -4,10 +4,12 @@
//! completely static API definitions that can be included within the programs read-only text
//! segment.
-use std::collections::HashSet;
+use std::collections::{HashMap, HashSet};
use std::fmt;
use anyhow::{bail, format_err, Error};
+use serde::ser::{SerializeMap, SerializeStruct};
+use serde::{Serialize, Serializer};
use serde_json::{json, Value};
use crate::ConstRegexPattern;
@@ -181,7 +183,8 @@ impl<'a> FromIterator<(&'a str, Error)> for ParameterError {
}
/// Data type to describe boolean values
-#[derive(Debug)]
+#[serde_with::skip_serializing_none]
+#[derive(Debug, Serialize)]
#[cfg_attr(feature = "test-harness", derive(Eq, PartialEq))]
#[non_exhaustive]
pub struct BooleanSchema {
@@ -222,7 +225,8 @@ impl BooleanSchema {
}
/// Data type to describe integer values.
-#[derive(Debug)]
+#[serde_with::skip_serializing_none]
+#[derive(Debug, Serialize)]
#[cfg_attr(feature = "test-harness", derive(Eq, PartialEq))]
#[non_exhaustive]
pub struct IntegerSchema {
@@ -304,7 +308,8 @@ impl IntegerSchema {
}
/// Data type to describe (JSON like) number value
-#[derive(Debug)]
+#[serde_with::skip_serializing_none]
+#[derive(Debug, Serialize)]
#[non_exhaustive]
pub struct NumberSchema {
pub description: &'static str,
@@ -406,7 +411,8 @@ impl PartialEq for NumberSchema {
}
/// Data type to describe string values.
-#[derive(Debug)]
+#[serde_with::skip_serializing_none]
+#[derive(Debug, Serialize)]
#[cfg_attr(feature = "test-harness", derive(Eq, PartialEq))]
#[non_exhaustive]
pub struct StringSchema {
@@ -418,6 +424,7 @@ pub struct StringSchema {
/// Optional maximal length.
pub max_length: Option<usize>,
/// Optional microformat.
+ #[serde(flatten)]
pub format: Option<&'static ApiStringFormat>,
/// A text representation of the format/type (used to generate documentation).
pub type_text: Option<&'static str>,
@@ -534,7 +541,8 @@ impl StringSchema {
///
/// All array elements are of the same type, as defined in the `items`
/// schema.
-#[derive(Debug)]
+#[serde_with::skip_serializing_none]
+#[derive(Debug, Serialize)]
#[cfg_attr(feature = "test-harness", derive(Eq, PartialEq))]
#[non_exhaustive]
pub struct ArraySchema {
@@ -634,6 +642,43 @@ pub type SchemaPropertyEntry = (&'static str, bool, &'static Schema);
/// This is a workaround unless RUST can const_fn `Hash::new()`
pub type SchemaPropertyMap = &'static [SchemaPropertyEntry];
+/// A wrapper struct to hold the [`SchemaPropertyMap`] and serialize it nicely.
+///
+/// [`SchemaPropertyMap`] holds [`SchemaPropertyEntry`]s which are tuples. Tuples are serialized to
+/// arrays, but we need a Map with the name (first item in the tuple) as a key and the optional
+/// (second item in the tuple) as a property of the value.
+pub struct SerializableSchemaProperties<'a>(&'a [SchemaPropertyEntry]);
+
+impl Serialize for SerializableSchemaProperties<'_> {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ let mut seq = serializer.serialize_map(Some(self.0.len()))?;
+
+ for (name, optional, schema) in self.0 {
+ let schema_with_metadata = OptionalSchema {
+ optional: *optional,
+ schema,
+ };
+
+ seq.serialize_entry(&name, &schema_with_metadata)?;
+ }
+
+ seq.end()
+ }
+}
+
+/// A schema with a optional bool property.
+///
+/// The schema gets flattened, so it looks just like a normal Schema but with a optional property.
+#[derive(Serialize)]
+struct OptionalSchema<'a> {
+ optional: bool,
+ #[serde(flatten)]
+ schema: &'a Schema,
+}
+
const fn assert_properties_sorted(properties: SchemaPropertyMap) {
use std::cmp::Ordering;
@@ -656,7 +701,7 @@ const fn assert_properties_sorted(properties: SchemaPropertyMap) {
/// Legacy property strings may contain shortcuts where the *value* of a specific key is used as a
/// *key* for yet another option. Most notably, PVE's `netX` properties use `<model>=<macaddr>`
/// instead of `model=<model>,macaddr=<macaddr>`.
-#[derive(Clone, Copy, Debug)]
+#[derive(Clone, Copy, Debug, Serialize)]
#[cfg_attr(feature = "test-harness", derive(Eq, PartialEq))]
pub struct KeyAliasInfo {
pub key_alias: &'static str,
@@ -700,6 +745,77 @@ pub struct ObjectSchema {
pub key_alias_info: Option<KeyAliasInfo>,
}
+impl Serialize for ObjectSchema {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ let mut s = serializer.serialize_struct("ObjectSchema", 5)?;
+
+ s.serialize_field("description", self.description)?;
+ s.serialize_field("additional_properties", &self.additional_properties)?;
+
+ // Collect all OneOf type properties recursively
+ let mut oneofs: Vec<SchemaPropertyEntry> = Vec::new();
+ for (_, _, schema) in self.properties {
+ collect_oneof_type_properties(schema, &mut oneofs);
+ }
+
+ if !oneofs.is_empty() {
+ // Extend the oneOf type-properties with the actual properties
+ oneofs.extend_from_slice(self.properties);
+ s.serialize_field("properties", &SerializableSchemaProperties(&oneofs))?;
+ } else {
+ s.serialize_field("properties", &SerializableSchemaProperties(self.properties))?;
+ }
+
+ if let Some(default_key) = self.default_key {
+ s.serialize_field("default_key", default_key)?;
+ } else {
+ s.skip_field("default_key")?;
+ }
+ if let Some(key_alias_info) = self.key_alias_info {
+ s.serialize_field("key_alias_info", &key_alias_info)?;
+ } else {
+ s.skip_field("key_alias_info")?;
+ }
+
+ s.end()
+ }
+}
+
+// Recursive function to find all OneOf type properties in a schema
+fn collect_oneof_type_properties(schema: &Schema, result: &mut Vec<SchemaPropertyEntry>) {
+ match schema {
+ Schema::OneOf(oneof) => {
+ result.push(*oneof.type_property_entry);
+ }
+ Schema::Array(array) => {
+ // Recursively check the array schema
+ collect_oneof_type_properties(array.items, result);
+ }
+ Schema::String(string) => {
+ // Check the PropertyString Schema
+ if let Some(ApiStringFormat::PropertyString(schema)) = string.format {
+ collect_oneof_type_properties(schema, result);
+ }
+ }
+ Schema::Object(obj) => {
+ // Check all properties in the object
+ for (_, _, prop_schema) in obj.properties {
+ collect_oneof_type_properties(prop_schema, result);
+ }
+ }
+ Schema::AllOf(all_of) => {
+ // Check all schemas in the allOf list
+ for &schema in all_of.list {
+ collect_oneof_type_properties(schema, result);
+ }
+ }
+ _ => {}
+ }
+}
+
impl ObjectSchema {
/// Create a new `object` schema.
///
@@ -811,7 +927,7 @@ impl ObjectSchema {
///
/// Technically this could also contain an `additional_properties` flag, however, in the JSON
/// Schema, this is not supported, so here we simply assume additional properties to be allowed.
-#[derive(Debug)]
+#[derive(Debug, Serialize)]
#[cfg_attr(feature = "test-harness", derive(Eq, PartialEq))]
#[non_exhaustive]
pub struct AllOfSchema {
@@ -864,7 +980,7 @@ impl AllOfSchema {
/// In serde-language, we use an internally tagged enum representation.
///
/// Note that these are limited to object schemas. Other schemas will produce errors.
-#[derive(Debug)]
+#[derive(Debug, Serialize)]
#[cfg_attr(feature = "test-harness", derive(Eq, PartialEq))]
#[non_exhaustive]
pub struct OneOfSchema {
@@ -880,6 +996,30 @@ pub struct OneOfSchema {
pub list: &'static [(&'static str, &'static Schema)],
}
+fn serialize_oneof_schema<S>(one_of: &OneOfSchema, serializer: S) -> Result<S::Ok, S::Error>
+where
+ S: Serializer,
+{
+ use serde::ser::SerializeMap;
+
+ let mut map = serializer.serialize_map(Some(3))?;
+
+ map.serialize_entry("description", &one_of.description)?;
+
+ let variants = one_of
+ .list
+ .iter()
+ .map(|(_, schema)| schema)
+ .collect::<Vec<_>>();
+
+ map.serialize_entry("oneOf", &variants)?;
+
+ // The schema gets inserted into the parent properties
+ map.serialize_entry("type-property", &one_of.type_property_entry.0)?;
+
+ map.end()
+}
+
const fn assert_one_of_list_is_sorted(list: &[(&str, &Schema)]) {
use std::cmp::Ordering;
@@ -1360,7 +1500,8 @@ impl Iterator for OneOfPropertyIterator {
/// ],
/// ).schema();
/// ```
-#[derive(Debug)]
+#[derive(Debug, Serialize)]
+#[serde(tag = "type", rename_all = "lowercase")]
#[cfg_attr(feature = "test-harness", derive(Eq, PartialEq))]
pub enum Schema {
Null,
@@ -1370,10 +1511,81 @@ pub enum Schema {
String(StringSchema),
Object(ObjectSchema),
Array(ArraySchema),
+ #[serde(serialize_with = "serialize_allof_schema")]
AllOf(AllOfSchema),
+ #[serde(untagged)]
+ #[serde(serialize_with = "serialize_oneof_schema", rename = "oneOf")]
OneOf(OneOfSchema),
}
+/// Serialize the AllOf Schema
+///
+/// This will create one ObjectSchema and merge the properties of all the children.
+fn serialize_allof_schema<S>(all_of: &AllOfSchema, serializer: S) -> Result<S::Ok, S::Error>
+where
+ S: Serializer,
+{
+ use serde::ser::SerializeMap;
+
+ let mut map = serializer.serialize_map(Some(4))?;
+
+ // Add the top-level description
+ map.serialize_entry("description", &all_of.description)?;
+
+ // The type is always object
+ map.serialize_entry("type", "object")?;
+
+ let mut all_properties = HashMap::new();
+ let mut additional_properties = false;
+
+ for &schema in all_of.list {
+ if let Some(object_schema) = schema.object() {
+ // If any schema allows additional properties, the merged schema will too
+ if object_schema.additional_properties {
+ additional_properties = true;
+ }
+
+ // Add all properties from this schema
+ for (name, optional, prop_schema) in object_schema.properties {
+ all_properties.insert(*name, (*optional, *prop_schema));
+ }
+ } else if let Some(nested_all_of) = schema.all_of() {
+ // For nested AllOf schemas go through recursively
+ for &nested_schema in nested_all_of.list {
+ if let Some(object_schema) = nested_schema.object() {
+ if object_schema.additional_properties {
+ additional_properties = true;
+ }
+
+ for (name, optional, prop_schema) in object_schema.properties {
+ all_properties.insert(*name, (*optional, *prop_schema));
+ }
+ }
+ }
+ }
+ }
+
+ // Add the merged properties
+ let properties_entry = all_properties
+ .iter()
+ .map(|(name, (optional, schema))| {
+ (
+ *name,
+ OptionalSchema {
+ optional: *optional,
+ schema,
+ },
+ )
+ })
+ .collect::<HashMap<_, _>>();
+
+ map.serialize_entry("properties", &properties_entry)?;
+
+ map.serialize_entry("additional_properties", &additional_properties)?;
+
+ map.end()
+}
+
impl Schema {
/// Verify JSON value with `schema`.
pub fn verify_json(&self, data: &Value) -> Result<(), Error> {
@@ -1694,10 +1906,12 @@ impl Schema {
}
/// A string enum entry. An enum entry must have a value and a description.
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, Serialize)]
+#[serde(transparent)]
#[cfg_attr(feature = "test-harness", derive(Eq, PartialEq))]
pub struct EnumEntry {
pub value: &'static str,
+ #[serde(skip)]
pub description: &'static str,
}
@@ -1776,14 +1990,20 @@ impl EnumEntry {
/// let data = PRODUCT_LIST_SCHEMA.parse_property_string("1,2"); // parse as Array
/// assert!(data.is_ok());
/// ```
+#[derive(Serialize)]
pub enum ApiStringFormat {
/// Enumerate all valid strings
+ #[serde(rename = "enum")]
Enum(&'static [EnumEntry]),
/// Use a regular expression to describe valid strings.
+ #[serde(rename = "pattern")]
Pattern(&'static ConstRegexPattern),
/// Use a schema to describe complex types encoded as string.
+ #[serde(rename = "format")]
PropertyString(&'static Schema),
/// Use a verification function.
+ /// Note: we can't serialize this, panic if we encounter this.
+ #[serde(skip)]
VerifyFn(ApiStringVerifyFn),
}
--
2.39.5
More information about the pbs-devel
mailing list