Skip to main content

siderite_validation/
validate.rs

1//! The [`Validate`] trait and its implementations for standard types.
2//!
3//! Validation has two phases, mirroring Pydantic's before/after split:
4//!
5//! 1. [`Validate::prepare`] runs on the raw [`Value`] **before**
6//!    deserialization. It is *type driven*: every type checks and, in lax
7//!    mode, coerces its own slot of the input (`"42"` → `42` for integers,
8//!    trimming for strings, ...). Derived models walk their fields, report
9//!    missing/extra keys and recurse with the location extended. Because all
10//!    errors are collected here, one request reports every problem at once.
11//! 2. [`Validate::validate`] runs on the typed value **after**
12//!    deserialization: field constraints, after-validators, model validators.
13//!
14//! Both phases push errors into a [`ValidationContext`]; neither panics.
15
16use crate::context::ValidationContext;
17use crate::error::LocationItem;
18use crate::rules;
19use crate::types::{BoundedI64, ConstrainedString, Email, PositiveInt, SecretString};
20use serde_json::{Map, Number, Value};
21use std::collections::{BTreeMap, HashMap};
22
23/// A type that takes part in validation.
24///
25/// Both methods default to doing nothing, so opting a type in is a one-line
26/// `impl Validate for T {}`. `#[derive(Validate)]` generates both.
27pub trait Validate {
28    /// Check and normalise the raw input for this type in place.
29    fn prepare(_input: &mut Value, _ctx: &mut ValidationContext) {}
30
31    /// Check invariants of the deserialized value.
32    fn validate(&self, _ctx: &mut ValidationContext) {}
33}
34
35/// JSON type name used in error messages.
36pub(crate) fn json_type(value: &Value) -> &'static str {
37    match value {
38        Value::Null => "null",
39        Value::Bool(_) => "boolean",
40        Value::Number(_) => "number",
41        Value::String(_) => "string",
42        Value::Array(_) => "array",
43        Value::Object(_) => "object",
44    }
45}
46
47fn type_error(ctx: &mut ValidationContext, code: &'static str, expected: &str, got: &Value) {
48    ctx.error(code, format!("expected {expected}, got {}", json_type(got)));
49}
50
51/// Coerce/check an integer slot against `[min, max]`.
52fn prepare_int(input: &mut Value, ctx: &mut ValidationContext, min: i128, max: i128) {
53    let candidate: Option<i128> = match &*input {
54        Value::Number(n) => {
55            if let Some(i) = n.as_i64() {
56                Some(i128::from(i))
57            } else if let Some(u) = n.as_u64() {
58                Some(i128::from(u))
59            } else if let Some(f) = n.as_f64() {
60                if ctx.is_strict() || f.fract() != 0.0 || !f.is_finite() {
61                    ctx.error(
62                        "int_from_float",
63                        "expected an integer, got a number with a fractional part",
64                    );
65                    return;
66                }
67                // Exact: fract() == 0 and range is checked below.
68                #[allow(clippy::cast_possible_truncation)]
69                Some(f as i128)
70            } else {
71                None
72            }
73        }
74        Value::String(s) if ctx.allows_text_coercion() => match s.trim().parse::<i128>() {
75            Ok(i) => Some(i),
76            Err(_) => {
77                ctx.error("int_parsing", "input is not a valid integer");
78                return;
79            }
80        },
81        other => {
82            let other = other.clone();
83            type_error(ctx, "int_type", "an integer", &other);
84            return;
85        }
86    };
87    let Some(value) = candidate else {
88        ctx.error("int_parsing", "input is not a valid integer");
89        return;
90    };
91    if value < min || value > max {
92        ctx.error(
93            "int_out_of_range",
94            format!("integer must be between {min} and {max}"),
95        );
96        return;
97    }
98    *input = if value < 0 {
99        i64::try_from(value).map_or(Value::Null, |v| Value::Number(v.into()))
100    } else {
101        u64::try_from(value).map_or(Value::Null, |v| Value::Number(v.into()))
102    };
103}
104
105macro_rules! int_validate {
106    ($($t:ty),*) => {$(
107        impl Validate for $t {
108            fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
109                prepare_int(input, ctx, i128::from(<$t>::MIN), i128::from(<$t>::MAX));
110            }
111        }
112    )*};
113}
114int_validate!(i8, i16, i32, i64, u8, u16, u32, u64);
115
116// Bounds are cast because `From` is not implemented for the pointer-sized
117// integers; 128-bit values are limited to what a JSON number can carry.
118macro_rules! wide_int_validate {
119    ($($t:ty => $min:expr, $max:expr);* $(;)?) => {$(
120        impl Validate for $t {
121            fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
122                prepare_int(input, ctx, $min, $max);
123            }
124        }
125    )*};
126}
127wide_int_validate! {
128    isize => isize::MIN as i128, isize::MAX as i128;
129    usize => 0, usize::MAX as i128;
130    i128 => i128::from(i64::MIN), i128::from(u64::MAX);
131    u128 => 0, i128::from(u64::MAX);
132}
133
134fn prepare_float(input: &mut Value, ctx: &mut ValidationContext) {
135    match &*input {
136        Value::Number(_) => {}
137        Value::String(s) if ctx.allows_text_coercion() => {
138            match s.trim().parse::<f64>().ok().and_then(Number::from_f64) {
139                Some(n) => *input = Value::Number(n),
140                None => ctx.error("float_parsing", "input is not a valid finite number"),
141            }
142        }
143        other => {
144            let other = other.clone();
145            type_error(ctx, "float_type", "a number", &other);
146        }
147    }
148}
149
150impl Validate for f32 {
151    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
152        prepare_float(input, ctx);
153    }
154}
155
156impl Validate for f64 {
157    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
158        prepare_float(input, ctx);
159    }
160}
161
162impl Validate for bool {
163    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
164        let coerced = match &*input {
165            Value::Bool(_) => return,
166            Value::String(s) if ctx.allows_text_coercion() => {
167                match s.trim().to_ascii_lowercase().as_str() {
168                    "true" | "t" | "yes" | "y" | "on" | "1" => Some(true),
169                    "false" | "f" | "no" | "n" | "off" | "0" => Some(false),
170                    _ => None,
171                }
172            }
173            Value::Number(n) if !ctx.is_strict() => match n.as_u64() {
174                Some(0) => Some(false),
175                Some(1) => Some(true),
176                _ => None,
177            },
178            other => {
179                let other = other.clone();
180                type_error(ctx, "bool_type", "a boolean", &other);
181                return;
182            }
183        };
184        match coerced {
185            Some(b) => *input = Value::Bool(b),
186            None => ctx.error("bool_parsing", "input is not a valid boolean"),
187        }
188    }
189}
190
191/// Apply the current model's string transforms and check the type.
192fn prepare_string(input: &mut Value, ctx: &mut ValidationContext) -> Option<()> {
193    let Value::String(s) = input else {
194        let other = input.clone();
195        type_error(ctx, "string_type", "a string", &other);
196        return None;
197    };
198    let config = *ctx.config();
199    if config.str_strip_whitespace {
200        let trimmed = s.trim();
201        if trimmed.len() != s.len() {
202            *s = trimmed.to_owned();
203        }
204    }
205    if config.str_to_lower {
206        *s = s.to_lowercase();
207    } else if config.str_to_upper {
208        *s = s.to_uppercase();
209    }
210    Some(())
211}
212
213impl Validate for String {
214    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
215        prepare_string(input, ctx);
216    }
217}
218
219impl Validate for Value {}
220
221impl<T: Validate> Validate for Option<T> {
222    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
223        if !input.is_null() {
224            T::prepare(input, ctx);
225        }
226    }
227
228    fn validate(&self, ctx: &mut ValidationContext) {
229        if let Some(inner) = self {
230            inner.validate(ctx);
231        }
232    }
233}
234
235impl<T: Validate + ?Sized> Validate for Box<T> {
236    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
237        T::prepare(input, ctx);
238    }
239
240    fn validate(&self, ctx: &mut ValidationContext) {
241        (**self).validate(ctx);
242    }
243}
244
245impl<T: Validate> Validate for Vec<T> {
246    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
247        if !input.is_array() {
248            // Text sources repeat keys for lists; a single value is a one-item list.
249            if ctx.input_kind() == crate::context::InputKind::Text && !input.is_null() {
250                *input = Value::Array(vec![input.take()]);
251            } else {
252                let other = input.clone();
253                type_error(ctx, "list_type", "an array", &other);
254                return;
255            }
256        }
257        if let Value::Array(items) = input {
258            for (index, item) in items.iter_mut().enumerate() {
259                ctx.at(LocationItem::index(index), |ctx| T::prepare(item, ctx));
260            }
261        }
262    }
263
264    fn validate(&self, ctx: &mut ValidationContext) {
265        for (index, item) in self.iter().enumerate() {
266            ctx.at(LocationItem::index(index), |ctx| item.validate(ctx));
267        }
268    }
269}
270
271fn prepare_map<T: Validate>(input: &mut Value, ctx: &mut ValidationContext) {
272    let Value::Object(map) = input else {
273        let other = input.clone();
274        type_error(ctx, "dict_type", "an object", &other);
275        return;
276    };
277    for (key, value) in map.iter_mut() {
278        ctx.at(key.as_str(), |ctx| T::prepare(value, ctx));
279    }
280}
281
282impl<T: Validate> Validate for BTreeMap<String, T> {
283    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
284        prepare_map::<T>(input, ctx);
285    }
286
287    fn validate(&self, ctx: &mut ValidationContext) {
288        for (key, value) in self {
289            ctx.at(key.as_str(), |ctx| value.validate(ctx));
290        }
291    }
292}
293
294impl<T: Validate, S> Validate for HashMap<String, T, S> {
295    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
296        prepare_map::<T>(input, ctx);
297    }
298
299    fn validate(&self, ctx: &mut ValidationContext) {
300        for (key, value) in self {
301            ctx.at(key.as_str(), |ctx| value.validate(ctx));
302        }
303    }
304}
305
306impl Validate for Map<String, Value> {
307    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
308        prepare_map::<Value>(input, ctx);
309    }
310}
311
312macro_rules! tuple_validate {
313    ($($t:ident $i:tt),+) => {
314        /// Tuples (e.g. positional path parameters) validate each element.
315        impl<$($t: Validate),+> Validate for ($($t,)+) {
316            fn validate(&self, ctx: &mut ValidationContext) {
317                $(ctx.at(LocationItem::index($i), |ctx| self.$i.validate(ctx));)+
318            }
319        }
320    };
321}
322tuple_validate!(A 0);
323tuple_validate!(A 0, B 1);
324tuple_validate!(A 0, B 1, C 2);
325tuple_validate!(A 0, B 1, C 2, D 3);
326
327impl Validate for char {}
328
329impl<const MIN: usize, const MAX: usize> Validate for ConstrainedString<MIN, MAX> {
330    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
331        if prepare_string(input, ctx).is_some() {
332            let s = input.as_str().unwrap_or_default();
333            ctx.check(rules::min_length(s, MIN));
334            ctx.check(rules::max_length(s, MAX));
335        }
336    }
337}
338
339impl Validate for Email {
340    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
341        if prepare_string(input, ctx).is_some() {
342            ctx.check(rules::email(input.as_str().unwrap_or_default()));
343        }
344    }
345}
346
347impl Validate for SecretString {
348    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
349        prepare_string(input, ctx);
350    }
351}
352
353impl<const MIN: i64, const MAX: i64> Validate for BoundedI64<MIN, MAX> {
354    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
355        prepare_int(input, ctx, i128::from(MIN), i128::from(MAX));
356    }
357}
358
359impl Validate for PositiveInt {
360    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
361        let before = ctx.error_count();
362        prepare_int(input, ctx, i128::from(i64::MIN), i128::from(i64::MAX));
363        if ctx.error_count() == before {
364            ctx.check(rules::gt(&input.as_i64().unwrap_or_default(), &0));
365        }
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::context::ModelConfig;
373    use serde_json::json;
374
375    fn run<T: Validate>(mut input: Value, ctx: &mut ValidationContext) -> (Value, Vec<String>) {
376        T::prepare(&mut input, ctx);
377        let codes = ctx
378            .take_errors()
379            .errors
380            .into_iter()
381            .map(|e| e.code.into_owned())
382            .collect();
383        (input, codes)
384    }
385
386    fn lax<T: Validate>(input: Value) -> (Value, Vec<String>) {
387        run::<T>(input, &mut ValidationContext::new())
388    }
389
390    fn strict<T: Validate>(input: Value) -> (Value, Vec<String>) {
391        run::<T>(input, &mut ValidationContext::new().with_strict(true))
392    }
393
394    #[test]
395    fn integers_coerce_in_lax_mode_only() {
396        assert_eq!(lax::<i32>(json!("42")), (json!(42), vec![]));
397        assert_eq!(lax::<i32>(json!(3.0)), (json!(3), vec![]));
398        assert_eq!(lax::<i32>(json!(3.5)).1, ["int_from_float"]);
399        assert_eq!(lax::<u8>(json!(300)).1, ["int_out_of_range"]);
400        assert_eq!(lax::<u8>(json!(-1)).1, ["int_out_of_range"]);
401        assert_eq!(lax::<i64>(json!("x")).1, ["int_parsing"]);
402        assert_eq!(strict::<i32>(json!("42")).1, ["int_type"]);
403        assert_eq!(strict::<i32>(json!(3.0)).1, ["int_from_float"]);
404        assert_eq!(lax::<i32>(json!(true)).1, ["int_type"]);
405        assert_eq!(lax::<usize>(json!("5")), (json!(5), vec![]));
406        assert_eq!(lax::<usize>(json!(-1)).1, ["int_out_of_range"]);
407        assert_eq!(lax::<i128>(json!(-3)), (json!(-3), vec![]));
408        assert_eq!(lax::<u128>(json!(-3)).1, ["int_out_of_range"]);
409    }
410
411    #[test]
412    fn text_input_coerces_even_when_strict() {
413        let mut ctx = ValidationContext::for_text().with_strict(true);
414        assert_eq!(run::<i64>(json!("7"), &mut ctx), (json!(7), vec![]));
415        assert_eq!(run::<Vec<u8>>(json!("7"), &mut ctx), (json!([7]), vec![]));
416    }
417
418    #[test]
419    fn floats_and_bools() {
420        assert_eq!(lax::<f64>(json!("1.5")), (json!(1.5), vec![]));
421        assert_eq!(lax::<f64>(json!("nan")).1, ["float_parsing"]);
422        assert_eq!(lax::<bool>(json!("Yes")), (json!(true), vec![]));
423        assert_eq!(lax::<bool>(json!(0)), (json!(false), vec![]));
424        assert_eq!(lax::<bool>(json!("maybe")).1, ["bool_parsing"]);
425        assert_eq!(strict::<bool>(json!(1)).1, ["bool_type"]);
426    }
427
428    #[test]
429    fn strings_apply_model_transforms() {
430        let config = ModelConfig {
431            str_strip_whitespace: true,
432            str_to_lower: true,
433            ..ModelConfig::DEFAULT
434        };
435        let mut ctx = ValidationContext::new();
436        let out = ctx.with_config(config, |ctx| run::<String>(json!("  HeLLo "), ctx));
437        assert_eq!(out, (json!("hello"), vec![]));
438        assert_eq!(lax::<String>(json!(5)).1, ["string_type"]);
439    }
440
441    #[test]
442    fn containers_collect_every_error_with_locations() {
443        let mut ctx = ValidationContext::new().at_root("body");
444        let mut input = json!({"a": [1, "x", 2.5], "b": null});
445        <BTreeMap<String, Vec<Option<i32>>>>::prepare(&mut input, &mut ctx);
446        let errors = ctx.take_errors().errors;
447        assert_eq!(errors.len(), 3);
448        assert_eq!(
449            errors[0].location,
450            vec!["body".into(), "a".into(), LocationItem::index(1)]
451        );
452        assert_eq!(errors[2].location, vec!["body".into(), "b".into()]);
453        assert_eq!(errors[2].code, "list_type");
454    }
455
456    #[test]
457    fn constrained_types_check_during_prepare() {
458        assert_eq!(
459            lax::<ConstrainedString<2, 3>>(json!("a")).1,
460            ["string_too_short"]
461        );
462        assert_eq!(lax::<Email>(json!("nope")).1, ["invalid_email"]);
463        assert_eq!(lax::<PositiveInt>(json!("0")).1, ["greater_than"]);
464        assert_eq!(lax::<BoundedI64<1, 5>>(json!(9)).1, ["int_out_of_range"]);
465    }
466}