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.
206    ///
207    /// This is a security feature, not a convenience: it is what stops a
208    /// client from smuggling an `is_admin` field into a mass assignment. Only
209    /// keys you wrote a rule for survive.
210    pub fn validated_only(&self, input: &Value) -> Result<Value, ValidationErrors> {
211        self.validate(input)?;
212
213        let mut kept = Map::new();
214        for (pattern, _) in &self.rules {
215            // The top-level key of the pattern: `address.city` keeps
216            // `address` wholesale, since a nested rule implies the parent.
217            let root = pattern.split(['.', '*']).next().unwrap_or(pattern);
218            if root.is_empty() || kept.contains_key(root) {
219                continue;
220            }
221            if let Some(value) = input.get(root) {
222                kept.insert(root.to_string(), value.clone());
223            }
224        }
225        Ok(Value::Object(kept))
226    }
227
228    fn message_for(&self, pattern: &str, rule: &Rule, default: String) -> String {
229        let key = format!("{pattern}.{}", rule.name());
230        self.messages
231            .iter()
232            .find(|(candidate, _)| *candidate == key)
233            .map(|(_, message)| message.clone())
234            .unwrap_or(default)
235    }
236}
237
238/// Expand a `*` wildcard in `pattern` against `input`.
239///
240/// `items.*.name` with three items becomes `items.0.name`, `items.1.name`,
241/// `items.2.name`. A pattern with no `*` expands to itself.
242fn expand(pattern: &str, input: &Value) -> Vec<String> {
243    if !pattern.contains('*') {
244        return vec![pattern.to_string()];
245    }
246
247    let mut expanded = vec![String::new()];
248    for segment in pattern.split('.') {
249        let mut next = Vec::new();
250        for prefix in &expanded {
251            if segment != "*" {
252                next.push(join(prefix, segment));
253                continue;
254            }
255            // Fan out over however many elements are actually there. An
256            // absent or non-array parent contributes nothing, so rules under
257            // it simply do not run — use a rule on the parent to require it.
258            let Some(Value::Array(items)) = crate::lookup(input, prefix) else {
259                continue;
260            };
261            for index in 0..items.len() {
262                next.push(join(prefix, &index.to_string()));
263            }
264        }
265        expanded = next;
266    }
267    expanded
268}
269
270fn join(prefix: &str, segment: &str) -> String {
271    if prefix.is_empty() {
272        segment.to_string()
273    } else {
274        format!("{prefix}.{segment}")
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use serde_json::json;
282
283    fn validator() -> Validator {
284        Validator::new()
285            .rules("name", vec![Rule::Required, Rule::String, Rule::Max(10.0)])
286            .rules("email", vec![Rule::Required, Rule::Email])
287            .rules("age", vec![Rule::Integer, Rule::Min(18.0)])
288    }
289
290    #[test]
291    fn valid_input_passes() {
292        let input = json!({ "name": "Ada", "email": "ada@example.com", "age": "36" });
293        assert!(validator().validate(&input).is_ok());
294    }
295
296    #[test]
297    fn optional_fields_may_be_absent() {
298        let input = json!({ "name": "Ada", "email": "ada@example.com" });
299        assert!(validator().validate(&input).is_ok());
300    }
301
302    #[test]
303    fn collects_a_failure_per_field() {
304        let input = json!({ "email": "nope", "age": "12" });
305        let errors = validator().validate(&input).unwrap_err();
306
307        assert_eq!(errors.len(), 3);
308        assert!(errors.has("name"));
309        assert!(errors.has("email"));
310        assert!(errors.has("age"));
311    }
312
313    #[test]
314    fn a_failed_required_rule_silences_the_rest_for_that_field() {
315        let input = json!({ "email": "" });
316        let errors = validator().validate(&input).unwrap_err();
317
318        // Just "is required" — not also "must be a valid email address".
319        assert_eq!(errors.get("email").len(), 1);
320        assert!(errors.first("email").unwrap().contains("required"));
321    }
322
323    #[test]
324    fn non_presence_failures_accumulate() {
325        let validator = Validator::new().rules("code", vec![Rule::Alpha, Rule::Min(5.0)]);
326        let errors = validator.validate(&json!({ "code": "a1" })).unwrap_err();
327        assert_eq!(errors.get("code").len(), 2);
328    }
329
330    #[test]
331    fn custom_messages_replace_the_default() {
332        let validator = validator().custom_message("email.required", "We need your email.");
333        let errors = validator.validate(&json!({ "name": "Ada" })).unwrap_err();
334        assert_eq!(errors.first("email"), Some("We need your email."));
335    }
336
337    #[test]
338    fn a_custom_message_only_replaces_its_own_rule() {
339        let validator = validator().custom_message("email.required", "We need your email.");
340        let errors = validator.validate(&json!({ "name": "Ada", "email": "x" })).unwrap_err();
341        assert!(errors.first("email").unwrap().contains("valid email address"));
342    }
343
344    #[test]
345    fn dotted_paths_reach_into_objects() {
346        let validator = Validator::new().rules("address.city", vec![Rule::Required]);
347        assert!(validator.validate(&json!({ "address": { "city": "Paris" } })).is_ok());
348
349        let errors = validator.validate(&json!({ "address": {} })).unwrap_err();
350        assert!(errors.has("address.city"));
351    }
352
353    #[test]
354    fn wildcards_fan_out_over_arrays() {
355        let validator = Validator::new()
356            .rules("items.*.quantity", vec![Rule::Required, Rule::Integer, Rule::Min(1.0)]);
357
358        let input = json!({ "items": [{ "quantity": "2" }, { "quantity": "0" }, {}] });
359        let errors = validator.validate(&input).unwrap_err();
360
361        assert!(!errors.has("items.0.quantity"));
362        assert!(errors.has("items.1.quantity"), "0 is below the minimum");
363        assert!(errors.has("items.2.quantity"), "missing entirely");
364    }
365
366    #[test]
367    fn a_wildcard_over_a_missing_array_produces_no_failures() {
368        let validator = Validator::new().rules("items.*.quantity", vec![Rule::Required]);
369        assert!(validator.validate(&json!({})).is_ok());
370    }
371
372    #[test]
373    fn nested_wildcards_expand() {
374        let validator = Validator::new().rules("orders.*.items.*.sku", vec![Rule::Required]);
375        let input = json!({
376            "orders": [
377                { "items": [{ "sku": "a" }, {}] },
378                { "items": [{ "sku": "c" }] }
379            ]
380        });
381        let errors = validator.validate(&input).unwrap_err();
382        assert_eq!(errors.fields(), vec!["orders.0.items.1.sku"]);
383    }
384
385    #[test]
386    fn validated_only_drops_unvalidated_keys() {
387        let input = json!({
388            "name": "Ada",
389            "email": "ada@example.com",
390            "is_admin": true
391        });
392        let kept = validator().validated_only(&input).unwrap();
393
394        assert_eq!(kept.get("name"), Some(&json!("Ada")));
395        assert!(kept.get("is_admin").is_none(), "mass assignment must not slip through");
396    }
397
398    #[test]
399    fn validated_only_keeps_the_parent_of_a_nested_rule() {
400        let validator = Validator::new()
401            .rules("address.city", vec![Rule::Required])
402            .rules("items.*.sku", vec![Rule::Required]);
403
404        let input = json!({
405            "address": { "city": "Paris", "postcode": "75001" },
406            "items": [{ "sku": "a" }],
407            "sneaky": 1
408        });
409        let kept = validator.validated_only(&input).unwrap();
410
411        assert!(kept.get("address").is_some());
412        assert!(kept.get("items").is_some());
413        assert!(kept.get("sneaky").is_none());
414    }
415
416    #[test]
417    fn errors_render_as_a_stable_json_payload() {
418        let errors = validator().validate(&json!({})).unwrap_err();
419        let payload = errors.to_json();
420
421        // BTreeMap ordering: email before name.
422        let keys: Vec<&String> = payload.as_object().unwrap().keys().collect();
423        assert_eq!(keys, vec!["email", "name"]);
424        assert!(payload["name"][0].as_str().unwrap().contains("required"));
425    }
426
427    #[test]
428    fn errors_convert_into_a_422() {
429        let errors = validator().validate(&json!({})).unwrap_err();
430        let error: Error = errors.into();
431        assert_eq!(error.status(), 422);
432        assert!(error.details().is_some());
433    }
434
435    #[test]
436    fn error_accessors() {
437        let errors = validator().validate(&json!({})).unwrap_err();
438        assert!(!errors.is_empty());
439        assert!(errors.first_message().is_some());
440        assert_eq!(errors.all_messages().len(), 2);
441        assert!(errors.get("nope").is_empty());
442        assert!(errors.first("nope").is_none());
443    }
444
445    #[test]
446    fn from_rules_builds_from_a_rule_set() {
447        let rules: RuleSet = vec![("title", vec![Rule::Required])];
448        let validator = Validator::from_rules(rules);
449        assert!(validator.validate(&json!({})).is_err());
450        assert!(validator.validate(&json!({ "title": "x" })).is_ok());
451    }
452}