Skip to main content

rainier_validation/
validator.rs

1//! The [`Validator`] and its [`ValidationErrors`].
2
3use std::collections::BTreeMap;
4
5use rainier_support::Error;
6use serde_json::{Map, Value};
7
8use crate::rule::{Outcome, Rule};
9
10/// Field failures, keyed by field name, in field order.
11///
12/// A `BTreeMap` rather than a `HashMap` so the JSON body a client receives is
13/// deterministic — otherwise the same invalid request would produce a
14/// different key order each time, which makes responses awkward to snapshot,
15/// cache or diff.
16#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct ValidationErrors {
18    errors: BTreeMap<String, Vec<String>>,
19}
20
21impl ValidationErrors {
22    /// No failures.
23    pub fn new() -> Self {
24        Self::default()
25    }
26
27    /// Record a failure against `field`.
28    pub fn add(&mut self, field: impl Into<String>, message: impl Into<String>) {
29        self.errors.entry(field.into()).or_default().push(message.into());
30    }
31
32    /// Whether anything failed.
33    pub fn is_empty(&self) -> bool {
34        self.errors.is_empty()
35    }
36
37    /// How many fields failed.
38    pub fn len(&self) -> usize {
39        self.errors.len()
40    }
41
42    /// Whether `field` failed.
43    pub fn has(&self, field: &str) -> bool {
44        self.errors.contains_key(field)
45    }
46
47    /// Every message for `field`.
48    pub fn get(&self, field: &str) -> &[String] {
49        self.errors.get(field).map(Vec::as_slice).unwrap_or(&[])
50    }
51
52    /// The first message for `field`.
53    pub fn first(&self, field: &str) -> Option<&str> {
54        self.errors.get(field)?.first().map(String::as_str)
55    }
56
57    /// The first message across every field.
58    pub fn first_message(&self) -> Option<&str> {
59        self.errors.values().next()?.first().map(String::as_str)
60    }
61
62    /// Every message, flattened.
63    pub fn all_messages(&self) -> Vec<&str> {
64        self.errors.values().flatten().map(String::as_str).collect()
65    }
66
67    /// Every failing field name.
68    pub fn fields(&self) -> Vec<&str> {
69        self.errors.keys().map(String::as_str).collect()
70    }
71
72    /// As the JSON payload a client receives: `{"field": ["message", …]}`.
73    pub fn to_json(&self) -> Value {
74        let mut map = Map::new();
75        for (field, messages) in &self.errors {
76            map.insert(
77                field.clone(),
78                Value::Array(messages.iter().cloned().map(Value::String).collect()),
79            );
80        }
81        Value::Object(map)
82    }
83}
84
85impl From<ValidationErrors> for Error {
86    /// A `422` carrying the failures as structured details, which the HTTP
87    /// layer renders without knowing anything about validation.
88    fn from(errors: ValidationErrors) -> Self {
89        Error::validation(errors.to_json())
90    }
91}
92
93impl std::fmt::Display for ValidationErrors {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        match self.first_message() {
96            Some(message) => f.write_str(message),
97            None => f.write_str("The given data was valid."),
98        }
99    }
100}
101
102/// A set of rules per field.
103pub type RuleSet = Vec<(&'static str, Vec<Rule>)>;
104
105/// Checks input against a rule set.
106///
107/// ```
108/// use rainier_validation::{Rule, Validator};
109/// use serde_json::json;
110///
111/// let validator = Validator::new()
112///     .rules("email", vec![Rule::Required, Rule::Email])
113///     .rules("age", vec![Rule::Integer, Rule::Min(18.0)]);
114///
115/// let errors = validator.validate(&json!({ "email": "not-an-email" })).unwrap_err();
116/// assert_eq!(errors.first("email"), Some("The email field must be a valid email address."));
117/// assert!(!errors.has("age"), "an absent optional field does not fail");
118/// ```
119#[derive(Debug, Default)]
120pub struct Validator {
121    rules: Vec<(String, Vec<Rule>)>,
122    messages: Vec<(String, String)>,
123}
124
125impl Validator {
126    /// An empty validator.
127    pub fn new() -> Self {
128        Self::default()
129    }
130
131    /// Build from a [`RuleSet`].
132    pub fn from_rules(rules: RuleSet) -> Self {
133        Self {
134            rules: rules.into_iter().map(|(field, rules)| (field.to_string(), rules)).collect(),
135            messages: Vec::new(),
136        }
137    }
138
139    /// Add rules for `field`.
140    ///
141    /// `field` may be a dotted path (`address.city`) or contain `*` to reach
142    /// into an array (`items.*.quantity`).
143    pub fn rules(mut self, field: impl Into<String>, rules: Vec<Rule>) -> Self {
144        self.rules.push((field.into(), rules));
145        self
146    }
147
148    /// Override the message for one `field.rule` pair —
149    /// `custom_message("email.required", "We need your email.")`.
150    pub fn custom_message(mut self, key: impl Into<String>, message: impl Into<String>) -> Self {
151        self.messages.push((key.into(), message.into()));
152        self
153    }
154
155    /// Add several custom messages.
156    pub fn custom_messages(
157        mut self,
158        messages: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
159    ) -> Self {
160        self.messages
161            .extend(messages.into_iter().map(|(key, message)| (key.into(), message.into())));
162        self
163    }
164
165    /// Validate `input`, returning the failures.
166    pub fn validate(&self, input: &Value) -> Result<(), ValidationErrors> {
167        let mut errors = ValidationErrors::new();
168
169        for (pattern, rules) in &self.rules {
170            for field in expand(pattern, input) {
171                let value = crate::lookup(input, &field);
172
173                for rule in rules {
174                    match rule.check(&field, value, input) {
175                        Outcome::Pass => {}
176                        Outcome::Fail(message) => {
177                            errors.add(&field, self.message_for(pattern, rule, message));
178                            // A failed presence rule makes every later rule
179                            // for this field meaningless — "is required" plus
180                            // "must be a valid email" is noise, not detail.
181                            if rule.applies_to_missing() {
182                                break;
183                            }
184                        }
185                        // Absent or null, and nothing demanded otherwise.
186                        Outcome::Skip => break,
187                    }
188                }
189            }
190        }
191
192        if errors.is_empty() {
193            Ok(())
194        } else {
195            Err(errors)
196        }
197    }
198
199    /// Validate and hand back the input, for chaining.
200    pub fn validated(&self, input: &Value) -> Result<Value, ValidationErrors> {
201        self.validate(input)?;
202        Ok(input.clone())
203    }
204
205    /// Validate and keep only the fields that had rules — Laravel's
206    /// `$request->validated()`.
207    ///
208    /// This is a security feature, not a convenience: it is what stops a
209    /// client from smuggling an `is_admin` field into a mass assignment. Only
210    /// keys you wrote a rule for survive.
211    pub fn validated_only(&self, input: &Value) -> Result<Value, ValidationErrors> {
212        self.validate(input)?;
213
214        let mut kept = Map::new();
215        for (pattern, _) in &self.rules {
216            // The top-level key of the pattern: `address.city` keeps
217            // `address` wholesale, since a nested rule implies the parent.
218            let root = pattern.split(['.', '*']).next().unwrap_or(pattern);
219            if root.is_empty() || kept.contains_key(root) {
220                continue;
221            }
222            if let Some(value) = input.get(root) {
223                kept.insert(root.to_string(), value.clone());
224            }
225        }
226        Ok(Value::Object(kept))
227    }
228
229    fn message_for(&self, pattern: &str, rule: &Rule, default: String) -> String {
230        let key = format!("{pattern}.{}", rule.name());
231        self.messages
232            .iter()
233            .find(|(candidate, _)| *candidate == key)
234            .map(|(_, message)| message.clone())
235            .unwrap_or(default)
236    }
237}
238
239/// Expand a `*` wildcard in `pattern` against `input`.
240///
241/// `items.*.name` with three items becomes `items.0.name`, `items.1.name`,
242/// `items.2.name`. A pattern with no `*` expands to itself.
243fn expand(pattern: &str, input: &Value) -> Vec<String> {
244    if !pattern.contains('*') {
245        return vec![pattern.to_string()];
246    }
247
248    let mut expanded = vec![String::new()];
249    for segment in pattern.split('.') {
250        let mut next = Vec::new();
251        for prefix in &expanded {
252            if segment != "*" {
253                next.push(join(prefix, segment));
254                continue;
255            }
256            // Fan out over however many elements are actually there. An
257            // absent or non-array parent contributes nothing, so rules under
258            // it simply do not run — use a rule on the parent to require it.
259            let Some(Value::Array(items)) = crate::lookup(input, prefix) else {
260                continue;
261            };
262            for index in 0..items.len() {
263                next.push(join(prefix, &index.to_string()));
264            }
265        }
266        expanded = next;
267    }
268    expanded
269}
270
271fn join(prefix: &str, segment: &str) -> String {
272    if prefix.is_empty() {
273        segment.to_string()
274    } else {
275        format!("{prefix}.{segment}")
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use serde_json::json;
283
284    fn validator() -> Validator {
285        Validator::new()
286            .rules("name", vec![Rule::Required, Rule::String, Rule::Max(10.0)])
287            .rules("email", vec![Rule::Required, Rule::Email])
288            .rules("age", vec![Rule::Integer, Rule::Min(18.0)])
289    }
290
291    #[test]
292    fn valid_input_passes() {
293        let input = json!({ "name": "Ada", "email": "ada@example.com", "age": "36" });
294        assert!(validator().validate(&input).is_ok());
295    }
296
297    #[test]
298    fn optional_fields_may_be_absent() {
299        let input = json!({ "name": "Ada", "email": "ada@example.com" });
300        assert!(validator().validate(&input).is_ok());
301    }
302
303    #[test]
304    fn collects_a_failure_per_field() {
305        let input = json!({ "email": "nope", "age": "12" });
306        let errors = validator().validate(&input).unwrap_err();
307
308        assert_eq!(errors.len(), 3);
309        assert!(errors.has("name"));
310        assert!(errors.has("email"));
311        assert!(errors.has("age"));
312    }
313
314    #[test]
315    fn a_failed_required_rule_silences_the_rest_for_that_field() {
316        let input = json!({ "email": "" });
317        let errors = validator().validate(&input).unwrap_err();
318
319        // Just "is required" — not also "must be a valid email address".
320        assert_eq!(errors.get("email").len(), 1);
321        assert!(errors.first("email").unwrap().contains("required"));
322    }
323
324    #[test]
325    fn non_presence_failures_accumulate() {
326        let validator = Validator::new().rules("code", vec![Rule::Alpha, Rule::Min(5.0)]);
327        let errors = validator.validate(&json!({ "code": "a1" })).unwrap_err();
328        assert_eq!(errors.get("code").len(), 2);
329    }
330
331    #[test]
332    fn custom_messages_replace_the_default() {
333        let validator = validator().custom_message("email.required", "We need your email.");
334        let errors = validator.validate(&json!({ "name": "Ada" })).unwrap_err();
335        assert_eq!(errors.first("email"), Some("We need your email."));
336    }
337
338    #[test]
339    fn a_custom_message_only_replaces_its_own_rule() {
340        let validator = validator().custom_message("email.required", "We need your email.");
341        let errors = validator.validate(&json!({ "name": "Ada", "email": "x" })).unwrap_err();
342        assert!(errors.first("email").unwrap().contains("valid email address"));
343    }
344
345    #[test]
346    fn dotted_paths_reach_into_objects() {
347        let validator = Validator::new().rules("address.city", vec![Rule::Required]);
348        assert!(validator.validate(&json!({ "address": { "city": "Paris" } })).is_ok());
349
350        let errors = validator.validate(&json!({ "address": {} })).unwrap_err();
351        assert!(errors.has("address.city"));
352    }
353
354    #[test]
355    fn wildcards_fan_out_over_arrays() {
356        let validator = Validator::new()
357            .rules("items.*.quantity", vec![Rule::Required, Rule::Integer, Rule::Min(1.0)]);
358
359        let input = json!({ "items": [{ "quantity": "2" }, { "quantity": "0" }, {}] });
360        let errors = validator.validate(&input).unwrap_err();
361
362        assert!(!errors.has("items.0.quantity"));
363        assert!(errors.has("items.1.quantity"), "0 is below the minimum");
364        assert!(errors.has("items.2.quantity"), "missing entirely");
365    }
366
367    #[test]
368    fn a_wildcard_over_a_missing_array_produces_no_failures() {
369        let validator = Validator::new().rules("items.*.quantity", vec![Rule::Required]);
370        assert!(validator.validate(&json!({})).is_ok());
371    }
372
373    #[test]
374    fn nested_wildcards_expand() {
375        let validator = Validator::new().rules("orders.*.items.*.sku", vec![Rule::Required]);
376        let input = json!({
377            "orders": [
378                { "items": [{ "sku": "a" }, {}] },
379                { "items": [{ "sku": "c" }] }
380            ]
381        });
382        let errors = validator.validate(&input).unwrap_err();
383        assert_eq!(errors.fields(), vec!["orders.0.items.1.sku"]);
384    }
385
386    #[test]
387    fn validated_only_drops_unvalidated_keys() {
388        let input = json!({
389            "name": "Ada",
390            "email": "ada@example.com",
391            "is_admin": true
392        });
393        let kept = validator().validated_only(&input).unwrap();
394
395        assert_eq!(kept.get("name"), Some(&json!("Ada")));
396        assert!(kept.get("is_admin").is_none(), "mass assignment must not slip through");
397    }
398
399    #[test]
400    fn validated_only_keeps_the_parent_of_a_nested_rule() {
401        let validator = Validator::new()
402            .rules("address.city", vec![Rule::Required])
403            .rules("items.*.sku", vec![Rule::Required]);
404
405        let input = json!({
406            "address": { "city": "Paris", "postcode": "75001" },
407            "items": [{ "sku": "a" }],
408            "sneaky": 1
409        });
410        let kept = validator.validated_only(&input).unwrap();
411
412        assert!(kept.get("address").is_some());
413        assert!(kept.get("items").is_some());
414        assert!(kept.get("sneaky").is_none());
415    }
416
417    #[test]
418    fn errors_render_as_a_stable_json_payload() {
419        let errors = validator().validate(&json!({})).unwrap_err();
420        let payload = errors.to_json();
421
422        // BTreeMap ordering: email before name.
423        let keys: Vec<&String> = payload.as_object().unwrap().keys().collect();
424        assert_eq!(keys, vec!["email", "name"]);
425        assert!(payload["name"][0].as_str().unwrap().contains("required"));
426    }
427
428    #[test]
429    fn errors_convert_into_a_422() {
430        let errors = validator().validate(&json!({})).unwrap_err();
431        let error: Error = errors.into();
432        assert_eq!(error.status(), 422);
433        assert!(error.details().is_some());
434    }
435
436    #[test]
437    fn error_accessors() {
438        let errors = validator().validate(&json!({})).unwrap_err();
439        assert!(!errors.is_empty());
440        assert!(errors.first_message().is_some());
441        assert_eq!(errors.all_messages().len(), 2);
442        assert!(errors.get("nope").is_empty());
443        assert!(errors.first("nope").is_none());
444    }
445
446    #[test]
447    fn from_rules_builds_from_a_rule_set() {
448        let rules: RuleSet = vec![("title", vec![Rule::Required])];
449        let validator = Validator::from_rules(rules);
450        assert!(validator.validate(&json!({})).is_err());
451        assert!(validator.validate(&json!({ "title": "x" })).is_ok());
452    }
453}