[pbs-devel] [PATCH proxmox] schema: implement Serialize for Schema w/ perl support

Wolfgang Bumiller w.bumiller at proxmox.com
Tue Mar 29 10:56:50 CEST 2022


This implements `Serialize` for the Schema, optionally
enabling perl compatibility via xsubs for the validator
methods.

NOTE: Currently, when serializing to perl, patterns are
serialized as regex strings and thus subject to differences
between perl and the regex crate. We may want to instead
warp these in xsubs running the regex crate's pattern
matcher instead, in which case perl wouldn't see the pattern
regex at all.

Also NOTE: The 'default_key' object schema property is moved
into the corresponding key as a boolean as we do in the
JSONSchema in perl.

Signed-off-by: Wolfgang Bumiller <w.bumiller at proxmox.com>
---
This also allows us to simply drop the custom serialization in the
`docgen` binary in pbs, and is otherwise a big step towards using rust
schemas in perl code.

 proxmox-schema/Cargo.toml                   |   2 +
 proxmox-schema/src/schema.rs                | 253 +++++++++++++++++++-
 proxmox-schema/tests/schema_verification.rs |  30 +++
 3 files changed, 280 insertions(+), 5 deletions(-)

diff --git a/proxmox-schema/Cargo.toml b/proxmox-schema/Cargo.toml
index 19d35e2..7204db3 100644
--- a/proxmox-schema/Cargo.toml
+++ b/proxmox-schema/Cargo.toml
@@ -22,6 +22,8 @@ nix = { version = "0.19", optional = true }
 
 proxmox-api-macro = { path = "../proxmox-api-macro", optional = true, version = "1.0.0" }
 
+perlmod = { version = "0.13.1", optional = true }
+
 [dev-dependencies]
 url = "2.1"
 serde = { version = "1.0", features = [ "derive" ] }
diff --git a/proxmox-schema/src/schema.rs b/proxmox-schema/src/schema.rs
index 39aca45..b41742a 100644
--- a/proxmox-schema/src/schema.rs
+++ b/proxmox-schema/src/schema.rs
@@ -7,6 +7,7 @@
 use std::fmt;
 
 use anyhow::{bail, format_err, Error};
+use serde::Serialize;
 use serde_json::{json, Value};
 
 use crate::ConstRegexPattern;
@@ -170,11 +171,13 @@ impl<'a> FromIterator<(&'a str, Error)> for ParameterError {
 }
 
 /// Data type to describe boolean values
-#[derive(Debug)]
+#[derive(Debug, Serialize)]
 #[cfg_attr(feature = "test-harness", derive(Eq, PartialEq))]
 pub struct BooleanSchema {
     pub description: &'static str,
+
     /// Optional default value.
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub default: Option<bool>,
 }
 
@@ -205,15 +208,21 @@ impl BooleanSchema {
 }
 
 /// Data type to describe integer values.
-#[derive(Debug)]
+#[derive(Debug, Serialize)]
 #[cfg_attr(feature = "test-harness", derive(Eq, PartialEq))]
 pub struct IntegerSchema {
     pub description: &'static str,
+
     /// Optional minimum.
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub minimum: Option<isize>,
+
     /// Optional maximum.
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub maximum: Option<isize>,
+
     /// Optional default.
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub default: Option<isize>,
 }
 
@@ -281,14 +290,20 @@ impl IntegerSchema {
 }
 
 /// Data type to describe (JSON like) number value
-#[derive(Debug)]
+#[derive(Debug, Serialize)]
 pub struct NumberSchema {
     pub description: &'static str,
+
     /// Optional minimum.
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub minimum: Option<f64>,
+
     /// Optional maximum.
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub maximum: Option<f64>,
+
     /// Optional default.
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub default: Option<f64>,
 }
 
@@ -495,19 +510,60 @@ impl StringSchema {
     }
 }
 
+impl Serialize for StringSchema {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        use serde::ser::SerializeMap;
+
+        let mut map = serializer.serialize_map(None)?;
+
+        map.serialize_entry("description", self.description)?;
+        if let Some(v) = &self.default {
+            map.serialize_entry("default", v)?;
+        }
+        if let Some(v) = &self.min_length {
+            map.serialize_entry("minLength", v)?;
+        }
+        if let Some(v) = &self.max_length {
+            map.serialize_entry("maxLength", v)?;
+        }
+        if let Some(v) = &self.format {
+            map = v.serialize_inner(map)?;
+        }
+        if let Some(v) = &self.type_text {
+            map.serialize_entry("typetext", v)?;
+        } else if let Some(ApiStringFormat::PropertyString(schema)) = &self.format {
+            map.serialize_entry(
+                "typetext",
+                &crate::format::get_property_string_type_text(schema),
+            )?;
+        }
+
+        map.end()
+    }
+}
+
 /// Data type to describe array of values.
 ///
 /// All array elements are of the same type, as defined in the `items`
 /// schema.
-#[derive(Debug)]
+#[derive(Debug, Serialize)]
 #[cfg_attr(feature = "test-harness", derive(Eq, PartialEq))]
+#[serde(rename_all = "camelCase")]
 pub struct ArraySchema {
     pub description: &'static str,
+
     /// Element type schema.
     pub items: &'static Schema,
+
     /// Optional minimal length.
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub min_length: Option<usize>,
+
     /// Optional maximal length.
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub max_length: Option<usize>,
 }
 
@@ -656,6 +712,63 @@ impl ObjectSchema {
     }
 }
 
+impl Serialize for ObjectSchema {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        use serde::ser::SerializeMap;
+
+        struct Helper<'a>(&'a ObjectSchema);
+
+        impl Serialize for Helper<'_> {
+            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+            where
+                S: serde::Serializer,
+            {
+                let mut map = serializer.serialize_map(Some(self.0.properties.len()))?;
+                for (name, optional, schema) in self.0.properties {
+                    map.serialize_entry(
+                        name,
+                        &PropertyEntrySerializer {
+                            schema: *schema,
+                            optional: *optional,
+                            default_key: self.0.default_key == Some(name),
+                        },
+                    )?;
+                }
+                map.end()
+            }
+        }
+
+        let mut map = serializer.serialize_map(None)?;
+
+        map.serialize_entry("description", self.description)?;
+        map.serialize_entry("additionalProperties", &self.additional_properties)?;
+
+        if !self.properties.is_empty() {
+            map.serialize_entry("properties", &Helper(self))?;
+        }
+
+        map.end()
+    }
+}
+
+#[derive(Serialize)]
+struct PropertyEntrySerializer {
+    #[serde(flatten)]
+    schema: &'static Schema,
+    #[serde(skip_serializing_if = "is_false")]
+    optional: bool,
+    #[serde(skip_serializing_if = "is_false")]
+    default_key: bool,
+}
+
+#[inline]
+fn is_false(v: &bool) -> bool {
+    !*v
+}
+
 /// Combines multiple *object* schemas into one.
 ///
 /// Note that these are limited to object schemas. Other schemas will produce errors.
@@ -714,6 +827,48 @@ impl AllOfSchema {
     }
 }
 
+impl Serialize for AllOfSchema {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        use serde::ser::SerializeMap;
+
+        struct Helper<'a>(&'a AllOfSchema);
+
+        impl Serialize for Helper<'_> {
+            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+            where
+                S: serde::Serializer,
+            {
+                let mut map = serializer.serialize_map(None)?;
+                for (name, optional, schema) in self.0.properties() {
+                    map.serialize_entry(
+                        name,
+                        &PropertyEntrySerializer {
+                            schema: *schema,
+                            optional: *optional,
+                            default_key: false,
+                        },
+                    )?;
+                }
+                map.end()
+            }
+        }
+
+        let mut map = serializer.serialize_map(None)?;
+
+        map.serialize_entry("description", self.description)?;
+        map.serialize_entry("additionalProperties", &self.additional_properties())?;
+
+        if self.properties().next().is_some() {
+            map.serialize_entry("properties", &Helper(self))?;
+        }
+
+        map.end()
+    }
+}
+
 /// Beside [`ObjectSchema`] we also have an [`AllOfSchema`] which also represents objects.
 pub trait ObjectSchemaType {
     fn description(&self) -> &'static str;
@@ -868,8 +1023,9 @@ impl Iterator for ObjectPropertyIterator {
 ///     ],
 /// ).schema();
 /// ```
-#[derive(Debug)]
+#[derive(Debug, Serialize)]
 #[cfg_attr(feature = "test-harness", derive(Eq, PartialEq))]
+#[serde(tag = "type", rename_all = "lowercase")]
 pub enum Schema {
     Null,
     Boolean(BooleanSchema),
@@ -878,6 +1034,9 @@ pub enum Schema {
     String(StringSchema),
     Object(ObjectSchema),
     Array(ArraySchema),
+
+    // NOTE: In perl we do not currently support `type = AllOf` directly.
+    #[serde(rename = "object")]
     AllOf(AllOfSchema),
 }
 
@@ -1137,6 +1296,90 @@ pub enum ApiStringFormat {
     VerifyFn(ApiStringVerifyFn),
 }
 
+impl ApiStringFormat {
+    fn serialize_inner<M: serde::ser::SerializeMap>(&self, mut map: M) -> Result<M, M::Error> {
+        match self {
+            ApiStringFormat::Enum(entries) => {
+                struct Helper(&'static [EnumEntry]);
+
+                impl Serialize for Helper {
+                    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+                    where
+                        S: serde::Serializer,
+                    {
+                        use serde::ser::SerializeSeq;
+
+                        let mut array = serializer.serialize_seq(Some(self.0.len()))?;
+                        for entry in self.0 {
+                            array.serialize_element(entry.value)?;
+                        }
+                        array.end()
+                    }
+                }
+
+                map.serialize_entry("enum", &Helper(entries))?;
+            }
+            ApiStringFormat::Pattern(pattern) => {
+                map.serialize_entry("pattern", &pattern.regex_string)?;
+            }
+            ApiStringFormat::PropertyString(schema) => {
+                map.serialize_entry("format", schema)?;
+            }
+            ApiStringFormat::VerifyFn(func) => {
+                #[cfg(feature = "perlmod")]
+                if perlmod::ser::is_active() {
+                    map.serialize_entry(
+                        "format",
+                        &serialize_verify_fn::make_format_validator(*func),
+                    )?;
+                }
+                #[cfg(not(features = "perlmod"))]
+                let _ = func;
+            }
+        }
+
+        Ok(map)
+    }
+}
+
+impl Serialize for ApiStringFormat {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        use serde::ser::SerializeMap;
+        self.serialize_inner(serializer.serialize_map(Some(1))?)?
+            .end()
+    }
+}
+
+#[cfg(feature = "perlmod")]
+mod serialize_verify_fn {
+    use super::ApiStringVerifyFn;
+
+    static FN_TAG: perlmod::MagicTag<()> = perlmod::MagicTag::new();
+
+    #[perlmod::export(xs_name = "call_validator_xsub")]
+    fn call_validator(#[cv] cv: perlmod::Value, s: &str) -> Result<(), anyhow::Error> {
+        let mg = cv
+            .find_raw_magic(None, Some(FN_TAG.as_ref()))
+            .ok_or_else(|| {
+                anyhow::format_err!("validator function without appropriate magic tag")
+            })?;
+        let mg: ApiStringVerifyFn = unsafe { ::core::mem::transmute(mg.ptr()) };
+        mg(s)
+    }
+
+    pub(super) fn make_format_validator(func: ApiStringVerifyFn) -> perlmod::RawValue {
+        unsafe {
+            let cv = perlmod::Value::new_xsub(call_validator_xsub);
+            cv.add_raw_magic(None, None, Some(FN_TAG.as_ref()), func as *const _, 0);
+            perlmod::Value::new_ref(&cv)
+        }
+        .into()
+    }
+}
+
 /// Type of a verification function for [`StringSchema`]s.
 pub type ApiStringVerifyFn = fn(&str) -> Result<(), Error>;
 
diff --git a/proxmox-schema/tests/schema_verification.rs b/proxmox-schema/tests/schema_verification.rs
index 09d7d87..506ef0f 100644
--- a/proxmox-schema/tests/schema_verification.rs
+++ b/proxmox-schema/tests/schema_verification.rs
@@ -193,3 +193,33 @@ fn verify_nested_property3() -> Result<(), Error> {
 
     Ok(())
 }
+
+#[test]
+fn verify_perl_json_schema() {
+    let schema =
+        serde_json::to_string(&SIMPLE_OBJECT_SCHEMA).expect("failed to serialize object schema");
+    assert_eq!(
+        schema,
+        "\
+        {\
+            \"type\":\"object\",\
+            \"description\":\"simple object schema\",\
+            \"additionalProperties\":false,\
+            \"properties\":{\
+                \"prop1\":{\
+                    \"type\":\"string\",\
+                    \"description\":\"A test string\"\
+                },\
+                \"prop2\":{\
+                    \"type\":\"string\",\
+                    \"description\":\"A test string\",\
+                    \"optional\":true\
+                },\
+                \"prop3\":{\
+                    \"type\":\"string\",\
+                    \"description\":\"A test string\"\
+                }\
+            }\
+        }"
+    );
+}
-- 
2.30.2





More information about the pbs-devel mailing list