Skip to main content

siderite_validation/
model.rs

1//! Building blocks used by `#[derive(Validate)]` for structs with named
2//! fields. They are public so hand-written models can use them too; the
3//! reference model in this module's tests shows the exact shape the derive
4//! generates.
5
6use crate::context::{Extra, ModelConfig, ValidationContext};
7use crate::error::FieldError;
8use crate::rules;
9use crate::validate::{Validate, json_type};
10use regex::Regex;
11use serde::de::DeserializeOwned;
12use serde_json::{Number, Value};
13
14/// Static description of one field of a model.
15#[derive(Debug, Clone, Copy)]
16pub struct FieldSpec {
17    /// Key Serde deserializes the field from (after `rename`/`rename_all`).
18    pub key: &'static str,
19    /// Other accepted input keys (validation aliases; the Rust field name
20    /// when `populate_by_name`). Sending more than one of the key and its
21    /// aliases is a `duplicate_field` error.
22    pub aliases: &'static [&'static str],
23    /// Whether a missing key is an error (no `Option`, no default).
24    pub required: bool,
25}
26
27impl FieldSpec {
28    /// Name used in error locations: the first alias (Pydantic reports the
29    /// alias by default) or the key.
30    pub fn input_name(&self) -> &'static str {
31        self.aliases.first().copied().unwrap_or(self.key)
32    }
33}
34
35/// Prepare a model object: installs `config`, checks the input is an object,
36/// resolves aliases, reports missing and extra keys, and calls `field` for
37/// each present field with the location set to the key the client sent.
38pub fn prepare_object(
39    input: &mut Value,
40    ctx: &mut ValidationContext,
41    config: ModelConfig,
42    specs: &[FieldSpec],
43    mut field: impl FnMut(usize, &mut Value, &mut ValidationContext),
44) {
45    ctx.with_config(config, |ctx| {
46        let Value::Object(map) = input else {
47            let got = json_type(input);
48            ctx.error("model_type", format!("expected an object, got {got}"));
49            return;
50        };
51        for (index, spec) in specs.iter().enumerate() {
52            // The key wins over aliases, and earlier aliases over later ones;
53            // any other name for the field sent alongside is a duplicate
54            // (Serde would reject it too).
55            let mut names = std::iter::once(spec.key)
56                .chain(spec.aliases.iter().copied())
57                .filter(|name| map.contains_key(*name));
58            let sent_as = names.next().unwrap_or(spec.key);
59            for duplicate in names.collect::<Vec<_>>() {
60                map.remove(duplicate);
61                ctx.at(duplicate, |ctx| {
62                    ctx.error(
63                        "duplicate_field",
64                        format!("`{sent_as}` and `{duplicate}` both set this field"),
65                    );
66                });
67            }
68            if sent_as != spec.key
69                && let Some(value) = map.remove(sent_as)
70            {
71                map.insert(spec.key.to_owned(), value);
72            }
73            match map.get_mut(spec.key) {
74                Some(slot) => ctx.at(sent_as, |ctx| field(index, slot, ctx)),
75                None if spec.required => {
76                    ctx.at(spec.input_name(), |ctx| {
77                        ctx.error("missing", "field required")
78                    });
79                }
80                None => {}
81            }
82        }
83        let known = |k: &str| specs.iter().any(|s| s.key == k || s.aliases.contains(&k));
84        let extras: Vec<String> = map.keys().filter(|k| !known(k)).cloned().collect();
85        for key in extras {
86            if config.extra == Extra::Forbid {
87                ctx.at(key.as_str(), |ctx| {
88                    ctx.error("extra_forbidden", "extra inputs are not permitted")
89                });
90            } else {
91                // Keep Serde happy even with `deny_unknown_fields`.
92                map.remove(&key);
93            }
94        }
95    });
96}
97
98/// A field constraint checked on the (already coerced) raw value.
99///
100/// Checks are skipped for `null` (optional fields) and for values of the
101/// wrong JSON type (the type error is already reported by `prepare`).
102#[derive(Debug, Clone, Copy)]
103pub enum Constraint<'a> {
104    /// Minimum length (characters for strings, items for arrays/objects).
105    MinLength(usize),
106    /// Maximum length.
107    MaxLength(usize),
108    /// Regex the whole string must match (`None` = pattern failed to compile).
109    Pattern(Option<&'a Regex>),
110    /// Email format.
111    Email,
112    /// Absolute URL, as accepted by [`crate::types::Url`].
113    Url,
114    /// `> n`.
115    Gt(&'a Number),
116    /// `>= n`.
117    Ge(&'a Number),
118    /// `< n`.
119    Lt(&'a Number),
120    /// `<= n`.
121    Le(&'a Number),
122    /// Multiple of `n`.
123    MultipleOf(&'a Number),
124}
125
126/// Compare two JSON numbers exactly when both are integers.
127fn cmp_numbers(a: &Number, b: &Number) -> Option<std::cmp::Ordering> {
128    match (
129        a.as_i64().map(i128::from).or(a.as_u64().map(i128::from)),
130        b.as_i64().map(i128::from).or(b.as_u64().map(i128::from)),
131    ) {
132        (Some(x), Some(y)) => Some(x.cmp(&y)),
133        _ => a.as_f64()?.partial_cmp(&b.as_f64()?),
134    }
135}
136
137/// Apply `constraint` to `value`, reporting at the current location.
138pub fn check(value: &Value, constraint: Constraint<'_>, ctx: &mut ValidationContext) {
139    use std::cmp::Ordering::{Equal, Greater, Less};
140    let len = match value {
141        Value::String(s) => Some(s.chars().count()),
142        Value::Array(a) => Some(a.len()),
143        Value::Object(o) => Some(o.len()),
144        _ => None,
145    };
146    let result = match (constraint, value) {
147        (_, Value::Null) => Ok(()),
148        (Constraint::MinLength(min), _) => match len {
149            Some(n) if n < min => Err(FieldError::new(
150                "too_short",
151                format!("should have at least {min} items/characters"),
152            )),
153            _ => Ok(()),
154        },
155        (Constraint::MaxLength(max), _) => match len {
156            Some(n) if n > max => Err(FieldError::new(
157                "too_long",
158                format!("should have at most {max} items/characters"),
159            )),
160            _ => Ok(()),
161        },
162        (Constraint::Pattern(Some(re)), Value::String(s)) => rules::pattern(s, re),
163        (Constraint::Pattern(None), _) => Err(FieldError::new(
164            "pattern_invalid",
165            "the field pattern is not a valid regex",
166        )),
167        (Constraint::Email, Value::String(s)) => rules::email(s),
168        (Constraint::Url, Value::String(s)) => rules::parse_url(s).map(drop),
169        (c, Value::Number(n)) => {
170            let bound =
171                |limit: &Number, ok: &[std::cmp::Ordering], code: &'static str, op: &str| {
172                    match cmp_numbers(n, limit) {
173                        Some(o) if ok.contains(&o) => Ok(()),
174                        _ => Err(FieldError::new(code, format!("should be {op} {limit}"))),
175                    }
176                };
177            match c {
178                Constraint::Gt(l) => bound(l, &[Greater], "greater_than", ">"),
179                Constraint::Ge(l) => bound(l, &[Greater, Equal], "greater_than_equal", ">="),
180                Constraint::Lt(l) => bound(l, &[Less], "less_than", "<"),
181                Constraint::Le(l) => bound(l, &[Less, Equal], "less_than_equal", "<="),
182                Constraint::MultipleOf(m) => match (n.as_i64(), m.as_i64()) {
183                    (Some(x), Some(y)) => rules::multiple_of(x, y),
184                    _ => match (n.as_f64(), m.as_f64()) {
185                        (Some(x), Some(y))
186                            if y != 0.0 && ((x / y) - (x / y).round()).abs() < 1e-9 =>
187                        {
188                            Ok(())
189                        }
190                        _ => Err(FieldError::new(
191                            "multiple_of",
192                            format!("should be a multiple of {m}"),
193                        )),
194                    },
195                },
196                _ => Ok(()),
197            }
198        }
199        _ => Ok(()),
200    };
201    ctx.check(result);
202}
203
204/// Differential check used in tests: for every input, `prepare` reporting
205/// no errors must imply Serde succeeds, and a Serde failure must have been
206/// reported by `prepare`. Returns a description of the first disagreement.
207///
208/// # Errors
209/// The first input on which `prepare` and Serde disagree.
210#[doc(hidden)]
211pub fn prepare_agrees_with_serde<T>(inputs: &[Value]) -> Result<(), String>
212where
213    T: DeserializeOwned + Validate,
214{
215    for input in inputs {
216        let mut prepared = input.clone();
217        let mut ctx = ValidationContext::new();
218        T::prepare(&mut prepared, &mut ctx);
219        let prepare_ok = !ctx.has_errors();
220        let serde_ok = serde_json::from_value::<T>(prepared.clone()).is_ok();
221        if prepare_ok && !serde_ok {
222            return Err(format!(
223                "prepare accepted but serde rejected: {input} (prepared {prepared})"
224            ));
225        }
226    }
227    Ok(())
228}
229
230#[cfg(test)]
231#[allow(clippy::needless_borrow)]
232mod tests {
233    //! A hand-written model identical to what `#[derive(Validate)]` generates
234    //! for:
235    //!
236    //! ```ignore
237    //! #[derive(Deserialize, Validate)]
238    //! #[serde(rename_all = "camelCase")]
239    //! #[model_config(extra = "forbid", str_strip_whitespace)]
240    //! struct Signup {
241    //!     #[field(min_length = 2, validation_alias = "login")]
242    //!     user_name: String,
243    //!     #[field(ge = 18)]
244    //!     age: Option<u8>,
245    //!     tags: Vec<String>,
246    //! }
247    //! #[model_hooks]
248    //! impl Signup {
249    //!     #[model_validator(mode = "after")]
250    //!     fn no_admin(&self) -> ValidationResult<()> { ... }
251    //! }
252    //! ```
253    use super::*;
254    use crate::context::ModelConfig;
255    #[allow(unused_imports)]
256    use crate::hooks::{ModelHooks, Probe, ViaDefault as _, ViaHooks as _};
257    use serde::Deserialize;
258    use serde_json::json;
259
260    #[derive(Debug, Deserialize)]
261    #[serde(rename_all = "camelCase")]
262    struct Signup {
263        user_name: String,
264        age: Option<u8>,
265        tags: Vec<String>,
266    }
267
268    const CONFIG: ModelConfig = ModelConfig {
269        extra: Extra::Forbid,
270        str_strip_whitespace: true,
271        ..ModelConfig::DEFAULT
272    };
273    const FIELDS: &[FieldSpec] = &[
274        FieldSpec {
275            key: "userName",
276            aliases: &["login"],
277            required: true,
278        },
279        FieldSpec {
280            key: "age",
281            aliases: &[],
282            required: false,
283        },
284        FieldSpec {
285            key: "tags",
286            aliases: &[],
287            required: true,
288        },
289    ];
290
291    impl Validate for Signup {
292        fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
293            (&&Probe::<Self>::new()).before_model(input, ctx);
294            prepare_object(input, ctx, CONFIG, FIELDS, |index, slot, ctx| match index {
295                0 => {
296                    (&&Probe::<Self>::new()).before_field("user_name", slot, ctx);
297                    let before = ctx.error_count();
298                    <String as Validate>::prepare(slot, ctx);
299                    if ctx.error_count() == before {
300                        check(slot, Constraint::MinLength(2), ctx);
301                    }
302                }
303                1 => {
304                    (&&Probe::<Self>::new()).before_field("age", slot, ctx);
305                    let before = ctx.error_count();
306                    <Option<u8> as Validate>::prepare(slot, ctx);
307                    if ctx.error_count() == before {
308                        check(slot, Constraint::Ge(&Number::from(18)), ctx);
309                    }
310                }
311                2 => {
312                    (&&Probe::<Self>::new()).before_field("tags", slot, ctx);
313                    <Vec<String> as Validate>::prepare(slot, ctx);
314                }
315                _ => {}
316            });
317        }
318
319        fn validate(&self, ctx: &mut ValidationContext) {
320            ctx.with_config(CONFIG, |ctx| {
321                ctx.at("userName", |ctx| self.user_name.validate(ctx));
322                ctx.at("age", |ctx| self.age.validate(ctx));
323                ctx.at("tags", |ctx| self.tags.validate(ctx));
324                (&&Probe::<Self>::new()).after_fields(self, ctx);
325                (&&Probe::<Self>::new()).after_model(self, ctx);
326            });
327        }
328    }
329
330    impl ModelHooks for Signup {
331        fn after_model(&self, ctx: &mut ValidationContext) {
332            if self.user_name == "admin" {
333                ctx.error("reserved", "admin is reserved");
334            }
335        }
336    }
337
338    fn codes(input: Value) -> Vec<(String, String)> {
339        match crate::parse_value::<Signup>(input, ValidationContext::new().at_root("body")) {
340            Ok(_) => vec![],
341            Err(e) => e
342                .errors
343                .into_iter()
344                .map(|e| {
345                    (
346                        e.location
347                            .iter()
348                            .map(ToString::to_string)
349                            .collect::<Vec<_>>()
350                            .join("."),
351                        e.code.into_owned(),
352                    )
353                })
354                .collect(),
355        }
356    }
357
358    #[test]
359    fn valid_input_with_alias_and_coercion() {
360        let s: Signup = crate::parse_value(
361            json!({"login": "  bob ", "age": "30", "tags": []}),
362            ValidationContext::new(),
363        )
364        .unwrap();
365        assert_eq!(s.user_name, "bob");
366        assert_eq!(s.age, Some(30));
367    }
368
369    #[test]
370    fn every_error_is_reported_with_input_locations() {
371        let got = codes(json!({"login": "b", "age": 10, "extra": 1}));
372        assert_eq!(
373            got,
374            vec![
375                ("body.login".into(), "too_short".into()),
376                ("body.age".into(), "greater_than_equal".into()),
377                ("body.tags".into(), "missing".into()),
378                ("body.extra".into(), "extra_forbidden".into()),
379            ]
380        );
381    }
382
383    #[test]
384    fn key_and_alias_together_are_a_duplicate() {
385        assert_eq!(
386            codes(json!({"userName": "ab", "login": "cd", "tags": []})),
387            vec![("body.login".into(), "duplicate_field".into())]
388        );
389    }
390
391    #[test]
392    fn model_validator_runs_after_fields() {
393        assert_eq!(
394            codes(json!({"userName": "admin", "tags": []})),
395            vec![("body".into(), "reserved".into())]
396        );
397    }
398
399    #[test]
400    fn non_object_input() {
401        assert_eq!(
402            codes(json!([1])),
403            vec![("body".into(), "model_type".into())]
404        );
405    }
406
407    #[test]
408    fn prepare_and_serde_agree() {
409        let inputs = [
410            json!({}),
411            json!(null),
412            json!({"userName": "ab", "tags": ["x"]}),
413            json!({"userName": 5, "tags": "x"}),
414            json!({"userName": "ab", "tags": [1]}),
415            json!({"login": "ab", "userName": "cd", "tags": []}),
416            json!({"userName": "ab", "age": 300, "tags": []}),
417            json!({"userName": "ab", "age": null, "tags": []}),
418            json!({"userName": "ab", "age": "19", "tags": []}),
419        ];
420        prepare_agrees_with_serde::<Signup>(&inputs).unwrap();
421    }
422}