Skip to main content

vld/
object.rs

1use serde_json::{Map, Value};
2
3use crate::error::{value_type_name, IssueCode, PathSegment, VldError};
4use crate::schema::VldSchema;
5
6/// Object-safe trait for type-erased schema validation.
7///
8/// Used internally by [`ZObject`] to store heterogeneous field schemas.
9pub trait DynSchema {
10    fn dyn_parse(&self, value: &Value) -> Result<Value, VldError>;
11    /// Generate a JSON Schema for this field. Returns empty schema `{}` by default.
12    ///
13    /// Only available with the `openapi` feature.
14    #[cfg(feature = "openapi")]
15    fn dyn_json_schema(&self) -> Value {
16        serde_json::json!({})
17    }
18}
19
20/// Blanket implementation: any `VldSchema` whose output is `Serialize`
21/// can be used as a dynamic schema.
22impl<T> DynSchema for T
23where
24    T: VldSchema,
25    T::Output: serde::Serialize,
26{
27    fn dyn_parse(&self, value: &Value) -> Result<Value, VldError> {
28        let result = self.parse_value(value)?;
29        serde_json::to_value(&result).map_err(|e| {
30            VldError::single(
31                IssueCode::Custom {
32                    code: "serialize".to_string(),
33                },
34                format!("Failed to serialize validated value: {}", e),
35            )
36        })
37    }
38}
39
40#[cfg(feature = "openapi")]
41/// Wrapper that stores a schema implementing both `DynSchema` and `JsonSchema`.
42///
43/// Used by [`ZObject::field_schema()`] to include JSON Schema info for fields.
44pub(crate) struct JsonSchemaField<T> {
45    inner: T,
46}
47
48#[cfg(feature = "openapi")]
49impl<T> DynSchema for JsonSchemaField<T>
50where
51    T: VldSchema + crate::json_schema::JsonSchema,
52    T::Output: serde::Serialize,
53{
54    fn dyn_parse(&self, value: &Value) -> Result<Value, VldError> {
55        let result = self.inner.parse_value(value)?;
56        serde_json::to_value(&result).map_err(|e| {
57            VldError::single(
58                IssueCode::Custom {
59                    code: "serialize".to_string(),
60                },
61                format!("Failed to serialize validated value: {}", e),
62            )
63        })
64    }
65
66    fn dyn_json_schema(&self) -> Value {
67        self.inner.json_schema()
68    }
69}
70
71struct ObjectField {
72    name: String,
73    schema: Box<dyn DynSchema>,
74}
75
76/// How to handle unknown fields not declared in the schema.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78enum UnknownFieldMode {
79    /// Silently drop unknown fields from the output (default).
80    Strip,
81    /// Reject unknown fields with a validation error.
82    Strict,
83    /// Keep unknown fields as-is in the output.
84    Passthrough,
85}
86
87/// Dynamic object schema with runtime-defined fields.
88///
89/// For compile-time type-safe objects, use the [`schema!`](crate::schema!) macro instead.
90///
91/// # Unknown field handling
92///
93/// - **`strip()`** (default) — unknown fields are silently removed from the output.
94/// - **`strict()`** — unknown fields cause a validation error.
95/// - **`passthrough()`** — unknown fields are kept as-is in the output.
96///
97/// # Example
98/// ```
99/// use vld::prelude::*;
100///
101/// let schema = vld::object()
102///     .field("name", vld::string().min(1))
103///     .field("age", vld::number().int().min(0));
104/// ```
105/// Conditional validation rule: when `condition_field` has `condition_value`,
106/// validate `target_field` with `schema`.
107struct ConditionalRule {
108    condition_field: String,
109    condition_value: serde_json::Value,
110    target_field: String,
111    schema: Box<dyn DynSchema>,
112}
113
114pub struct ZObject {
115    fields: Vec<ObjectField>,
116    unknown_mode: UnknownFieldMode,
117    catchall_schema: Option<Box<dyn DynSchema>>,
118    conditional_rules: Vec<ConditionalRule>,
119}
120
121impl ZObject {
122    pub fn new() -> Self {
123        Self {
124            fields: vec![],
125            unknown_mode: UnknownFieldMode::Strip,
126            catchall_schema: None,
127            conditional_rules: vec![],
128        }
129    }
130
131    /// Add a field with its validation schema.
132    pub fn field<S: DynSchema + 'static>(mut self, name: impl Into<String>, schema: S) -> Self {
133        self.fields.push(ObjectField {
134            name: name.into(),
135            schema: Box::new(schema),
136        });
137        self
138    }
139
140    /// Add a field with its validation schema **and** JSON Schema support.
141    ///
142    /// Same as [`field()`](Self::field), but the field's schema will be
143    /// included in the output of [`to_json_schema()`](Self::to_json_schema)
144    /// and [`json_schema()`](crate::json_schema::JsonSchema::json_schema).
145    ///
146    /// Requires the `openapi` feature.
147    #[cfg(feature = "openapi")]
148    pub fn field_schema<S>(mut self, name: impl Into<String>, schema: S) -> Self
149    where
150        S: VldSchema + crate::json_schema::JsonSchema + 'static,
151        S::Output: serde::Serialize,
152    {
153        self.fields.push(ObjectField {
154            name: name.into(),
155            schema: Box::new(JsonSchemaField { inner: schema }),
156        });
157        self
158    }
159
160    /// Add a field that is automatically optional (null/missing → `null`).
161    ///
162    /// Shorthand for `.field(name, OptionalDynSchema(schema))` — the field won't
163    /// cause a validation error if it is missing or null.
164    ///
165    /// # Example
166    /// ```
167    /// use vld::prelude::*;
168    ///
169    /// let schema = vld::object()
170    ///     .field("name", vld::string().min(1))
171    ///     .field_optional("nickname", vld::string().min(1));
172    ///
173    /// let result = schema.parse(r#"{"name": "Alice"}"#).unwrap();
174    /// assert_eq!(result.get("nickname").unwrap(), &serde_json::Value::Null);
175    /// ```
176    pub fn field_optional<S: DynSchema + 'static>(
177        mut self,
178        name: impl Into<String>,
179        schema: S,
180    ) -> Self {
181        self.fields.push(ObjectField {
182            name: name.into(),
183            schema: Box::new(OptionalDynSchema(Box::new(schema))),
184        });
185        self
186    }
187
188    /// Reject unknown fields not defined in the schema.
189    pub fn strict(mut self) -> Self {
190        self.unknown_mode = UnknownFieldMode::Strict;
191        self
192    }
193
194    /// Silently remove unknown fields from the output (default behavior).
195    pub fn strip(mut self) -> Self {
196        self.unknown_mode = UnknownFieldMode::Strip;
197        self
198    }
199
200    /// Keep unknown fields as-is in the output without validation.
201    pub fn passthrough(mut self) -> Self {
202        self.unknown_mode = UnknownFieldMode::Passthrough;
203        self
204    }
205
206    /// Remove a field definition by name. Returns self for chaining.
207    ///
208    /// Useful with [`extend()`](Self::extend) to override fields.
209    pub fn omit(mut self, name: &str) -> Self {
210        self.fields.retain(|f| f.name != name);
211        self
212    }
213
214    /// Keep only the listed fields, removing all others.
215    pub fn pick(mut self, names: &[&str]) -> Self {
216        self.fields.retain(|f| names.contains(&f.name.as_str()));
217        self
218    }
219
220    /// Merge another object schema's fields into this one.
221    ///
222    /// If both schemas define the same field, the one from `other` wins.
223    pub fn extend(mut self, other: ZObject) -> Self {
224        for field in other.fields {
225            self.fields.retain(|f| f.name != field.name);
226            self.fields.push(field);
227        }
228        self
229    }
230
231    /// Alias for [`extend`](Self::extend).
232    pub fn merge(self, other: ZObject) -> Self {
233        self.extend(other)
234    }
235
236    /// Make all fields optional: null/missing values return `null` in the output
237    /// instead of failing validation.
238    ///
239    /// Equivalent to Zod's `.partial()`.
240    pub fn partial(mut self) -> Self {
241        self.fields = self
242            .fields
243            .into_iter()
244            .map(|f| ObjectField {
245                name: f.name,
246                schema: Box::new(OptionalDynSchema(f.schema)),
247            })
248            .collect();
249        self
250    }
251
252    /// Make all fields required: null values will fail validation.
253    /// This is the opposite of [`partial()`](Self::partial).
254    pub fn required(mut self) -> Self {
255        self.fields = self
256            .fields
257            .into_iter()
258            .map(|f| ObjectField {
259                name: f.name,
260                schema: Box::new(RequiredDynSchema(f.schema)),
261            })
262            .collect();
263        self
264    }
265
266    /// Validate unknown fields using the given schema instead of stripping/rejecting them.
267    ///
268    /// When set, unknown fields are parsed through the catchall schema
269    /// regardless of the unknown field mode.
270    pub fn catchall<S: DynSchema + 'static>(mut self, schema: S) -> Self {
271        self.catchall_schema = Some(Box::new(schema));
272        self
273    }
274
275    /// Add a conditional validation rule.
276    ///
277    /// When `condition_field` has the given value, `target_field` is validated
278    /// with the provided schema **in addition** to any existing field schemas.
279    ///
280    /// # Example
281    /// ```
282    /// use vld::prelude::*;
283    ///
284    /// let schema = vld::object()
285    ///     .field("role", vld::string())
286    ///     .field_optional("admin_key", vld::string())
287    ///     .when("role", "admin", "admin_key", vld::string().min(10));
288    ///
289    /// // When role != "admin", admin_key is optional and any value is fine
290    /// let ok = schema.parse(r#"{"role": "user"}"#);
291    /// assert!(ok.is_ok());
292    ///
293    /// // When role == "admin", admin_key must pass the extra schema
294    /// let err = schema.parse(r#"{"role": "admin", "admin_key": "short"}"#);
295    /// assert!(err.is_err());
296    /// ```
297    pub fn when<S: DynSchema + 'static>(
298        mut self,
299        condition_field: impl Into<String>,
300        condition_value: impl Into<serde_json::Value>,
301        target_field: impl Into<String>,
302        schema: S,
303    ) -> Self {
304        self.conditional_rules.push(ConditionalRule {
305            condition_field: condition_field.into(),
306            condition_value: condition_value.into(),
307            target_field: target_field.into(),
308            schema: Box::new(schema),
309        });
310        self
311    }
312
313    /// Make all fields optional recursively.
314    ///
315    /// Currently equivalent to [`partial()`](Self::partial) — nested objects
316    /// must apply `partial()` separately.
317    pub fn deep_partial(self) -> Self {
318        self.partial()
319    }
320
321    /// Get the list of field names defined in this schema.
322    pub fn keyof(&self) -> Vec<String> {
323        self.fields.iter().map(|f| f.name.clone()).collect()
324    }
325
326    /// Generate a JSON Schema representation of this object schema.
327    ///
328    /// Fields added via [`field_schema()`](Self::field_schema) will include their
329    /// full JSON Schema. Fields added via [`field()`](Self::field) will appear as
330    /// empty schemas `{}`.
331    ///
332    /// Requires the `openapi` feature.
333    #[cfg(feature = "openapi")]
334    pub fn to_json_schema(&self) -> serde_json::Value {
335        let required: Vec<String> = self.fields.iter().map(|f| f.name.clone()).collect();
336        let mut props = serde_json::Map::new();
337        for f in &self.fields {
338            props.insert(f.name.clone(), f.schema.dyn_json_schema());
339        }
340        let mut schema = serde_json::json!({
341            "type": "object",
342            "required": required,
343            "properties": Value::Object(props),
344            "additionalProperties": self.unknown_mode != UnknownFieldMode::Strict,
345        });
346        if let Some(ref catchall) = self.catchall_schema {
347            schema["additionalProperties"] = catchall.dyn_json_schema();
348        }
349        schema
350    }
351}
352
353impl Default for ZObject {
354    fn default() -> Self {
355        Self::new()
356    }
357}
358
359impl VldSchema for ZObject {
360    type Output = Map<String, Value>;
361
362    fn parse_value(&self, value: &Value) -> Result<Map<String, Value>, VldError> {
363        let obj = value.as_object().ok_or_else(|| {
364            VldError::single(
365                IssueCode::InvalidType {
366                    expected: "object".to_string(),
367                    received: value_type_name(value),
368                },
369                format!("Expected object, received {}", value_type_name(value)),
370            )
371        })?;
372
373        let mut result = Map::new();
374        let mut errors = VldError::new();
375
376        // Validate defined fields
377        for field in &self.fields {
378            let field_value = obj.get(&field.name).unwrap_or(&Value::Null);
379            match field.schema.dyn_parse(field_value) {
380                Ok(v) => {
381                    result.insert(field.name.clone(), v);
382                }
383                Err(e) => {
384                    errors = errors.merge(e.with_prefix(PathSegment::Field(field.name.clone())));
385                }
386            }
387        }
388
389        // Handle unknown fields
390        let known_keys: Vec<&str> = self.fields.iter().map(|f| f.name.as_str()).collect();
391        let unknown_keys: Vec<&String> = obj
392            .keys()
393            .filter(|k| !known_keys.contains(&k.as_str()))
394            .collect();
395
396        if let Some(catchall) = &self.catchall_schema {
397            for key in &unknown_keys {
398                let val = &obj[key.as_str()];
399                match catchall.dyn_parse(val) {
400                    Ok(v) => {
401                        result.insert((*key).clone(), v);
402                    }
403                    Err(e) => {
404                        errors = errors.merge(e.with_prefix(PathSegment::Field((*key).clone())));
405                    }
406                }
407            }
408        } else {
409            match self.unknown_mode {
410                UnknownFieldMode::Strip => {}
411                UnknownFieldMode::Strict => {
412                    for key in &unknown_keys {
413                        let mut issue_err = VldError::single(
414                            IssueCode::UnrecognizedField,
415                            format!("Unrecognized field: \"{}\"", key),
416                        );
417                        issue_err = issue_err.with_prefix(PathSegment::Field((*key).clone()));
418                        errors = errors.merge(issue_err);
419                    }
420                }
421                UnknownFieldMode::Passthrough => {
422                    for key in &unknown_keys {
423                        result.insert((*key).clone(), obj[key.as_str()].clone());
424                    }
425                }
426            }
427        }
428
429        // Evaluate conditional rules
430        for rule in &self.conditional_rules {
431            let cond_val = obj.get(&rule.condition_field).unwrap_or(&Value::Null);
432            if *cond_val == rule.condition_value {
433                let target_val = obj.get(&rule.target_field).unwrap_or(&Value::Null);
434                if let Err(e) = rule.schema.dyn_parse(target_val) {
435                    errors =
436                        errors.merge(e.with_prefix(PathSegment::Field(rule.target_field.clone())));
437                }
438            }
439        }
440
441        if errors.is_empty() {
442            Ok(result)
443        } else {
444            Err(errors)
445        }
446    }
447}
448
449/// Internal wrapper that makes a DynSchema nullable (null/missing → Value::Null).
450struct OptionalDynSchema(Box<dyn DynSchema>);
451
452impl DynSchema for OptionalDynSchema {
453    fn dyn_parse(&self, value: &Value) -> Result<Value, VldError> {
454        if value.is_null() {
455            return Ok(Value::Null);
456        }
457        self.0.dyn_parse(value)
458    }
459}
460
461/// Internal wrapper that rejects null values.
462struct RequiredDynSchema(Box<dyn DynSchema>);
463
464impl DynSchema for RequiredDynSchema {
465    fn dyn_parse(&self, value: &Value) -> Result<Value, VldError> {
466        if value.is_null() {
467            return Err(VldError::single(
468                IssueCode::MissingField,
469                "Required field is missing or null",
470            ));
471        }
472        self.0.dyn_parse(value)
473    }
474}