Skip to main content

rustlavel_validation/
errors.rs

1//! What comes back when validation fails.
2//!
3//! Laravel answers a failed validation with `422` and a body of
4//! `{"message": "...", "errors": {"email": ["..."]}}`. Every JavaScript client
5//! written against a Laravel API already knows that shape, so Rustlavel emits
6//! it verbatim rather than inventing a fourth error envelope.
7
8use crate::messages::Messages;
9use rustlavel_core::Json;
10use rustlavel_http::{IntoResponse, Response, Status};
11use std::collections::BTreeMap;
12
13/// The messages a failed validation produced, grouped by field.
14///
15/// `wants_json` and `back` are captured when the errors are built from a
16/// request, because [`IntoResponse::into_response`] no longer has the request
17/// to negotiate with or to read a `Referer` from.
18#[derive(Debug, Clone, Default, PartialEq)]
19pub struct Errors {
20    fields: BTreeMap<String, Vec<String>>,
21    wants_json: bool,
22    /// Where a browser is sent when validation fails. `None` when there is no
23    /// session to leave the messages in, which is when this falls back to
24    /// rendering them as text.
25    back: Option<String>,
26}
27
28impl Errors {
29    pub fn new() -> Self {
30        Errors::default()
31    }
32
33    /// The status a failed validation answers with.
34    pub const STATUS: Status = Status::UNPROCESSABLE;
35
36    pub fn add(&mut self, field: impl Into<String>, message: impl Into<String>) {
37        self.fields.entry(field.into()).or_default().push(message.into());
38    }
39
40    pub fn has(&self, field: &str) -> bool {
41        self.fields.contains_key(field)
42    }
43
44    /// The first message for a field — what a form renders next to the input.
45    pub fn first(&self, field: &str) -> Option<&str> {
46        self.fields.get(field)?.first().map(String::as_str)
47    }
48
49    /// Every message for one field.
50    pub fn get(&self, field: &str) -> &[String] {
51        self.fields.get(field).map_or(&[], Vec::as_slice)
52    }
53
54    /// Every message for every field, keyed by field name.
55    pub fn all(&self) -> &BTreeMap<String, Vec<String>> {
56        &self.fields
57    }
58
59    /// Every message, flattened, in field order.
60    pub fn messages(&self) -> impl Iterator<Item = &str> {
61        self.fields.values().flatten().map(String::as_str)
62    }
63
64    pub fn is_empty(&self) -> bool {
65        self.fields.is_empty()
66    }
67
68    /// The number of messages, not the number of fields — one field can fail
69    /// several rules.
70    pub fn len(&self) -> usize {
71        self.fields.values().map(Vec::len).sum()
72    }
73
74    pub fn fields(&self) -> impl Iterator<Item = &str> {
75        self.fields.keys().map(String::as_str)
76    }
77
78    /// Whether the response should be JSON. Set from `Request::wants_json`.
79    pub fn wants_json(&self) -> bool {
80        self.wants_json
81    }
82
83    /// Send a browser back here instead of rendering the messages as text.
84    pub fn redirecting_to(mut self, back: Option<String>) -> Self {
85        self.back = back;
86        self
87    }
88
89    /// Where a browser will be sent, if anywhere.
90    pub fn back(&self) -> Option<&str> {
91        self.back.as_deref()
92    }
93
94    pub fn with_json(mut self, wants_json: bool) -> Self {
95        self.wants_json = wants_json;
96        self
97    }
98
99    /// Add a message by hand, chaining — useful for a rule an application
100    /// enforces itself, so its failure comes back in the same envelope as the
101    /// built-in ones.
102    pub fn with(mut self, field: impl Into<String>, message: impl Into<String>) -> Self {
103        self.add(field, message);
104        self
105    }
106
107    /// Render a hand-written message through the message bag's label rules, so
108    /// it interpolates `:attribute` the way a built-in one does.
109    pub fn add_interpolated(&mut self, messages: &Messages, field: &str, template: &str) {
110        let rendered =
111            crate::messages::interpolate(template, &[("attribute", messages.label(field))]);
112        self.add(field, rendered);
113    }
114
115    /// The single-line summary Laravel puts in the `message` key: the first
116    /// failure, and a count of the rest so a client that only shows one line
117    /// still tells the user there is more to fix.
118    pub fn summary(&self) -> String {
119        let Some(first) = self.messages().next() else {
120            return "The given data was invalid.".to_string();
121        };
122        match self.len() - 1 {
123            0 => first.to_string(),
124            1 => format!("{first} (and 1 more error)"),
125            more => format!("{first} (and {more} more errors)"),
126        }
127    }
128
129    /// The Laravel-shaped 422 body.
130    /// Just the field map, as a template reads it: `{"email": ["…"]}`.
131    ///
132    /// [`Errors::to_json`] wraps this in Laravel's `{"message", "errors"}`
133    /// envelope, which is right for an API response and one level too deep for
134    /// a view.
135    pub fn to_field_json(&self) -> Json {
136        Json::object(self.fields.iter().map(|(field, messages)| {
137            (
138                field.as_str(),
139                Json::Array(messages.iter().map(|m| Json::from(m.as_str())).collect()),
140            )
141        }))
142    }
143
144    pub fn to_json(&self) -> Json {
145        let errors = self.fields.iter().map(|(field, messages)| {
146            let messages = messages.iter().map(|m| Json::from(m.as_str())).collect();
147            (field.clone(), Json::Array(messages))
148        });
149        Json::object([
150            ("message", Json::from(self.summary())),
151            ("errors", Json::Object(errors.collect())),
152        ])
153    }
154}
155
156impl std::fmt::Display for Errors {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        f.write_str(&self.summary())
159    }
160}
161
162impl std::error::Error for Errors {}
163
164impl From<&Errors> for Json {
165    fn from(errors: &Errors) -> Self {
166        errors.to_json()
167    }
168}
169
170impl From<Errors> for Json {
171    fn from(errors: Errors) -> Self {
172        errors.to_json()
173    }
174}
175
176/// A `422` for an API client; for a browser, a redirect back to the form.
177///
178/// The two halves answer different questions. A JSON client asked for a result
179/// and gets one, with the status that says why. A browser asked for a page, and
180/// the useful answer is the form it just submitted, with the messages attached
181/// and the boxes still filled in.
182///
183/// It is a redirect rather than the page itself because the answer to a failed
184/// `POST` has to leave the browser somewhere reloadable. Rendering in place
185/// leaves it on a URL that re-submits the form on refresh, which is the
186/// double-submission problem in miniature — and it is why the messages travel
187/// through the session rather than in this response.
188///
189/// With no session to leave them in, this falls back to plain text. That is a
190/// degraded answer rather than a broken one, and it is what an application
191/// with no session middleware gets.
192impl IntoResponse for Errors {
193    fn into_response(self) -> Response {
194        if self.wants_json {
195            return Response::new(Errors::STATUS).with_json(self.to_json());
196        }
197        if let Some(back) = &self.back {
198            // 303, so the browser follows it with a GET. A 302 leaves the
199            // method to the browser, and the older ones famously disagreed.
200            return Response::see_other(back.clone());
201        }
202        let mut body = self.summary();
203        for message in self.messages().skip(1) {
204            body.push('\n');
205            body.push_str(message);
206        }
207        Response::new(Errors::STATUS).with_text(body)
208    }
209}
210
211impl From<Errors> for Response {
212    fn from(errors: Errors) -> Self {
213        errors.into_response()
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    fn sample() -> Errors {
222        Errors::new()
223            .with("email", "The email field is required.")
224            .with("email", "The email field must be a valid email address.")
225            .with("age", "The age field must be at least 18.")
226    }
227
228    #[test]
229    fn an_empty_bag_reports_itself_as_empty() {
230        let errors = Errors::new();
231        assert!(errors.is_empty());
232        assert_eq!(errors.len(), 0);
233        assert!(!errors.has("email"));
234        assert_eq!(errors.first("email"), None);
235        assert!(errors.get("email").is_empty());
236    }
237
238    #[test]
239    fn messages_are_grouped_by_field_and_kept_in_order() {
240        let errors = sample();
241
242        assert!(errors.has("email"));
243        assert_eq!(errors.first("email"), Some("The email field is required."));
244        assert_eq!(errors.get("email").len(), 2);
245        assert_eq!(errors.all().len(), 2, "two fields failed");
246        assert_eq!(errors.len(), 3, "three messages in total");
247        assert_eq!(errors.fields().collect::<Vec<_>>(), ["age", "email"]);
248    }
249
250    #[test]
251    fn the_summary_counts_the_failures_it_did_not_show() {
252        assert_eq!(Errors::new().summary(), "The given data was invalid.");
253        assert_eq!(Errors::new().with("a", "One.").summary(), "One.");
254        assert_eq!(
255            Errors::new().with("a", "One.").with("a", "Two.").summary(),
256            "One. (and 1 more error)"
257        );
258        assert_eq!(sample().summary(), "The age field must be at least 18. (and 2 more errors)");
259    }
260
261    #[test]
262    fn the_body_has_laravels_422_shape() {
263        let body = Errors::new()
264            .with("email", "The email field is required.")
265            .to_json();
266
267        assert_eq!(
268            body.to_string(),
269            r#"{"errors":{"email":["The email field is required."]},"message":"The email field is required."}"#
270        );
271        assert_eq!(body.get("errors.email.0").unwrap().as_str(), Some("The email field is required."));
272    }
273
274    #[test]
275    fn a_json_client_gets_the_422_envelope() {
276        let response = sample().with_json(true).into_response();
277
278        assert_eq!(response.status, Status::UNPROCESSABLE);
279        assert_eq!(response.headers.content_type(), Some("application/json"));
280        assert!(response.body_string().contains(r#""errors":{"age":["#));
281    }
282
283    #[test]
284    fn a_browser_gets_a_plain_body_it_can_read() {
285        let response = sample().into_response();
286
287        assert_eq!(response.status, Status::UNPROCESSABLE);
288        assert_eq!(response.headers.content_type(), Some("text/plain"));
289        assert!(response.body_string().contains("The email field is required."));
290    }
291
292    #[test]
293    fn a_hand_written_message_interpolates_the_attribute() {
294        let messages = Messages::new().attribute("dob", "date of birth");
295        let mut errors = Errors::new();
296        errors.add_interpolated(&messages, "dob", "The :attribute field is in the future.");
297
298        assert_eq!(errors.first("dob"), Some("The date of birth field is in the future."));
299    }
300
301    #[test]
302    fn errors_display_as_their_summary() {
303        assert_eq!(sample().to_string(), sample().summary());
304    }
305}