Skip to main content

rustlavel_validation/
validator.rs

1//! Running rules against input, and what comes back when they all pass.
2//!
3//! The semantics follow Laravel closely, including the two that surprise people
4//! if they are not written down:
5//!
6//! - A field that was never sent only trips `required`. Every other rule is
7//!   skipped, so `nullable|integer` on an absent field is silence, not an error.
8//! - A blank string — an untouched text input, which a browser submits as `""`
9//!   rather than omitting — is treated as absent for the same reason.
10//!
11//! On success the validator hands back a [`Validated`] containing *only* the
12//! fields that had rules. That is the point of validating: what comes out is
13//! the subset that was actually checked, so an unexpected extra field in the
14//! body cannot ride along into a database write.
15
16use crate::check;
17use crate::errors::Errors;
18use crate::input::Input;
19use crate::messages::{self, Messages, SizeKind};
20use crate::rule::{IntoRules, Rule, Rules};
21use rustlavel_core::Json;
22use rustlavel_http::Request;
23use std::collections::BTreeMap;
24
25/// A set of fields, their rules, and the values to run them against.
26#[derive(Debug, Clone, Default)]
27pub struct Validator {
28    input: Input,
29    fields: Vec<(String, Rules)>,
30    messages: Messages,
31    wants_json: bool,
32    /// The session to leave the messages in, and where to send the browser.
33    /// Both are captured from the request, since the response is built later.
34    flash: Option<(std::sync::Arc<dyn rustlavel_http::Flash>, String)>,
35    old_input: Option<rustlavel_core::Json>,
36}
37
38impl Validator {
39    /// Validate a bag of values — a JSON body, a form, or an [`Input`] built by hand.
40    pub fn new(input: impl Into<Input>) -> Self {
41        Validator { input: input.into(), ..Validator::default() }
42    }
43
44    /// Validate a request, remembering whether the client wants JSON back so
45    /// the failure response can be negotiated later, when the request is gone.
46    pub fn from_request(request: &mut Request) -> Self {
47        let wants_json = request.wants_json();
48
49        // Read before validating, because a failure has to hand back what was
50        // typed — and because `form()` borrows the request mutably, which the
51        // response cannot do later.
52        let old_input = (!wants_json).then(|| rustlavel_http::flash::old_input_of(request));
53        let flash = (!wants_json)
54            .then(|| {
55                request.flash().cloned().map(|store| (store, request.previous_url()))
56            })
57            .flatten();
58
59        Validator {
60            input: Input::from_request(request),
61            wants_json,
62            flash,
63            old_input,
64            ..Validator::default()
65        }
66    }
67
68    /// Add one field's rules, as a Laravel string or as a built rule set.
69    pub fn rule(mut self, field: impl Into<String>, rules: impl IntoRules) -> Self {
70        self.fields.push((field.into(), rules.into_rules()));
71        self
72    }
73
74    /// Add every field at once: `.rules(&[("email", "required|email")])`.
75    pub fn rules(mut self, specs: &[(&str, &str)]) -> Self {
76        for (field, spec) in specs {
77            self = self.rule(*field, *spec);
78        }
79        self
80    }
81
82    /// Override one message. The key is `"field.rule"` or just `"rule"`.
83    pub fn message(mut self, key: impl Into<String>, message: impl Into<String>) -> Self {
84        self.messages.set(key, message);
85        self
86    }
87
88    /// Rename a field for display: `.attribute("dob", "date of birth")`.
89    pub fn attribute(mut self, field: impl Into<String>, label: impl Into<String>) -> Self {
90        self.messages.set_attribute(field, label);
91        self
92    }
93
94    /// Replace the whole message bag, for an application that keeps its own.
95    pub fn with_messages(mut self, messages: Messages) -> Self {
96        self.messages = messages;
97        self
98    }
99
100    /// Force the failure response to be JSON (or not), overriding what the
101    /// request negotiated.
102    pub fn with_json(mut self, wants_json: bool) -> Self {
103        self.wants_json = wants_json;
104        self
105    }
106
107    pub fn input(&self) -> &Input {
108        &self.input
109    }
110
111    /// Every message the rules produce. Empty when the input is valid.
112    pub fn errors(&self) -> Errors {
113        let mut errors = Errors::new().with_json(self.wants_json);
114        for (field, rules) in &self.fields {
115            self.check(field, rules, &mut errors);
116        }
117        errors
118    }
119
120    /// Put the messages and the old input where the next request will find
121    /// them, and tell the errors where to send the browser.
122    ///
123    /// Done here rather than in `into_response` because this is the last place
124    /// that still has the session — by the time a `Response` is built, the
125    /// request is gone.
126    fn leave_behind(&self, errors: Errors) -> Errors {
127        let Some((flash, back)) = &self.flash else { return errors };
128
129        flash.flash(rustlavel_http::flash::ERRORS_KEY, errors.to_field_json());
130        if let Some(old) = &self.old_input {
131            flash.flash(rustlavel_http::flash::OLD_INPUT_KEY, old.clone());
132        }
133        errors.redirecting_to(Some(back.clone()))
134    }
135
136    pub fn passes(&self) -> bool {
137        self.errors().is_empty()
138    }
139
140    pub fn fails(&self) -> bool {
141        !self.passes()
142    }
143
144    /// The validated subset of the input, or every message that failed.
145    pub fn validate(&self) -> Result<Validated, Errors> {
146        let errors = self.errors();
147        if !errors.is_empty() {
148            return Err(self.leave_behind(errors));
149        }
150        let mut fields = BTreeMap::new();
151        for (field, _) in &self.fields {
152            if let Some(value) = self.input.get(field) {
153                fields.insert(field.clone(), value.clone());
154            }
155        }
156        Ok(Validated { fields })
157    }
158
159    fn check(&self, field: &str, rules: &Rules, errors: &mut Errors) {
160        let required = rules.has("required");
161
162        let Some(value) = self.input.get(field) else {
163            if required {
164                self.fail(field, &Rule::Required, SizeKind::String, errors);
165            }
166            return;
167        };
168
169        if required && is_blank(value) {
170            self.fail(field, &Rule::Required, SizeKind::String, errors);
171            return;
172        }
173
174        // A blank string is what a browser sends for an untouched input. Laravel
175        // treats it as absent for every rule but the presence rules, so
176        // `nullable|email` on an empty box is silence rather than a complaint.
177        if matches!(value, Json::String(text) if text.trim().is_empty()) {
178            return;
179        }
180
181        // An explicit `null` stops here only when the field opted into it;
182        // otherwise it falls through so `integer` can say what is wrong.
183        if value.is_null() && rules.has("nullable") {
184            return;
185        }
186
187        for rule in rules.rules() {
188            if matches!(rule, Rule::Required | Rule::Nullable) {
189                continue;
190            }
191            if !self.satisfied(field, rule, value, rules) {
192                self.fail(field, rule, size_kind(value, rules), errors);
193            }
194        }
195    }
196
197    fn satisfied(&self, field: &str, rule: &Rule, value: &Json, rules: &Rules) -> bool {
198        match rule {
199            Rule::Required | Rule::Nullable => true,
200            Rule::String => matches!(value, Json::String(_)),
201            Rule::Integer => as_number(value).is_some_and(|n| n.fract() == 0.0),
202            Rule::Numeric => as_number(value).is_some(),
203            Rule::Boolean => is_boolean(value),
204            Rule::Email => text(value).is_some_and(|t| check::is_email(&t)),
205            Rule::Url => text(value).is_some_and(|t| check::is_url(&t)),
206            Rule::Alpha => text(value).is_some_and(|t| check::is_alpha(&t)),
207            Rule::AlphaNum => text(value).is_some_and(|t| check::is_alpha_num(&t)),
208            Rule::AlphaDash => text(value).is_some_and(|t| check::is_alpha_dash(&t)),
209            Rule::Date => text(value).is_some_and(|t| check::is_date(&t)),
210            Rule::Uuid => text(value).is_some_and(|t| check::is_uuid(&t)),
211            Rule::Array => matches!(value, Json::Array(_)),
212            Rule::Min(bound) => measure(value, rules).is_some_and(|(size, _)| size >= *bound),
213            Rule::Max(bound) => measure(value, rules).is_some_and(|(size, _)| size <= *bound),
214            Rule::Between(low, high) => {
215                measure(value, rules).is_some_and(|(size, _)| size >= *low && size <= *high)
216            }
217            Rule::Size(exact) => measure(value, rules).is_some_and(|(size, _)| size == *exact),
218            Rule::In(allowed) => text(value).is_some_and(|t| allowed.contains(&t)),
219            Rule::NotIn(denied) => text(value).is_some_and(|t| !denied.contains(&t)),
220            Rule::StartsWith(prefixes) => {
221                text(value).is_some_and(|t| prefixes.iter().any(|p| t.starts_with(p)))
222            }
223            Rule::EndsWith(suffixes) => {
224                text(value).is_some_and(|t| suffixes.iter().any(|s| t.ends_with(s)))
225            }
226            Rule::Confirmed => self.compares(value, &format!("{field}_confirmation"), true),
227            Rule::Same(other) => self.compares(value, other, true),
228            Rule::Different(other) => self.compares(value, other, false),
229        }
230    }
231
232    /// Compare against another field. The other field must exist either way:
233    /// "must be different from a field you did not send" is not something a
234    /// user can act on, so it fails rather than silently passing.
235    fn compares(&self, value: &Json, other: &str, want_equal: bool) -> bool {
236        self.input.get(other).is_some_and(|found| equivalent(value, found) == want_equal)
237    }
238
239    fn fail(&self, field: &str, rule: &Rule, kind: SizeKind, errors: &mut Errors) {
240        let template = self.messages.template(field, rule, kind);
241        let mut values = vec![("attribute", self.messages.label(field))];
242        match rule {
243            Rule::Min(bound) => values.push(("min", messages::format_number(*bound))),
244            Rule::Max(bound) => values.push(("max", messages::format_number(*bound))),
245            Rule::Between(low, high) => {
246                values.push(("min", messages::format_number(*low)));
247                values.push(("max", messages::format_number(*high)));
248            }
249            Rule::Size(exact) => values.push(("size", messages::format_number(*exact))),
250            Rule::In(list) | Rule::NotIn(list) | Rule::StartsWith(list) | Rule::EndsWith(list) => {
251                values.push(("values", messages::format_values(list)));
252            }
253            Rule::Same(other) | Rule::Different(other) => {
254                values.push(("other", self.messages.label(other)));
255            }
256            _ => {}
257        }
258        errors.add(field, messages::interpolate(&template, &values));
259    }
260}
261
262/// The fields that had rules and passed them.
263#[derive(Debug, Clone, Default, PartialEq)]
264pub struct Validated {
265    fields: BTreeMap<String, Json>,
266}
267
268impl Validated {
269    pub fn get(&self, name: &str) -> Option<&Json> {
270        self.fields.get(name)
271    }
272
273    pub fn has(&self, name: &str) -> bool {
274        self.fields.contains_key(name)
275    }
276
277    /// A scalar as text. A number comes back as its digits, matching what
278    /// `Request::input` hands back for the same field sent through a form.
279    pub fn string(&self, name: &str) -> Option<String> {
280        text(self.get(name)?)
281    }
282
283    pub fn integer(&self, name: &str) -> Option<i64> {
284        let number = as_number(self.get(name)?)?;
285        (number.fract() == 0.0).then_some(number as i64)
286    }
287
288    pub fn number(&self, name: &str) -> Option<f64> {
289        as_number(self.get(name)?)
290    }
291
292    /// A boolean, accepting the `"1"`/`"0"` and `"true"`/`"false"` a form sends.
293    pub fn boolean(&self, name: &str) -> Option<bool> {
294        match self.get(name)? {
295            Json::Bool(value) => Some(*value),
296            Json::Number(number) => Some(*number != 0.0),
297            Json::String(text) => match text.as_str() {
298                "1" | "true" => Some(true),
299                "0" | "false" => Some(false),
300                _ => None,
301            },
302            _ => None,
303        }
304    }
305
306    pub fn array(&self, name: &str) -> Option<&[Json]> {
307        self.get(name)?.as_array()
308    }
309
310    pub fn fields(&self) -> &BTreeMap<String, Json> {
311        &self.fields
312    }
313
314    pub fn len(&self) -> usize {
315        self.fields.len()
316    }
317
318    pub fn is_empty(&self) -> bool {
319        self.fields.is_empty()
320    }
321
322    /// The validated data as a JSON object, ready to echo back or store.
323    pub fn into_json(self) -> Json {
324        Json::Object(self.fields)
325    }
326}
327
328impl From<Validated> for Json {
329    fn from(validated: Validated) -> Self {
330        validated.into_json()
331    }
332}
333
334/// What `required` considers missing: null, a blank string, an empty array.
335fn is_blank(value: &Json) -> bool {
336    match value {
337        Json::Null => true,
338        Json::String(text) => text.trim().is_empty(),
339        Json::Array(items) => items.is_empty(),
340        _ => false,
341    }
342}
343
344/// A scalar rendered as text, so rules written for strings still work on the
345/// numbers and booleans a JSON body carries.
346fn text(value: &Json) -> Option<String> {
347    match value {
348        Json::String(found) => Some(found.clone()),
349        Json::Number(_) | Json::Bool(_) => Some(value.to_string()),
350        Json::Null | Json::Array(_) | Json::Object(_) => None,
351    }
352}
353
354/// A number, whether it came from a JSON body or as the text of a form field.
355///
356/// Non-finite values are refused: Rust parses `"inf"` and `"NaN"` happily, and
357/// neither is a number a user meant to type.
358fn as_number(value: &Json) -> Option<f64> {
359    match value {
360        Json::Number(number) => Some(*number).filter(|n| n.is_finite()),
361        Json::String(found) => found.trim().parse::<f64>().ok().filter(|n| n.is_finite()),
362        _ => None,
363    }
364}
365
366fn is_boolean(value: &Json) -> bool {
367    match value {
368        Json::Bool(_) => true,
369        Json::Number(number) => *number == 0.0 || *number == 1.0,
370        Json::String(found) => matches!(found.as_str(), "0" | "1" | "true" | "false"),
371        _ => false,
372    }
373}
374
375/// Equality for `confirmed`, `same` and `different`.
376///
377/// Compared as text first, so `18` from a JSON body and `"18"` from the form
378/// that re-submitted it are the same value.
379fn equivalent(left: &Json, right: &Json) -> bool {
380    match (text(left), text(right)) {
381        (Some(left), Some(right)) => left == right,
382        _ => left == right,
383    }
384}
385
386/// How big a value is, and which reading of "big" applied.
387///
388/// An array counts items. A field carrying `integer` or `numeric` is compared
389/// by value, which is what makes `age|integer|min:18` accept the string `"18"`
390/// a form sends. Anything else is measured in characters.
391fn measure(value: &Json, rules: &Rules) -> Option<(f64, SizeKind)> {
392    if let Json::Array(items) = value {
393        return Some((items.len() as f64, SizeKind::Array));
394    }
395    if rules.has("integer") || rules.has("numeric") {
396        return as_number(value).map(|number| (number, SizeKind::Numeric));
397    }
398    match value {
399        Json::Number(number) => Some((*number, SizeKind::Numeric)),
400        Json::String(found) => Some((found.chars().count() as f64, SizeKind::String)),
401        _ => None,
402    }
403}
404
405/// The reading a message should use, even for a value too odd to measure.
406fn size_kind(value: &Json, rules: &Rules) -> SizeKind {
407    if let Some((_, kind)) = measure(value, rules) {
408        return kind;
409    }
410    if rules.has("integer") || rules.has("numeric") { SizeKind::Numeric } else { SizeKind::String }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use rustlavel_http::Method;
417
418    /// Run one field's rules over one value and report the first message.
419    fn message_for(value: Json, spec: &str) -> Option<String> {
420        Validator::new(Input::new().with("field", value))
421            .rule("field", spec)
422            .errors()
423            .first("field")
424            .map(str::to_string)
425    }
426
427    fn passes(value: Json, spec: &str) -> bool {
428        message_for(value, spec).is_none()
429    }
430
431    #[test]
432    fn required_accepts_a_value_and_rejects_every_shape_of_emptiness() {
433        assert!(passes(Json::from("ada"), "required"));
434        assert!(passes(Json::from(0), "required"), "zero is a value");
435        assert!(passes(Json::from(false), "required"), "false is a value");
436
437        assert!(!passes(Json::Null, "required"));
438        assert!(!passes(Json::from(""), "required"));
439        assert!(!passes(Json::from("   "), "required"));
440        assert!(!passes(Json::Array(vec![]), "required"));
441
442        let missing = Validator::new(Input::new()).rule("field", "required").errors();
443        assert_eq!(missing.first("field"), Some("The field field is required."));
444    }
445
446    #[test]
447    fn an_absent_field_without_required_trips_nothing() {
448        let errors = Validator::new(Input::new()).rule("age", "integer|min:18").errors();
449        assert!(errors.is_empty());
450    }
451
452    #[test]
453    fn nullable_lets_an_explicit_null_through_where_a_bare_rule_would_not() {
454        assert!(passes(Json::Null, "nullable|integer"));
455        assert!(!passes(Json::Null, "integer"));
456    }
457
458    #[test]
459    fn nullable_and_required_together_still_demand_a_value() {
460        assert!(!passes(Json::Null, "required|nullable|string"));
461    }
462
463    #[test]
464    fn a_blank_string_is_treated_as_absent_by_every_rule_but_required() {
465        assert!(passes(Json::from(""), "email|min:5"));
466        assert!(!passes(Json::from(""), "required|email"));
467    }
468
469    #[test]
470    fn string_accepts_text_and_rejects_other_json_types() {
471        assert!(passes(Json::from("ada"), "string"));
472        assert!(!passes(Json::from(7), "string"));
473        assert!(!passes(Json::from(true), "string"));
474        assert_eq!(
475            message_for(Json::from(7), "string").unwrap(),
476            "The field field must be a string."
477        );
478    }
479
480    #[test]
481    fn integer_accepts_a_whole_number_however_it_was_encoded() {
482        assert!(passes(Json::from(18), "integer"));
483        assert!(passes(Json::from("18"), "integer"), "a form sends numbers as text");
484        assert!(passes(Json::from(-3), "integer"));
485
486        assert!(!passes(Json::from(1.5), "integer"));
487        assert!(!passes(Json::from("1.5"), "integer"));
488        assert!(!passes(Json::from("eighteen"), "integer"));
489        assert_eq!(
490            message_for(Json::from("x"), "integer").unwrap(),
491            "The field field must be an integer."
492        );
493    }
494
495    #[test]
496    fn numeric_accepts_fractions_but_not_words_or_infinities() {
497        assert!(passes(Json::from(1.5), "numeric"));
498        assert!(passes(Json::from("-2.75"), "numeric"));
499
500        assert!(!passes(Json::from("abc"), "numeric"));
501        assert!(!passes(Json::from("inf"), "numeric"), "`inf` parses in Rust but is not a number a user typed");
502        assert!(!passes(Json::from(true), "numeric"));
503    }
504
505    #[test]
506    fn boolean_accepts_the_forms_a_checkbox_arrives_in() {
507        for value in [Json::from(true), Json::from(false), Json::from(1), Json::from(0)] {
508            assert!(passes(value.clone(), "boolean"), "{value} should be boolean");
509        }
510        for value in ["1", "0", "true", "false"] {
511            assert!(passes(Json::from(value), "boolean"), "{value} should be boolean");
512        }
513
514        assert!(!passes(Json::from("yes"), "boolean"));
515        assert!(!passes(Json::from(2), "boolean"));
516        assert_eq!(
517            message_for(Json::from("yes"), "boolean").unwrap(),
518            "The field field must be true or false."
519        );
520    }
521
522    #[test]
523    fn email_and_url_delegate_to_the_format_checks() {
524        assert!(passes(Json::from("ada@example.com"), "email"));
525        assert!(!passes(Json::from("ada@example"), "email"));
526        assert_eq!(
527            message_for(Json::from("nope"), "email").unwrap(),
528            "The field field must be a valid email address."
529        );
530
531        assert!(passes(Json::from("https://example.com"), "url"));
532        assert!(!passes(Json::from("example.com"), "url"));
533        assert_eq!(
534            message_for(Json::from("example.com"), "url").unwrap(),
535            "The field field must be a valid URL."
536        );
537    }
538
539    #[test]
540    fn min_counts_characters_for_a_string() {
541        assert!(passes(Json::from("abc"), "min:3"));
542        assert!(!passes(Json::from("ab"), "min:3"));
543        assert_eq!(
544            message_for(Json::from("ab"), "min:3").unwrap(),
545            "The field field must be at least 3 characters."
546        );
547    }
548
549    #[test]
550    fn min_compares_values_when_the_field_is_a_number() {
551        assert!(passes(Json::from(18), "integer|min:18"));
552        assert!(passes(Json::from("18"), "integer|min:18"), "a form value is still a number");
553        assert!(!passes(Json::from(17), "integer|min:18"));
554        assert_eq!(
555            message_for(Json::from(17), "integer|min:18").unwrap(),
556            "The field field must be at least 18."
557        );
558    }
559
560    #[test]
561    fn min_counts_items_for_an_array() {
562        assert!(passes(Json::Array(vec![Json::from("a"), Json::from("b")]), "array|min:2"));
563        assert_eq!(
564            message_for(Json::Array(vec![Json::from("a")]), "array|min:2").unwrap(),
565            "The field field must have at least 2 items."
566        );
567    }
568
569    #[test]
570    fn max_mirrors_min_across_the_same_three_readings() {
571        assert!(passes(Json::from("abc"), "max:3"));
572        assert!(!passes(Json::from("abcd"), "max:3"));
573        assert_eq!(
574            message_for(Json::from("abcd"), "max:3").unwrap(),
575            "The field field must not be greater than 3 characters."
576        );
577
578        assert!(passes(Json::from(3), "integer|max:3"));
579        assert_eq!(
580            message_for(Json::from(4), "integer|max:3").unwrap(),
581            "The field field must not be greater than 3."
582        );
583
584        assert_eq!(
585            message_for(Json::Array(vec![Json::from(1), Json::from(2)]), "array|max:1").unwrap(),
586            "The field field must not have more than 1 items."
587        );
588    }
589
590    #[test]
591    fn between_is_inclusive_on_both_ends() {
592        assert!(passes(Json::from(1), "integer|between:1,10"));
593        assert!(passes(Json::from(10), "integer|between:1,10"));
594        assert!(!passes(Json::from(11), "integer|between:1,10"));
595        assert_eq!(
596            message_for(Json::from(0), "integer|between:1,10").unwrap(),
597            "The field field must be between 1 and 10."
598        );
599        assert_eq!(
600            message_for(Json::from("ab"), "between:3,5").unwrap(),
601            "The field field must be between 3 and 5 characters."
602        );
603    }
604
605    #[test]
606    fn size_demands_an_exact_length_value_or_count() {
607        assert!(passes(Json::from("abcdef"), "size:6"));
608        assert!(!passes(Json::from("abcde"), "size:6"));
609        assert_eq!(
610            message_for(Json::from("abcde"), "size:6").unwrap(),
611            "The field field must be 6 characters."
612        );
613        assert!(passes(Json::from(6), "integer|size:6"));
614        assert!(passes(Json::Array(vec![Json::from(1)]), "array|size:1"));
615    }
616
617    #[test]
618    fn in_and_not_in_match_against_the_listed_values() {
619        assert!(passes(Json::from("draft"), "in:draft,published"));
620        assert!(!passes(Json::from("deleted"), "in:draft,published"));
621        assert_eq!(
622            message_for(Json::from("deleted"), "in:draft,published").unwrap(),
623            "The selected field is invalid."
624        );
625
626        assert!(passes(Json::from("ada"), "not_in:admin,root"));
627        assert!(!passes(Json::from("root"), "not_in:admin,root"));
628    }
629
630    #[test]
631    fn alpha_families_reject_the_characters_they_exclude() {
632        assert!(passes(Json::from("Ada"), "alpha"));
633        assert!(!passes(Json::from("Ada2"), "alpha"));
634        assert_eq!(
635            message_for(Json::from("Ada2"), "alpha").unwrap(),
636            "The field field must only contain letters."
637        );
638
639        assert!(passes(Json::from("Ada2"), "alpha_num"));
640        assert!(!passes(Json::from("Ada-2"), "alpha_num"));
641
642        assert!(passes(Json::from("ada-2_x"), "alpha_dash"));
643        assert!(!passes(Json::from("ada 2"), "alpha_dash"));
644    }
645
646    #[test]
647    fn starts_with_and_ends_with_accept_any_of_their_options() {
648        assert!(passes(Json::from("https://x.dev"), "starts_with:http,https"));
649        assert!(!passes(Json::from("ftp://x.dev"), "starts_with:http,https"));
650        assert_eq!(
651            message_for(Json::from("ftp://x.dev"), "starts_with:http,https").unwrap(),
652            "The field field must start with one of the following: http, https."
653        );
654
655        assert!(passes(Json::from("a@b.dev"), "ends_with:.com,.dev"));
656        assert!(!passes(Json::from("a@b.net"), "ends_with:.com,.dev"));
657        assert_eq!(
658            message_for(Json::from("a@b.net"), "ends_with:.com,.dev").unwrap(),
659            "The field field must end with one of the following: .com, .dev."
660        );
661    }
662
663    #[test]
664    fn date_uuid_and_array_report_their_own_shapes() {
665        assert!(passes(Json::from("2024-02-29"), "date"));
666        assert!(!passes(Json::from("2023-02-29"), "date"));
667        assert_eq!(
668            message_for(Json::from("nope"), "date").unwrap(),
669            "The field field must be a valid date in the format YYYY-MM-DD."
670        );
671
672        assert!(passes(Json::from("9f8b2c1a-4d3e-4f5a-8b7c-1d2e3f4a5b6c"), "uuid"));
673        assert!(!passes(Json::from("not-a-uuid"), "uuid"));
674        assert_eq!(
675            message_for(Json::from("x"), "uuid").unwrap(),
676            "The field field must be a valid UUID."
677        );
678
679        assert!(passes(Json::Array(vec![Json::from(1)]), "array"));
680        assert!(!passes(Json::from("a,b"), "array"));
681        assert_eq!(
682            message_for(Json::from("a"), "array").unwrap(),
683            "The field field must be an array."
684        );
685    }
686
687    #[test]
688    fn confirmed_looks_for_the_matching_confirmation_field() {
689        let input = Input::new().with("password", "secret").with("password_confirmation", "secret");
690        assert!(Validator::new(input).rule("password", "confirmed").passes());
691
692        let wrong = Input::new().with("password", "secret").with("password_confirmation", "typo");
693        let errors = Validator::new(wrong).rule("password", "confirmed").errors();
694        assert_eq!(errors.first("password"), Some("The password field confirmation does not match."));
695
696        // A confirmation that was never sent fails just as a mismatch does.
697        let absent = Input::new().with("password", "secret");
698        assert!(Validator::new(absent).rule("password", "confirmed").fails());
699    }
700
701    #[test]
702    fn same_and_different_compare_against_another_field() {
703        let input = Input::new().with("password", "secret").with("repeat", "secret");
704        assert!(Validator::new(input.clone()).rule("repeat", "same:password").passes());
705
706        let errors = Validator::new(input).rule("repeat", "different:password").errors();
707        assert_eq!(errors.first("repeat"), Some("The repeat field and password must be different."));
708
709        let differing = Input::new().with("username", "ada").with("password", "secret");
710        assert!(Validator::new(differing.clone()).rule("password", "different:username").passes());
711
712        let errors = Validator::new(differing).rule("password", "same:username").errors();
713        assert_eq!(errors.first("password"), Some("The password field must match username."));
714    }
715
716    #[test]
717    fn same_compares_a_json_number_with_the_text_a_form_would_resend() {
718        let input = Input::new().with("total", 42).with("confirm_total", "42");
719        assert!(Validator::new(input).rule("confirm_total", "same:total").passes());
720    }
721
722    #[test]
723    fn every_failing_rule_on_a_field_is_reported() {
724        let errors = Validator::new(Input::new().with("email", "nope"))
725            .rule("email", "email|min:20")
726            .errors();
727
728        assert_eq!(errors.get("email").len(), 2);
729        assert_eq!(errors.len(), 2);
730    }
731
732    #[test]
733    fn a_custom_message_replaces_the_default_for_one_field_and_rule() {
734        let errors = Validator::new(Input::new())
735            .rule("email", "required")
736            .rule("name", "required")
737            .message("email.required", "We cannot reach you without an email.")
738            .errors();
739
740        assert_eq!(errors.first("email"), Some("We cannot reach you without an email."));
741        assert_eq!(errors.first("name"), Some("The name field is required."));
742    }
743
744    #[test]
745    fn a_custom_message_keeps_its_placeholders() {
746        let errors = Validator::new(Input::new().with("age", 12))
747            .rule("age", "integer|min:18")
748            .message("min", "You must be :min or older to sign up (:attribute).")
749            .errors();
750
751        assert_eq!(errors.first("age"), Some("You must be 18 or older to sign up (age)."));
752    }
753
754    #[test]
755    fn an_attribute_override_reaches_the_rendered_message() {
756        let errors = Validator::new(Input::new())
757            .rule("dob", "required")
758            .attribute("dob", "date of birth")
759            .errors();
760
761        assert_eq!(errors.first("dob"), Some("The date of birth field is required."));
762    }
763
764    #[test]
765    fn a_snake_case_field_reads_as_words_without_any_configuration() {
766        let errors = Validator::new(Input::new()).rule("email_address", "required").errors();
767        assert_eq!(errors.first("email_address"), Some("The email address field is required."));
768    }
769
770    #[test]
771    fn validated_data_is_the_checked_subset_and_nothing_else() {
772        let input = Input::new()
773            .with("email", "ada@example.com")
774            .with("age", 36)
775            .with("is_admin", true);
776
777        let data = Validator::new(input)
778            .rules(&[("email", "required|email"), ("age", "integer")])
779            .validate()
780            .unwrap();
781
782        assert_eq!(data.len(), 2);
783        assert!(!data.has("is_admin"), "a field with no rules must not ride along");
784        assert_eq!(data.string("email").as_deref(), Some("ada@example.com"));
785        assert_eq!(data.integer("age"), Some(36));
786    }
787
788    #[test]
789    fn validated_accessors_read_the_forms_a_wire_value_arrives_in() {
790        let input = Input::new()
791            .with("age", "36")
792            .with("rate", "1.5")
793            .with("active", "true")
794            .with("tags", Json::Array(vec![Json::from("a")]));
795
796        let data = Validator::new(input)
797            .rules(&[("age", "integer"), ("rate", "numeric"), ("active", "boolean"), ("tags", "array")])
798            .validate()
799            .unwrap();
800
801        assert_eq!(data.integer("age"), Some(36));
802        assert_eq!(data.number("rate"), Some(1.5));
803        assert_eq!(data.boolean("active"), Some(true));
804        assert_eq!(data.array("tags").unwrap().len(), 1);
805        assert_eq!(data.string("age").as_deref(), Some("36"));
806        assert_eq!(data.integer("missing"), None);
807    }
808
809    #[test]
810    fn validated_data_converts_to_a_json_object() {
811        let data = Validator::new(Input::new().with("name", "ada"))
812            .rule("name", "required|string")
813            .validate()
814            .unwrap();
815
816        assert_eq!(Json::from(data).to_string(), r#"{"name":"ada"}"#);
817    }
818
819    #[test]
820    fn a_nullable_field_that_was_sent_as_null_is_still_returned() {
821        let data = Validator::new(Input::new().with("nickname", Json::Null))
822            .rule("nickname", "nullable|string")
823            .validate()
824            .unwrap();
825
826        assert!(data.has("nickname"));
827        assert!(data.get("nickname").unwrap().is_null());
828        assert_eq!(data.string("nickname"), None);
829    }
830
831    #[test]
832    fn the_builder_and_the_string_syntax_validate_identically() {
833        let input = Input::new().with("email", "nope").with("age", 12);
834        let specs = Validator::new(input.clone())
835            .rules(&[("email", "required|email"), ("age", "integer|min:18")])
836            .errors();
837        let built = Validator::new(input)
838            .rule("email", Rule::required().email())
839            .rule("age", Rule::integer().min(18))
840            .errors();
841
842        assert_eq!(specs, built);
843    }
844
845    #[test]
846    fn validating_a_request_reads_a_json_body() {
847        let mut request = Request::new(Method::Post, "/users")
848            .with_json(Json::object([("email", "ada@example.com".into()), ("age", 36.into())]));
849
850        let data = Validator::from_request(&mut request)
851            .rules(&[("email", "required|email"), ("age", "required|integer|min:18")])
852            .validate()
853            .unwrap();
854
855        assert_eq!(data.string("email").as_deref(), Some("ada@example.com"));
856        assert_eq!(data.integer("age"), Some(36));
857    }
858
859    #[test]
860    fn validating_a_request_reads_a_form_body() {
861        let mut request = Request::new(Method::Post, "/register").with_form(&[
862            ("email", "ada@example.com"),
863            ("password", "secret123"),
864            ("password_confirmation", "secret123"),
865        ]);
866
867        let data = Validator::from_request(&mut request)
868            .rules(&[("email", "required|email"), ("password", "required|min:8|confirmed")])
869            .validate()
870            .unwrap();
871
872        assert_eq!(data.len(), 2);
873        assert_eq!(data.string("password").as_deref(), Some("secret123"));
874    }
875
876    #[test]
877    fn a_failing_form_request_reports_every_field() {
878        let mut request = Request::new(Method::Post, "/register")
879            .with_form(&[("email", "nope"), ("password", "short"), ("password_confirmation", "other")]);
880
881        let errors = Validator::from_request(&mut request)
882            .rules(&[("email", "required|email"), ("password", "required|min:8|confirmed")])
883            .validate()
884            .unwrap_err();
885
886        assert_eq!(errors.first("email"), Some("The email field must be a valid email address."));
887        assert_eq!(errors.get("password").len(), 2);
888        assert!(!errors.wants_json(), "a form post is a browser, not an API client");
889    }
890
891    #[test]
892    fn a_json_request_is_remembered_as_wanting_a_json_failure() {
893        let mut request =
894            Request::new(Method::Post, "/api/users").with_json(Json::object([("email", "no".into())]));
895
896        let errors = Validator::from_request(&mut request).rule("email", "email").validate().unwrap_err();
897        assert!(errors.wants_json());
898    }
899
900    #[test]
901    fn query_string_values_validate_like_any_other_input() {
902        let mut request = Request::new(Method::Get, "/search?page=2&per_page=500");
903
904        let errors = Validator::from_request(&mut request)
905            .rules(&[("page", "integer|min:1"), ("per_page", "integer|max:100")])
906            .validate()
907            .unwrap_err();
908
909        assert!(!errors.has("page"));
910        assert_eq!(errors.first("per_page"), Some("The per page field must not be greater than 100."));
911    }
912}