]> git.proxmox.com Git - proxmox.git/blobdiff - proxmox-schema/src/schema.rs
schema: implement oneOf schema support
[proxmox.git] / proxmox-schema / src / schema.rs
index a2b165c810db4a91582de54eb693cd6d110ac847..2377718e1f812261d682493d954a201641bdf1d7 100644 (file)
@@ -4,6 +4,7 @@
 //! completely static API definitions that can be included within the programs read-only text
 //! segment.
 
+use std::collections::HashSet;
 use std::fmt;
 
 use anyhow::{bail, format_err, Error};
@@ -88,6 +89,10 @@ impl ParameterError {
             Err(err) => self.push(prefix.to_string(), err),
         }
     }
+
+    pub(crate) fn from_list(error_list: Vec<(String, Error)>) -> Self {
+        Self { error_list }
+    }
 }
 
 impl fmt::Display for ParameterError {
@@ -97,14 +102,18 @@ impl fmt::Display for ParameterError {
         let mut msg = String::new();
 
         if !self.is_empty() {
-            msg.push_str("parameter verification errors\n\n");
-        }
-
-        for (name, err) in self.error_list.iter() {
-            let _ = writeln!(msg, "parameter '{}': {}", name, err);
+            if self.len() == 1 {
+                msg.push_str("parameter verification failed - ");
+                let _ = write!(msg, "'{}': {}", self.error_list[0].0, self.error_list[0].1);
+            } else {
+                msg.push_str("parameter verification failed:\n");
+                for (name, err) in self.error_list.iter() {
+                    let _ = writeln!(msg, "- '{}': {}", name, err);
+                }
+            }
         }
 
-        write!(f, "{}", msg)
+        write!(f, "{}", msg.trim())
     }
 }
 
@@ -248,7 +257,7 @@ impl IntegerSchema {
         Schema::Integer(self)
     }
 
-    fn check_constraints(&self, value: isize) -> Result<(), Error> {
+    pub fn check_constraints(&self, value: isize) -> Result<(), Error> {
         if let Some(minimum) = self.minimum {
             if value < minimum {
                 bail!(
@@ -323,7 +332,7 @@ impl NumberSchema {
         Schema::Number(self)
     }
 
-    fn check_constraints(&self, value: f64) -> Result<(), Error> {
+    pub fn check_constraints(&self, value: f64) -> Result<(), Error> {
         if let Some(minimum) = self.minimum {
             if value < minimum {
                 bail!(
@@ -436,7 +445,7 @@ impl StringSchema {
         Schema::String(self)
     }
 
-    fn check_length(&self, length: usize) -> Result<(), Error> {
+    pub(crate) fn check_length(&self, length: usize) -> Result<(), Error> {
         if let Some(min_length) = self.min_length {
             if length < min_length {
                 bail!("value must be at least {} characters long", min_length);
@@ -468,7 +477,7 @@ impl StringSchema {
                     }
                 }
                 ApiStringFormat::PropertyString(subschema) => {
-                    subschema.parse_property_string(value)?;
+                    crate::de::verify::verify(subschema, value)?;
                 }
                 ApiStringFormat::VerifyFn(verify_fn) => {
                     verify_fn(value)?;
@@ -537,7 +546,7 @@ impl ArraySchema {
         Schema::Array(self)
     }
 
-    fn check_length(&self, length: usize) -> Result<(), Error> {
+    pub(crate) fn check_length(&self, length: usize) -> Result<(), Error> {
         if let Some(min_length) = self.min_length {
             if length < min_length {
                 bail!("array must contain at least {} elements", min_length);
@@ -685,24 +694,105 @@ impl AllOfSchema {
 
     pub fn lookup(&self, key: &str) -> Option<(bool, &Schema)> {
         for entry in self.list {
-            match entry {
-                Schema::AllOf(s) => {
-                    if let Some(v) = s.lookup(key) {
-                        return Some(v);
-                    }
-                }
-                Schema::Object(s) => {
-                    if let Some(v) = s.lookup(key) {
-                        return Some(v);
-                    }
-                }
-                _ => panic!("non-object-schema in `AllOfSchema`"),
+            if let Some(v) = entry
+                .any_object()
+                .expect("non-object-schema in `AllOfSchema`")
+                .lookup(key)
+            {
+                return Some(v);
+            }
+        }
+
+        None
+    }
+
+    /// Parse key/value pairs and verify with object schema
+    ///
+    /// - `test_required`: is set, checks if all required properties are
+    ///   present.
+    pub fn parse_parameter_strings(
+        &'static self,
+        data: &[(String, String)],
+        test_required: bool,
+    ) -> Result<Value, ParameterError> {
+        ParameterSchema::from(self).parse_parameter_strings(data, test_required)
+    }
+}
+
+/// An object schema which is basically like a rust enum: exactly one variant may match.
+///
+/// Contrary to JSON Schema, we require there be a 'type' property to distinguish the types.
+/// 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)]
+#[cfg_attr(feature = "test-harness", derive(Eq, PartialEq))]
+pub struct OneOfSchema {
+    pub description: &'static str,
+
+    /// The type property entry.
+    ///
+    /// This must be a static reference due to how we implemented the property iterator.
+    pub type_property_entry: &'static SchemaPropertyEntry,
+
+    /// The parameter is checked against all of the schemas in the list. Note that all schemas must
+    /// be object schemas.
+    pub list: &'static [(&'static str, &'static Schema)],
+}
+
+impl OneOfSchema {
+    pub const fn new(
+        description: &'static str,
+        type_property_entry: &'static SchemaPropertyEntry,
+        list: &'static [(&'static str, &'static Schema)],
+    ) -> Self {
+        Self {
+            description,
+            type_property_entry,
+            list,
+        }
+    }
+
+    pub const fn schema(self) -> Schema {
+        Schema::OneOf(self)
+    }
+
+    pub fn type_property(&self) -> &'static str {
+        self.type_property_entry.0
+    }
+
+    pub fn type_schema(&self) -> &'static Schema {
+        self.type_property_entry.2
+    }
+
+    pub fn lookup(&self, key: &str) -> Option<(bool, &Schema)> {
+        if key == self.type_property() {
+            return Some((false, self.type_schema()));
+        }
+
+        for (_variant, entry) in self.list {
+            if let Some(v) = entry
+                .any_object()
+                .expect("non-object-schema in `OneOfSchema`")
+                .lookup(key)
+            {
+                return Some(v);
             }
         }
 
         None
     }
 
+    pub fn lookup_variant(&self, name: &str) -> Option<&Schema> {
+        Some(
+            self.list[self
+                .list
+                .binary_search_by_key(&name, |(name, _)| name)
+                .ok()?]
+            .1,
+        )
+    }
+
     /// Parse key/value pairs and verify with object schema
     ///
     /// - `test_required`: is set, checks if all required properties are
@@ -722,6 +812,7 @@ pub trait ObjectSchemaType {
     fn lookup(&self, key: &str) -> Option<(bool, &Schema)>;
     fn properties(&self) -> ObjectPropertyIterator;
     fn additional_properties(&self) -> bool;
+    fn default_key(&self) -> Option<&'static str>;
 
     /// Verify JSON value using an object schema.
     fn verify_json(&self, data: &Value) -> Result<(), Error> {
@@ -765,6 +856,23 @@ pub trait ObjectSchemaType {
     }
 }
 
+#[doc(hidden)]
+pub enum ObjectPropertyIterator {
+    Simple(SimpleObjectPropertyIterator),
+    OneOf(OneOfPropertyIterator),
+}
+
+impl Iterator for ObjectPropertyIterator {
+    type Item = &'static SchemaPropertyEntry;
+
+    fn next(&mut self) -> Option<&'static SchemaPropertyEntry> {
+        match self {
+            Self::Simple(iter) => iter.next(),
+            Self::OneOf(iter) => iter.next(),
+        }
+    }
+}
+
 impl ObjectSchemaType for ObjectSchema {
     fn description(&self) -> &'static str {
         self.description
@@ -775,16 +883,20 @@ impl ObjectSchemaType for ObjectSchema {
     }
 
     fn properties(&self) -> ObjectPropertyIterator {
-        ObjectPropertyIterator {
+        ObjectPropertyIterator::Simple(SimpleObjectPropertyIterator {
             schemas: [].iter(),
             properties: Some(self.properties.iter()),
             nested: None,
-        }
+        })
     }
 
     fn additional_properties(&self) -> bool {
         self.additional_properties
     }
+
+    fn default_key(&self) -> Option<&'static str> {
+        self.default_key
+    }
 }
 
 impl ObjectSchemaType for AllOfSchema {
@@ -797,26 +909,41 @@ impl ObjectSchemaType for AllOfSchema {
     }
 
     fn properties(&self) -> ObjectPropertyIterator {
-        ObjectPropertyIterator {
+        ObjectPropertyIterator::Simple(SimpleObjectPropertyIterator {
             schemas: self.list.iter(),
             properties: None,
             nested: None,
-        }
+        })
     }
 
     fn additional_properties(&self) -> bool {
         true
     }
+
+    fn default_key(&self) -> Option<&'static str> {
+        for schema in self.list {
+            let default_key = schema
+                .any_object()
+                .expect("non-object-schema in `AllOfSchema`")
+                .default_key();
+
+            if default_key.is_some() {
+                return default_key;
+            }
+        }
+
+        None
+    }
 }
 
 #[doc(hidden)]
-pub struct ObjectPropertyIterator {
+pub struct SimpleObjectPropertyIterator {
     schemas: std::slice::Iter<'static, &'static Schema>,
     properties: Option<std::slice::Iter<'static, SchemaPropertyEntry>>,
     nested: Option<Box<ObjectPropertyIterator>>,
 }
 
-impl Iterator for ObjectPropertyIterator {
+impl Iterator for SimpleObjectPropertyIterator {
     type Item = &'static SchemaPropertyEntry;
 
     fn next(&mut self) -> Option<&'static SchemaPropertyEntry> {
@@ -830,6 +957,7 @@ impl Iterator for ObjectPropertyIterator {
                 Some(item) => return Some(item),
                 None => match self.schemas.next()? {
                     Schema::AllOf(o) => self.nested = Some(Box::new(o.properties())),
+                    Schema::OneOf(o) => self.nested = Some(Box::new(o.properties())),
                     Schema::Object(o) => self.properties = Some(o.properties.iter()),
                     _ => {
                         self.properties = None;
@@ -841,6 +969,93 @@ impl Iterator for ObjectPropertyIterator {
     }
 }
 
+impl ObjectSchemaType for OneOfSchema {
+    fn description(&self) -> &'static str {
+        self.description
+    }
+
+    fn lookup(&self, key: &str) -> Option<(bool, &Schema)> {
+        OneOfSchema::lookup(self, key)
+    }
+
+    fn properties(&self) -> ObjectPropertyIterator {
+        ObjectPropertyIterator::OneOf(OneOfPropertyIterator {
+            type_property_entry: self.type_property_entry,
+            schemas: self.list.iter(),
+            done: HashSet::new(),
+            nested: None,
+        })
+    }
+
+    fn additional_properties(&self) -> bool {
+        true
+    }
+
+    fn default_key(&self) -> Option<&'static str> {
+        None
+    }
+
+    fn verify_json(&self, data: &Value) -> Result<(), Error> {
+        let map = match data {
+            Value::Object(ref map) => map,
+            Value::Array(_) => bail!("Expected object - got array."),
+            _ => bail!("Expected object - got scalar value."),
+        };
+
+        // Without the type we also cannot verify anything else...:
+        let variant = match map.get(self.type_property()) {
+            None => bail!("Missing '{}' property", self.type_property()),
+            Some(Value::String(v)) => v,
+            _ => bail!("Expected string in '{}'", self.type_property()),
+        };
+
+        let schema = self
+            .lookup_variant(variant)
+            .ok_or_else(|| format_err!("invalid '{}': {}", self.type_property(), variant))?;
+
+        schema.verify_json(data)
+    }
+}
+
+#[doc(hidden)]
+pub struct OneOfPropertyIterator {
+    type_property_entry: &'static SchemaPropertyEntry,
+    schemas: std::slice::Iter<'static, (&'static str, &'static Schema)>,
+    done: HashSet<&'static str>,
+    nested: Option<Box<ObjectPropertyIterator>>,
+}
+
+impl Iterator for OneOfPropertyIterator {
+    type Item = &'static SchemaPropertyEntry;
+
+    fn next(&mut self) -> Option<&'static SchemaPropertyEntry> {
+        if self.done.insert(self.type_property_entry.0) {
+            return Some(self.type_property_entry);
+        }
+
+        loop {
+            match self.nested.as_mut().and_then(Iterator::next) {
+                Some(item) => {
+                    if !self.done.insert(item.0) {
+                        continue;
+                    }
+                    return Some(item);
+                }
+                None => self.nested = None,
+            }
+
+            self.nested = Some(Box::new(
+                self.schemas
+                    .next()?
+                    .1
+                    .any_object()
+                    .expect("non-object-schema in `OneOfSchema`")
+                    .properties(),
+            ));
+        }
+    }
+}
+
 /// Schemas are used to describe complex data types.
 ///
 /// All schema types implement constant builder methods, and a final
@@ -881,6 +1096,7 @@ pub enum Schema {
     Object(ObjectSchema),
     Array(ArraySchema),
     AllOf(AllOfSchema),
+    OneOf(OneOfSchema),
 }
 
 impl Schema {
@@ -899,6 +1115,7 @@ impl Schema {
             Schema::Number(s) => s.verify_json(data)?,
             Schema::String(s) => s.verify_json(data)?,
             Schema::AllOf(s) => s.verify_json(data)?,
+            Schema::OneOf(s) => s.verify_json(data)?,
         }
         Ok(())
     }
@@ -1042,6 +1259,87 @@ impl Schema {
             _ => panic!("unwrap_all_of_schema on different schema"),
         }
     }
+
+    /// Gets the underlying [`OneOfSchema`], panics on different schemas.
+    pub const fn unwrap_one_of_schema(&self) -> &OneOfSchema {
+        match self {
+            Schema::OneOf(s) => s,
+            _ => panic!("unwrap_one_of_schema on different schema"),
+        }
+    }
+
+    /// Gets the underlying [`BooleanSchema`].
+    pub const fn boolean(&self) -> Option<&BooleanSchema> {
+        match self {
+            Schema::Boolean(s) => Some(s),
+            _ => None,
+        }
+    }
+
+    /// Gets the underlying [`IntegerSchema`].
+    pub const fn integer(&self) -> Option<&IntegerSchema> {
+        match self {
+            Schema::Integer(s) => Some(s),
+            _ => None,
+        }
+    }
+
+    /// Gets the underlying [`NumberSchema`].
+    pub const fn number(&self) -> Option<&NumberSchema> {
+        match self {
+            Schema::Number(s) => Some(s),
+            _ => None,
+        }
+    }
+
+    /// Gets the underlying [`StringSchema`].
+    pub const fn string(&self) -> Option<&StringSchema> {
+        match self {
+            Schema::String(s) => Some(s),
+            _ => None,
+        }
+    }
+
+    /// Gets the underlying [`ObjectSchema`].
+    pub const fn object(&self) -> Option<&ObjectSchema> {
+        match self {
+            Schema::Object(s) => Some(s),
+            _ => None,
+        }
+    }
+
+    /// Gets the underlying [`ArraySchema`].
+    pub const fn array(&self) -> Option<&ArraySchema> {
+        match self {
+            Schema::Array(s) => Some(s),
+            _ => None,
+        }
+    }
+
+    /// Gets the underlying [`AllOfSchema`].
+    pub const fn all_of(&self) -> Option<&AllOfSchema> {
+        match self {
+            Schema::AllOf(s) => Some(s),
+            _ => None,
+        }
+    }
+
+    /// Gets the underlying [`AllOfSchema`].
+    pub const fn one_of(&self) -> Option<&OneOfSchema> {
+        match self {
+            Schema::OneOf(s) => Some(s),
+            _ => None,
+        }
+    }
+
+    pub fn any_object(&self) -> Option<&dyn ObjectSchemaType> {
+        match self {
+            Schema::Object(s) => Some(s),
+            Schema::AllOf(s) => Some(s),
+            Schema::OneOf(s) => Some(s),
+            _ => None,
+        }
+    }
 }
 
 /// A string enum entry. An enum entry must have a value and a description.
@@ -1202,6 +1500,7 @@ impl PartialEq for ApiStringFormat {
 pub enum ParameterSchema {
     Object(&'static ObjectSchema),
     AllOf(&'static AllOfSchema),
+    OneOf(&'static OneOfSchema),
 }
 
 impl ParameterSchema {
@@ -1223,6 +1522,7 @@ impl ObjectSchemaType for ParameterSchema {
         match self {
             ParameterSchema::Object(o) => o.description(),
             ParameterSchema::AllOf(o) => o.description(),
+            ParameterSchema::OneOf(o) => o.description(),
         }
     }
 
@@ -1230,6 +1530,7 @@ impl ObjectSchemaType for ParameterSchema {
         match self {
             ParameterSchema::Object(o) => o.lookup(key),
             ParameterSchema::AllOf(o) => o.lookup(key),
+            ParameterSchema::OneOf(o) => o.lookup(key),
         }
     }
 
@@ -1237,6 +1538,7 @@ impl ObjectSchemaType for ParameterSchema {
         match self {
             ParameterSchema::Object(o) => o.properties(),
             ParameterSchema::AllOf(o) => o.properties(),
+            ParameterSchema::OneOf(o) => o.properties(),
         }
     }
 
@@ -1244,6 +1546,15 @@ impl ObjectSchemaType for ParameterSchema {
         match self {
             ParameterSchema::Object(o) => o.additional_properties(),
             ParameterSchema::AllOf(o) => o.additional_properties(),
+            ParameterSchema::OneOf(o) => o.additional_properties(),
+        }
+    }
+
+    fn default_key(&self) -> Option<&'static str> {
+        match self {
+            ParameterSchema::Object(o) => o.default_key(),
+            ParameterSchema::AllOf(o) => o.default_key(),
+            ParameterSchema::OneOf(o) => o.default_key(),
         }
     }
 }
@@ -1260,6 +1571,12 @@ impl From<&'static AllOfSchema> for ParameterSchema {
     }
 }
 
+impl From<&'static OneOfSchema> for ParameterSchema {
+    fn from(schema: &'static OneOfSchema) -> Self {
+        ParameterSchema::OneOf(schema)
+    }
+}
+
 /// Helper function to parse boolean values
 ///
 /// - true:  `1 | on | yes | true`