Skip to main content

seam_core/
validate.rs

1//! One walk over the payload, reading it in place.
2//!
3//! Generic over [`Input`] so a binding validates its host's own objects without
4//! copying them into a [`Value`] first. Everything here reads; nothing here
5//! allocates a representation of the payload.
6
7use crate::datetime::{validate_date, validate_datetime};
8use crate::error::{Code, Issue, Path, Segment, ValidationError};
9use crate::input::{Input, Kind};
10use crate::limits::Limits;
11use crate::schema::{Field, IntType, ObjectType, Presence, Rule, Schema, Type, UnionType};
12use crate::value::{Int, Slot, Value};
13
14pub fn validate<I: Input>(
15    schema: &Schema,
16    type_name: &str,
17    input: &I,
18    limits: Limits,
19) -> Result<(), ValidationError> {
20    // Clamped here rather than in each binding: depth is the one limit whose
21    // breach is fatal instead of reportable, so nothing may raise it past what
22    // the stack can take.
23    let limits = limits.clamped();
24    let mut v = Validator { schema, limits, path: Vec::new(), issues: Vec::new() };
25
26    match schema.get(type_name) {
27        Some(ty) => v.object(ty, input, 0),
28        None => match schema.union(type_name) {
29            Some(u) => v.union(u, input, 0),
30            None => v.push(
31                Code::UnknownType,
32                format!("schema declares no type named `{type_name}`"),
33            ),
34        },
35    }
36
37    if v.issues.is_empty() {
38        Ok(())
39    } else {
40        Err(ValidationError { issues: v.issues })
41    }
42}
43
44enum Step<'a> {
45    Key(&'a str),
46    Index(usize),
47}
48
49struct Validator<'a> {
50    schema: &'a Schema,
51    limits: Limits,
52    path: Vec<Step<'a>>,
53    issues: Vec<Issue>,
54}
55
56impl<'a> Validator<'a> {
57    fn here(&self) -> Path {
58        Path(
59            self.path
60                .iter()
61                .map(|step| match step {
62                    Step::Key(k) => Segment::Key((*k).to_string()),
63                    Step::Index(i) => Segment::Index(*i),
64                })
65                .collect(),
66        )
67    }
68
69    fn push(&mut self, code: Code, message: String) {
70        let path = self.here();
71        self.issues.push(Issue { path, code, message });
72    }
73
74    fn push_under(&mut self, key: String, code: Code, message: String) {
75        let mut path = self.here();
76        path.0.push(Segment::Key(key));
77        self.issues.push(Issue { path, code, message });
78    }
79
80    fn within<F: FnOnce(&mut Self)>(&mut self, step: Step<'a>, f: F) {
81        self.path.push(step);
82        f(self);
83        self.path.pop();
84    }
85
86    fn depth_ok(&mut self, depth: usize) -> bool {
87        if depth > self.limits.max_depth {
88            self.push(
89                Code::DepthExceeded,
90                format!("nesting deeper than the limit of {}", self.limits.max_depth),
91            );
92            return false;
93        }
94        true
95    }
96
97    fn mismatch<I: Input>(&mut self, expected: &str, got: &I) {
98        self.push(
99            Code::TypeMismatch,
100            format!("expected {expected}, found {}", got.kind().name()),
101        );
102    }
103
104    fn object<I: Input>(&mut self, ty: &'a ObjectType, input: &I, depth: usize) {
105        self.object_with(ty, input, depth, None);
106    }
107
108    /// `tag` is the one key a union has already accounted for. It is not a
109    /// field of the variant, so it must not be reported as an unknown one.
110    fn object_with<I: Input>(
111        &mut self,
112        ty: &'a ObjectType,
113        input: &I,
114        depth: usize,
115        tag: Option<&str>,
116    ) {
117        if !self.depth_ok(depth) {
118            return;
119        }
120        if input.kind() != Kind::Object {
121            self.mismatch("object", input);
122            return;
123        }
124
125        if input.len() > self.limits.max_object_keys {
126            self.push(
127                Code::SizeExceeded,
128                format!(
129                    "{} keys exceeds the limit of {}",
130                    input.len(),
131                    self.limits.max_object_keys
132                ),
133            );
134            return;
135        }
136
137        for field in &ty.fields {
138            self.field(field, input, depth);
139        }
140
141        if ty.deny_unknown_fields {
142            // Collected first because the callback cannot borrow `self` while
143            // it is already borrowed to walk the input.
144            let mut unknown = Vec::new();
145            input.each_key(&mut |key| {
146                if ty.field(key).is_none() && tag != Some(key) {
147                    unknown.push(key.to_string());
148                }
149            });
150            let owner = ty.name.clone();
151            for key in unknown {
152                let message = format!("`{owner}` declares no field named `{key}`");
153                self.push_under(key, Code::UnknownField, message);
154            }
155        }
156    }
157
158    /// One object, whose shape is decided by the value of its tag field.
159    ///
160    /// The tag is read first and reported at its own path. If it does not name
161    /// a variant there is nothing to check the rest of the payload against, so
162    /// that is the only issue reported: guessing a variant to keep going would
163    /// produce a list of errors about a shape the caller never claimed to send.
164    fn union<I: Input>(&mut self, u: &'a UnionType, input: &I, depth: usize) {
165        if !self.depth_ok(depth) {
166            return;
167        }
168        if input.kind() != Kind::Object {
169            self.mismatch("object", input);
170            return;
171        }
172
173        let chosen = match input.slot(&u.tag) {
174            Slot::Absent => {
175                let message = format!("`{}` decides which variant of `{}` this is", u.tag, u.name);
176                self.push_under(u.tag.clone(), Code::Required, message);
177                None
178            }
179            Slot::Null => {
180                let message = format!("the tag of `{}` cannot be null", u.name);
181                self.push_under(u.tag.clone(), Code::NullNotAllowed, message);
182                None
183            }
184            Slot::Present(value) if value.kind() != Kind::String => {
185                let message = format!("expected string, found {}", value.kind().name());
186                self.push_under(u.tag.clone(), Code::TypeMismatch, message);
187                None
188            }
189            Slot::Present(value) => match value.as_str() {
190                None => None,
191                Some(found) => match u.variant(&found) {
192                    Some(variant) => Some(variant),
193                    None => {
194                        let listed: Vec<&str> = u.variants.iter().map(|v| v.tag.as_str()).collect();
195                        let message = format!(
196                            "`{found}` is not a variant of `{}`; expected one of: {}",
197                            u.name,
198                            listed.join(", ")
199                        );
200                        self.push_under(u.tag.clone(), Code::UnknownVariant, message);
201                        None
202                    }
203                },
204            },
205        };
206
207        let Some(variant) = chosen else {
208            return;
209        };
210
211        // Bound before the mutable borrow, as in `value`.
212        let target = self.schema.get(&variant.type_name);
213        match target {
214            // Same object, same path: a variant is not a level of nesting, so
215            // an issue in it is `event.amount`, never `event.payment.amount`.
216            Some(obj) => self.object_with(obj, input, depth, Some(&u.tag)),
217            None => self.push(
218                Code::UnknownType,
219                format!("schema declares no type named `{}`", variant.type_name),
220            ),
221        }
222    }
223
224    fn field<I: Input>(&mut self, field: &'a Field, input: &I, depth: usize) {
225        let slot = input.slot(&field.name);
226        self.within(Step::Key(&field.name), |v| match slot {
227            Slot::Absent => {
228                if !field.presence.optional {
229                    v.push(Code::Required, required_message(field.presence));
230                }
231            }
232            Slot::Null => {
233                if !field.presence.nullable {
234                    v.push(Code::NullNotAllowed, null_message(field.presence));
235                }
236            }
237            Slot::Present(value) => {
238                v.value(&field.ty, &value, depth + 1);
239                v.rules(&field.rules, &value);
240            }
241        });
242    }
243
244    fn value<I: Input>(&mut self, ty: &'a Type, input: &I, depth: usize) {
245        // Applies to every string-shaped type, so it is checked once here
246        // rather than in each of String, Date, DateTime and Enum.
247        if input.kind() == Kind::String {
248            if let Some(s) = input.as_str() {
249                if s.len() > self.limits.max_string_bytes {
250                    self.push(
251                        Code::SizeExceeded,
252                        format!(
253                            "{} bytes exceeds the limit of {}",
254                            s.len(),
255                            self.limits.max_string_bytes
256                        ),
257                    );
258                    return;
259                }
260            }
261        }
262
263        match ty {
264            Type::Bool => {
265                if input.as_bool().is_none() {
266                    self.mismatch("bool", input);
267                }
268            }
269            Type::Float => match input.kind() {
270                Kind::Int => {}
271                Kind::Float => match input.as_f64() {
272                    Some(f) if f.is_finite() => {}
273                    _ => self.push(
274                        Code::NotFinite,
275                        "NaN and infinity are not valid values".to_string(),
276                    ),
277                },
278                _ => self.mismatch("float", input),
279            },
280            Type::String => {
281                if input.kind() != Kind::String {
282                    self.mismatch("string", input);
283                }
284            }
285            Type::Int(int_ty) => self.integer(*int_ty, input),
286            Type::Date => self.temporal(input, "date", validate_date),
287            Type::DateTime => self.temporal(input, "datetime", validate_datetime),
288            Type::Enum(allowed) => self.enumeration(allowed, input),
289            Type::Array { item, item_nullable } => self.array(item, *item_nullable, input, depth),
290            Type::Object(obj) => self.object(obj, input, depth),
291            Type::Ref(name) => {
292                // Bound first so the borrow of the schema is not tied to the
293                // mutable borrow of `self` that follows.
294                let target = self.schema.get(name);
295                match target {
296                    Some(obj) => self.object(obj, input, depth),
297                    None => match self.schema.union(name) {
298                        Some(u) => self.union(u, input, depth),
299                        None => self.push(
300                            Code::UnknownType,
301                            format!("schema declares no type named `{name}`"),
302                        ),
303                    },
304                }
305            }
306        }
307    }
308
309    fn integer<I: Input>(&mut self, ty: IntType, input: &I) {
310        if input.kind() == Kind::UnsafeInteger {
311            self.push(
312                Code::UnsafeInteger,
313                "arrived in a numeric type that cannot hold it exactly;                  send it as the host's arbitrary-precision integer"
314                    .to_string(),
315            );
316            return;
317        }
318        if input.kind() == Kind::IntegerTooWide {
319            self.push(
320                Code::IntegerTooWide,
321                "integer is wider than 64 bits".to_string(),
322            );
323            return;
324        }
325        let Some(n) = input.as_int() else {
326            self.mismatch(ty.name(), input);
327            return;
328        };
329        let (min, max) = ty.range();
330        let v = n.as_i128();
331        if v < min || v > max {
332            self.push(
333                Code::OutOfRange,
334                format!("{v} is outside the range of {} ({min}..={max})", ty.name()),
335            );
336        }
337    }
338
339    fn temporal<I: Input>(
340        &mut self,
341        input: &I,
342        expected: &str,
343        check: fn(&str) -> Result<(), Code>,
344    ) {
345        let Some(s) = input.as_str() else {
346            self.mismatch(expected, input);
347            return;
348        };
349        if let Err(code) = check(&s) {
350            let message = match code {
351                Code::MissingTimezone => {
352                    format!("`{s}` has no UTC offset; Seam does not assume local time")
353                }
354                _ => format!("`{s}` is not a valid {expected}"),
355            };
356            self.push(code, message);
357        }
358    }
359
360    fn enumeration<I: Input>(&mut self, allowed: &[String], input: &I) {
361        let Some(s) = input.as_str() else {
362            self.mismatch("string", input);
363            return;
364        };
365        if !allowed.iter().any(|a| a.as_str() == s.as_ref()) {
366            self.push(
367                Code::NotInEnum,
368                format!("`{s}` is not one of: {}", allowed.join(", ")),
369            );
370        }
371    }
372
373    fn array<I: Input>(&mut self, item: &'a Type, item_nullable: bool, input: &I, depth: usize) {
374        if !self.depth_ok(depth) {
375            return;
376        }
377        if input.kind() != Kind::Array {
378            self.mismatch("array", input);
379            return;
380        }
381        let len = input.len();
382        if len > self.limits.max_items {
383            self.push(
384                Code::SizeExceeded,
385                format!("{len} items exceeds the limit of {}", self.limits.max_items),
386            );
387            return;
388        }
389        for i in 0..len {
390            let Some(child) = input.item(i) else { continue };
391            self.within(Step::Index(i), |v| match child.kind() {
392                Kind::Null if item_nullable => {}
393                Kind::Null => v.push(
394                    Code::NullNotAllowed,
395                    "must not be null; declare the element as `T?` to allow it".to_string(),
396                ),
397                _ => v.value(item, &child, depth + 1),
398            });
399        }
400    }
401
402    fn rules<I: Input>(&mut self, rules: &[Rule], input: &I) {
403        // Counted once: `as_str` may decode, and asking per rule would pay for
404        // it twice on a field carrying both bounds.
405        let chars = rules
406            .iter()
407            .any(|r| matches!(r, Rule::MinLen(_) | Rule::MaxLen(_)))
408            .then(|| input.as_str().map(|s| s.chars().count()))
409            .flatten();
410
411        for rule in rules {
412            match rule {
413                Rule::MinLen(n) => {
414                    if let Some(len) = chars {
415                        if len < *n {
416                            self.push(
417                                Code::TooShort,
418                                format!("length {len} is below the minimum of {n}"),
419                            );
420                        }
421                    }
422                }
423                Rule::MaxLen(n) => {
424                    if let Some(len) = chars {
425                        if len > *n {
426                            self.push(
427                                Code::TooLong,
428                                format!("length {len} exceeds the maximum of {n}"),
429                            );
430                        }
431                    }
432                }
433                Rule::Range { min, max } => {
434                    if let Some(n) = input.as_int() {
435                        let v = n.as_i128();
436                        if v < *min || v > *max {
437                            self.push(Code::OutOfRange, format!("{v} is outside {min}..={max}"));
438                        }
439                    }
440                }
441                Rule::Format(f) => {
442                    // Only a string can have a format; anything else already
443                    // reported a type_mismatch and does not need a second
444                    // complaint about the same value.
445                    if let Some(s) = input.as_str() {
446                        if !f.matches(&s) {
447                            self.push(Code::InvalidFormat, format!("not a valid {}", f.name()));
448                        }
449                    }
450                }
451                Rule::MinItems(n) => {
452                    if input.kind() == Kind::Array && input.len() < *n {
453                        self.push(
454                            Code::TooFewItems,
455                            format!("{} items is below the minimum of {n}", input.len()),
456                        );
457                    }
458                }
459                Rule::MaxItems(n) => {
460                    if input.kind() == Kind::Array && input.len() > *n {
461                        self.push(
462                            Code::TooManyItems,
463                            format!("{} items exceeds the maximum of {n}", input.len()),
464                        );
465                    }
466                }
467            }
468        }
469    }
470}
471
472fn required_message(p: Presence) -> String {
473    if p.nullable {
474        "required; may be null but must be present".to_string()
475    } else {
476        "required".to_string()
477    }
478}
479
480fn null_message(p: Presence) -> String {
481    if p.optional {
482        "may be absent, but must not be null when present".to_string()
483    } else {
484        "must not be null".to_string()
485    }
486}
487
488impl From<Int> for Value {
489    fn from(i: Int) -> Self {
490        Value::Int(i)
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use crate::schema::IntWidth;
498    use std::collections::BTreeMap;
499
500    fn user_schema() -> Schema {
501        let user = ObjectType {
502            name: "User".into(),
503            deny_unknown_fields: true,
504            fields: vec![
505                Field {
506                    name: "id".into(),
507                    ty: Type::Int(IntType { width: IntWidth::W64, signed: false }),
508                    presence: Presence::required(),
509                    rules: vec![],
510                },
511                Field {
512                    name: "name".into(),
513                    ty: Type::String,
514                    presence: Presence::required(),
515                    rules: vec![Rule::MinLen(3), Rule::MaxLen(64)],
516                },
517                Field {
518                    name: "plan".into(),
519                    ty: Type::Enum(vec!["free".into(), "pro".into(), "enterprise".into()]),
520                    presence: Presence::required(),
521                    rules: vec![],
522                },
523                Field {
524                    name: "nickname".into(),
525                    ty: Type::String,
526                    presence: Presence::nullable(),
527                    rules: vec![],
528                },
529                Field {
530                    name: "bio".into(),
531                    ty: Type::String,
532                    presence: Presence::optional(),
533                    rules: vec![],
534                },
535                Field {
536                    name: "signup_date".into(),
537                    ty: Type::Date,
538                    presence: Presence::optional_nullable(),
539                    rules: vec![],
540                },
541                Field {
542                    name: "tags".into(),
543                    ty: Type::Array { item: Box::new(Type::String), item_nullable: false },
544                    presence: Presence::optional(),
545                    rules: vec![Rule::MaxItems(3)],
546                },
547            ],
548        };
549        let mut schema = Schema::default();
550        schema.types.insert("User".into(), user);
551        schema
552    }
553
554    fn obj(pairs: &[(&str, Value)]) -> Value {
555        let mut m = BTreeMap::new();
556        for (k, v) in pairs {
557            m.insert((*k).to_string(), v.clone());
558        }
559        Value::Object(m)
560    }
561
562    fn valid_user() -> Value {
563        obj(&[
564            ("id", Value::Int(Int::from(1_u64))),
565            ("name", Value::String("Gabriel".into())),
566            ("plan", Value::String("pro".into())),
567            ("nickname", Value::Null),
568        ])
569    }
570
571    fn with(key: &str, value: Value) -> Value {
572        let mut v = valid_user();
573        if let Value::Object(m) = &mut v {
574            m.insert(key.into(), value);
575        }
576        v
577    }
578
579    fn codes(v: &Value) -> Vec<(String, Code)> {
580        match validate(&user_schema(), "User", v, Limits::DEFAULT) {
581            Ok(()) => vec![],
582            Err(e) => e
583                .issues
584                .into_iter()
585                .map(|i| (i.path.render(), i.code))
586                .collect(),
587        }
588    }
589
590    #[test]
591    fn a_valid_payload_passes() {
592        assert_eq!(codes(&valid_user()), vec![]);
593    }
594
595    #[test]
596    fn the_readme_payload_keeps_its_u64_exactly() {
597        let v = with("id", Value::Int(Int::from(9_007_199_254_740_993_u64)));
598        assert_eq!(codes(&v), vec![]);
599    }
600
601    #[test]
602    fn absent_and_null_are_judged_separately() {
603        // nullable but required: null is fine, absent is not.
604        let mut m = BTreeMap::new();
605        m.insert("id".into(), Value::Int(Int::from(1_u64)));
606        m.insert("name".into(), Value::String("Gabriel".into()));
607        m.insert("plan".into(), Value::String("pro".into()));
608        assert_eq!(
609            codes(&Value::Object(m)),
610            vec![("nickname".into(), Code::Required)]
611        );
612
613        // optional but not nullable: absent is fine, null is not.
614        assert_eq!(
615            codes(&with("bio", Value::Null)),
616            vec![("bio".into(), Code::NullNotAllowed)]
617        );
618
619        // optional and nullable: both are fine.
620        assert_eq!(codes(&with("signup_date", Value::Null)), vec![]);
621    }
622
623    #[test]
624    fn a_naive_datetime_never_slips_through() {
625        let v = with("signup_date", Value::String("2026-08-29T00:00:00".into()));
626        assert_eq!(codes(&v), vec![("signup_date".into(), Code::InvalidDate)]);
627    }
628
629    #[test]
630    fn errors_carry_the_path_into_arrays() {
631        let v = with(
632            "tags",
633            Value::Array(vec![
634                Value::String("ok".into()),
635                Value::Int(Int::from(7_i64)),
636            ]),
637        );
638        assert_eq!(codes(&v), vec![("tags[1]".into(), Code::TypeMismatch)]);
639    }
640
641    #[test]
642    fn every_issue_is_reported_not_just_the_first() {
643        let v = obj(&[
644            ("id", Value::String("not a number".into())),
645            ("name", Value::String("ab".into())),
646            ("plan", Value::String("platinum".into())),
647            ("nickname", Value::Null),
648            ("surprise", Value::Bool(true)),
649        ]);
650        let found = codes(&v);
651        assert_eq!(found.len(), 4, "expected four issues, got {found:?}");
652        assert!(found.contains(&("id".into(), Code::TypeMismatch)));
653        assert!(found.contains(&("name".into(), Code::TooShort)));
654        assert!(found.contains(&("plan".into(), Code::NotInEnum)));
655        assert!(found.contains(&("surprise".into(), Code::UnknownField)));
656    }
657
658    #[test]
659    fn integer_width_is_enforced_against_the_declared_type() {
660        let mut schema = Schema::default();
661        schema.types.insert(
662            "T".into(),
663            ObjectType {
664                name: "T".into(),
665                deny_unknown_fields: false,
666                fields: vec![Field {
667                    name: "n".into(),
668                    ty: Type::Int(IntType { width: IntWidth::W32, signed: true }),
669                    presence: Presence::required(),
670                    rules: vec![],
671                }],
672            },
673        );
674        let too_big = obj(&[("n", Value::Int(Int::from(i64::from(i32::MAX) + 1)))]);
675        let err = validate(&schema, "T", &too_big, Limits::DEFAULT);
676        assert!(matches!(err, Err(ref e) if e.issues.len() == 1));
677        if let Err(e) = err {
678            assert_eq!(e.issues.first().map(|i| i.code), Some(Code::OutOfRange));
679        }
680    }
681
682    #[test]
683    fn limits_stop_an_oversized_array() {
684        let limits = Limits { max_items: 2, ..Limits::DEFAULT };
685        let v = with("tags", Value::Array(vec![Value::String("a".into()); 5]));
686        let err = validate(&user_schema(), "User", &v, limits);
687        assert!(matches!(err, Err(ref e) if e.issues.iter().any(|i| i.code == Code::SizeExceeded)));
688    }
689
690    #[test]
691    fn limits_stop_an_oversized_string() {
692        let limits = Limits { max_string_bytes: 8, ..Limits::DEFAULT };
693        let v = with("name", Value::String("a".repeat(9)));
694        let err = validate(&user_schema(), "User", &v, limits);
695        assert!(matches!(err, Err(ref e) if e.issues.iter().any(|i| i.code == Code::SizeExceeded)));
696
697        let ok = with("name", Value::String("a".repeat(8)));
698        assert!(validate(&user_schema(), "User", &ok, limits).is_ok());
699    }
700
701    /// The limit is in bytes, so a multi-byte character counts as its encoded
702    /// width. A limit measured in characters would not bound memory.
703    #[test]
704    fn the_string_limit_counts_bytes_not_characters() {
705        let limits = Limits { max_string_bytes: 4, ..Limits::DEFAULT };
706        // Three characters, six bytes in UTF-8.
707        let v = with("name", Value::String("ñññ".into()));
708        let err = validate(&user_schema(), "User", &v, limits);
709        assert!(matches!(err, Err(ref e) if e.issues.iter().any(|i| i.code == Code::SizeExceeded)));
710    }
711
712    #[test]
713    fn a_null_array_item_is_rejected_unless_the_element_allows_it() {
714        let with_items = |item_nullable| {
715            let mut schema = Schema::default();
716            schema.types.insert(
717                "T".into(),
718                ObjectType {
719                    name: "T".into(),
720                    deny_unknown_fields: false,
721                    fields: vec![Field {
722                        name: "xs".into(),
723                        ty: Type::Array { item: Box::new(Type::String), item_nullable },
724                        presence: Presence::required(),
725                        rules: vec![],
726                    }],
727                },
728            );
729            schema
730        };
731        let payload = obj(&[(
732            "xs",
733            Value::Array(vec![Value::String("a".into()), Value::Null]),
734        )]);
735
736        let err = validate(&with_items(false), "T", &payload, Limits::DEFAULT)
737            .expect_err("a null element must not pass `[String]`");
738        assert_eq!(err.issues.len(), 1);
739        assert_eq!(
740            err.issues.first().map(|i| (i.path.render(), i.code)),
741            Some(("xs[1]".to_string(), Code::NullNotAllowed))
742        );
743
744        assert!(validate(&with_items(true), "T", &payload, Limits::DEFAULT).is_ok());
745    }
746}