Skip to main content

ullun/
validate.rs

1//! Collect all problems of a request instead of failing on the first one
2//!
3//! ```
4//! use ullun::validate::Validator;
5//!
6//! fn check(title: &str, priority: u8) -> ullun::error::Result<()> {
7//!     let mut v = Validator::new();
8//!     v.len_between("title", title, 1, 120);
9//!     v.check(priority <= 3, "priority", "must be 0-3");
10//!     v.finish()
11//! }
12//!
13//! assert!(check("Write docs", 2).is_ok());
14//! let err = check("", 9).unwrap_err();
15//! assert_eq!(err.status_code(), 422);
16//! assert_eq!(err.fields().len(), 2);
17//! ```
18//!
19//! The client receives every problem at once:
20//! `{"error":"Validation failed","status":422,"fields":{"title":["..."],"priority":["..."]}}`.
21
22use crate::error::{Error, FieldError, Result};
23
24/// Accumulates [`FieldError`]s; call [`finish`](Validator::finish) at the end
25#[derive(Debug, Default)]
26pub struct Validator {
27    errors: Vec<FieldError>,
28}
29
30impl Validator {
31    pub fn new() -> Self {
32        Self::default()
33    }
34
35    /// Record an error for `field`
36    pub fn error(&mut self, field: &str, message: impl Into<String>) -> &mut Self {
37        self.errors.push(FieldError {
38            field: field.to_string(),
39            message: message.into(),
40        });
41        self
42    }
43
44    /// Record `message` for `field` unless `condition` holds
45    pub fn check(&mut self, condition: bool, field: &str, message: impl Into<String>) -> &mut Self {
46        if !condition {
47            self.error(field, message);
48        }
49        self
50    }
51
52    /// Text that is not empty after trimming
53    pub fn non_empty(&mut self, field: &str, value: &str) -> &mut Self {
54        self.check(!value.trim().is_empty(), field, "must not be empty")
55    }
56
57    /// Length in characters (not bytes) within `min..=max`
58    pub fn len_between(&mut self, field: &str, value: &str, min: usize, max: usize) -> &mut Self {
59        let len = value.chars().count();
60        if len < min || len > max {
61            self.error(field, format!("must be {min} to {max} characters long"));
62        }
63        self
64    }
65
66    /// Value within `min..=max`
67    pub fn range<T: PartialOrd + std::fmt::Display>(
68        &mut self,
69        field: &str,
70        value: T,
71        min: T,
72        max: T,
73    ) -> &mut Self {
74        if value < min || value > max {
75            self.error(field, format!("must be between {min} and {max}"));
76        }
77        self
78    }
79
80    /// Simple e-mail shape check: one `@`, text on both sides, a dot in the
81    /// domain, no whitespace. Deliverability is only proven by sending mail.
82    pub fn email(&mut self, field: &str, value: &str) -> &mut Self {
83        let ok = value.split_once('@').is_some_and(|(local, domain)| {
84            !local.is_empty()
85                && !domain.contains('@')
86                && domain.split('.').count() >= 2
87                && domain.split('.').all(|p| !p.is_empty())
88        }) && !value.chars().any(|c| c.is_whitespace() || c.is_control())
89            && value.len() <= 254;
90        self.check(ok, field, "must be a valid e-mail address")
91    }
92
93    pub fn is_valid(&self) -> bool {
94        self.errors.is_empty()
95    }
96
97    /// `Ok(())` without errors, otherwise [`Error::Validation`] (HTTP 422)
98    pub fn finish(self) -> Result<()> {
99        if self.errors.is_empty() {
100            Ok(())
101        } else {
102            Err(Error::Validation(self.errors))
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn collects_every_problem() {
113        let mut v = Validator::new();
114        v.non_empty("a", "  ")
115            .len_between("b", "xy", 3, 5)
116            .range("c", 9, 0, 3);
117        v.error("d", "custom").check(true, "e", "never");
118        assert!(!v.is_valid());
119        let err = v.finish().unwrap_err();
120        let fields: Vec<_> = err.fields().iter().map(|f| f.field.as_str()).collect();
121        assert_eq!(fields, ["a", "b", "c", "d"]);
122        assert_eq!(err.status_code(), 422);
123        assert_eq!(err.message(), "Validation failed");
124        assert_eq!(err.fields()[1].message, "must be 3 to 5 characters long");
125        assert_eq!(err.fields()[2].message, "must be between 0 and 3");
126    }
127
128    #[test]
129    fn ok_when_nothing_failed() {
130        let mut v = Validator::new();
131        v.non_empty("a", "x")
132            .len_between("b", "äöü", 3, 3)
133            .range("c", 1.5, 0.0, 2.0);
134        assert!(v.is_valid());
135        assert!(v.finish().is_ok());
136    }
137
138    #[test]
139    fn email_shapes() {
140        let ok = |s: &str| {
141            let mut v = Validator::new();
142            v.email("email", s);
143            v.is_valid()
144        };
145        for good in ["a@b.co", "first.last+tag@sub.example.org"] {
146            assert!(ok(good), "{good}");
147        }
148        for bad in [
149            "",
150            "a",
151            "a@",
152            "@b.co",
153            "a@b",
154            "a@@b.co",
155            "a b@c.de",
156            "a@b..co",
157            "a@.co",
158            "a@b.co\n",
159            &format!("{}@b.co", "x".repeat(260)),
160        ] {
161            assert!(!ok(bad), "{bad:?}");
162        }
163    }
164}