public inbox for pbs-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Wolfgang Bumiller <w.bumiller@proxmox.com>
To: pbs-devel@lists.proxmox.com
Subject: [pbs-devel] [PATCH proxmox] schema: implement Serialize for Schema w/ perl support
Date: Tue, 29 Mar 2022 10:56:50 +0200	[thread overview]
Message-ID: <20220329085650.37508-1-w.bumiller@proxmox.com> (raw)

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





                 reply	other threads:[~2022-03-29  8:56 UTC|newest]

Thread overview: [no followups] expand[flat|nested]  mbox.gz  Atom feed

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20220329085650.37508-1-w.bumiller@proxmox.com \
    --to=w.bumiller@proxmox.com \
    --cc=pbs-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal