Skip to main content

adapters/schema/
object.rs

1//! Object schema — validates structured objects with named fields.
2//!
3//! Provides the [`ObjectSchema`] structure to represent nested struct mappings and validate properties.
4
5use super::SchemaValidator;
6use crate::error::ValidationError;
7use crate::value::Value;
8use std::collections::BTreeMap;
9
10/// Schema representing key-value object constraints and field mappings.
11#[derive(Default)]
12pub struct ObjectSchema {
13    field_order: Vec<String>,
14    field_map: BTreeMap<String, Box<dyn SchemaValidator>>,
15    strict: bool,
16    required: bool,
17    optional: bool,
18}
19
20impl ObjectSchema {
21    /// Creates a new `ObjectSchema` with no structural fields configured.
22    pub fn new() -> Self {
23        Self::default()
24    }
25
26    /// Appends a new named property field associated with a schema validator.
27    pub fn field(mut self, name: &str, schema: impl SchemaValidator + 'static) -> Self {
28        self.field_order.push(name.to_string());
29        self.field_map.insert(name.to_string(), Box::new(schema));
30        self
31    }
32
33    /// Opts into strict validation mode: unrecognized keys trigger validation failures.
34    pub fn strict(mut self) -> Self {
35        self.strict = true;
36        self
37    }
38
39    /// Configures the schema to strictly fail if the field is absent.
40    pub fn required(mut self) -> Self {
41        self.required = true;
42        self
43    }
44
45    /// Registers the field as optional (permits `Null` values).
46    pub fn optional(mut self) -> Self {
47        self.optional = true;
48        self
49    }
50}
51
52impl SchemaValidator for ObjectSchema {
53    fn validate(&self, value: &Value, field: &str) -> Result<(), ValidationError> {
54        if self.optional && value.is_null() {
55            return Ok(());
56        }
57        if self.required && value.is_null() {
58            return Err(ValidationError::new(field, "field is required", "required"));
59        }
60        let obj = match value.as_object() {
61            Some(o) => o,
62            None => {
63                return Err(ValidationError::new(
64                    field,
65                    format!("expected object, got {}", value.type_name()),
66                    "type_mismatch",
67                ));
68            }
69        };
70
71        if self.strict {
72            for key in obj.keys() {
73                if !self.field_map.contains_key(key) {
74                    return Err(ValidationError::new(
75                        field,
76                        format!("unknown field '{key}' (strict mode)"),
77                        "unknown_field",
78                    ));
79                }
80            }
81        }
82
83        for name in &self.field_order {
84            let schema = &self.field_map[name];
85            let child_field = if field == "root" || field.is_empty() {
86                name.clone()
87            } else {
88                format!("{field}.{name}")
89            };
90
91            let val = match obj.get(name) {
92                Some(v) => v.clone(),
93                None => {
94                    if let Some(def) = schema.default_value() {
95                        def
96                    } else if schema.is_required() {
97                        return Err(ValidationError::new(
98                            &child_field,
99                            format!("required field '{name}' is missing"),
100                            "required",
101                        ));
102                    } else {
103                        Value::Null
104                    }
105                }
106            };
107
108            schema.validate(&val, &child_field)?;
109        }
110        Ok(())
111    }
112
113    fn is_required(&self) -> bool {
114        self.required
115    }
116    fn default_value(&self) -> Option<Value> {
117        None
118    }
119    fn schema_type(&self) -> &'static str {
120        "object"
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::schema::{IntegerSchema, StringSchema};
128
129    fn simple_user_schema() -> ObjectSchema {
130        ObjectSchema::new()
131            .field("username", StringSchema::new().required().min_length(3))
132            .field("age", IntegerSchema::new().required().min(18))
133    }
134
135    fn make_obj(fields: &[(&str, Value)]) -> Value {
136        let mut m = BTreeMap::new();
137        for (k, v) in fields {
138            m.insert(k.to_string(), v.clone());
139        }
140        Value::Object(m)
141    }
142
143    #[test]
144    fn test_object_valid() {
145        let s = simple_user_schema();
146        let v = make_obj(&[
147            ("username", Value::String("alice".into())),
148            ("age", Value::Int(25)),
149        ]);
150        assert!(s.validate(&v, "root").is_ok());
151    }
152
153    #[test]
154    fn test_object_missing_required_field() {
155        let s = simple_user_schema();
156        let v = make_obj(&[("username", Value::String("alice".into()))]);
157        assert!(s.validate(&v, "root").is_err());
158    }
159
160    #[test]
161    fn test_object_field_validation_fails() {
162        let s = simple_user_schema();
163        let v = make_obj(&[
164            ("username", Value::String("al".into())),
165            ("age", Value::Int(25)),
166        ]);
167        let err = s.validate(&v, "root").unwrap_err();
168        assert!(err.field.contains("username"), "field: {}", err.field);
169    }
170
171    #[test]
172    fn test_object_strict_rejects_unknown() {
173        let s = simple_user_schema().strict();
174        let v = make_obj(&[
175            ("username", Value::String("alice".into())),
176            ("age", Value::Int(25)),
177            ("extra", Value::Bool(true)),
178        ]);
179        assert!(s.validate(&v, "root").is_err());
180    }
181
182    #[test]
183    fn test_object_not_an_object_fails() {
184        let s = simple_user_schema();
185        assert!(
186            s.validate(&Value::String("not an object".into()), "root")
187                .is_err()
188        );
189    }
190
191    #[test]
192    fn test_object_optional_null_passes() {
193        let s = ObjectSchema::new().optional();
194        assert!(s.validate(&Value::Null, "addr").is_ok());
195    }
196
197    #[test]
198    fn test_nested_error_path() {
199        let inner = ObjectSchema::new().field("city", StringSchema::new().required().min_length(3));
200        let outer = ObjectSchema::new().field("address", inner);
201        let bad_city = make_obj(&[("city", Value::String("ab".into()))]);
202        let v = make_obj(&[("address", bad_city)]);
203        let err = outer.validate(&v, "root").unwrap_err();
204        assert!(err.field.contains("address"), "path: {}", err.field);
205    }
206}