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` is captured when the errors are built from a request, because
16/// [`IntoResponse::into_response`] no longer has the request to negotiate with.
17#[derive(Debug, Clone, Default, PartialEq)]
18pub struct Errors {
19    fields: BTreeMap<String, Vec<String>>,
20    wants_json: bool,
21}
22
23impl Errors {
24    pub fn new() -> Self {
25        Errors::default()
26    }
27
28    /// The status a failed validation answers with.
29    pub const STATUS: Status = Status::UNPROCESSABLE;
30
31    pub fn add(&mut self, field: impl Into<String>, message: impl Into<String>) {
32        self.fields.entry(field.into()).or_default().push(message.into());
33    }
34
35    pub fn has(&self, field: &str) -> bool {
36        self.fields.contains_key(field)
37    }
38
39    /// The first message for a field — what a form renders next to the input.
40    pub fn first(&self, field: &str) -> Option<&str> {
41        self.fields.get(field)?.first().map(String::as_str)
42    }
43
44    /// Every message for one field.
45    pub fn get(&self, field: &str) -> &[String] {
46        self.fields.get(field).map_or(&[], Vec::as_slice)
47    }
48
49    /// Every message for every field, keyed by field name.
50    pub fn all(&self) -> &BTreeMap<String, Vec<String>> {
51        &self.fields
52    }
53
54    /// Every message, flattened, in field order.
55    pub fn messages(&self) -> impl Iterator<Item = &str> {
56        self.fields.values().flatten().map(String::as_str)
57    }
58
59    pub fn is_empty(&self) -> bool {
60        self.fields.is_empty()
61    }
62
63    /// The number of messages, not the number of fields — one field can fail
64    /// several rules.
65    pub fn len(&self) -> usize {
66        self.fields.values().map(Vec::len).sum()
67    }
68
69    pub fn fields(&self) -> impl Iterator<Item = &str> {
70        self.fields.keys().map(String::as_str)
71    }
72
73    /// Whether the response should be JSON. Set from `Request::wants_json`.
74    pub fn wants_json(&self) -> bool {
75        self.wants_json
76    }
77
78    pub fn with_json(mut self, wants_json: bool) -> Self {
79        self.wants_json = wants_json;
80        self
81    }
82
83    /// Add a message by hand, chaining — useful for a rule an application
84    /// enforces itself, so its failure comes back in the same envelope as the
85    /// built-in ones.
86    pub fn with(mut self, field: impl Into<String>, message: impl Into<String>) -> Self {
87        self.add(field, message);
88        self
89    }
90
91    /// Render a hand-written message through the message bag's label rules, so
92    /// it interpolates `:attribute` the way a built-in one does.
93    pub fn add_interpolated(&mut self, messages: &Messages, field: &str, template: &str) {
94        let rendered =
95            crate::messages::interpolate(template, &[("attribute", messages.label(field))]);
96        self.add(field, rendered);
97    }
98
99    /// The single-line summary Laravel puts in the `message` key: the first
100    /// failure, and a count of the rest so a client that only shows one line
101    /// still tells the user there is more to fix.
102    pub fn summary(&self) -> String {
103        let Some(first) = self.messages().next() else {
104            return "The given data was invalid.".to_string();
105        };
106        match self.len() - 1 {
107            0 => first.to_string(),
108            1 => format!("{first} (and 1 more error)"),
109            more => format!("{first} (and {more} more errors)"),
110        }
111    }
112
113    /// The Laravel-shaped 422 body.
114    pub fn to_json(&self) -> Json {
115        let errors = self.fields.iter().map(|(field, messages)| {
116            let messages = messages.iter().map(|m| Json::from(m.as_str())).collect();
117            (field.clone(), Json::Array(messages))
118        });
119        Json::object([
120            ("message", Json::from(self.summary())),
121            ("errors", Json::Object(errors.collect())),
122        ])
123    }
124}
125
126impl std::fmt::Display for Errors {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        f.write_str(&self.summary())
129    }
130}
131
132impl std::error::Error for Errors {}
133
134impl From<&Errors> for Json {
135    fn from(errors: &Errors) -> Self {
136        errors.to_json()
137    }
138}
139
140impl From<Errors> for Json {
141    fn from(errors: Errors) -> Self {
142        errors.to_json()
143    }
144}
145
146/// A `422`, as JSON for an API client and as plain text for a browser.
147///
148/// The HTML side is intentionally minimal for now: re-rendering the submitted
149/// form with the messages attached needs the view layer, which is a later phase.
150impl IntoResponse for Errors {
151    fn into_response(self) -> Response {
152        if self.wants_json {
153            return Response::new(Errors::STATUS).with_json(self.to_json());
154        }
155        let mut body = self.summary();
156        for message in self.messages().skip(1) {
157            body.push('\n');
158            body.push_str(message);
159        }
160        Response::new(Errors::STATUS).with_text(body)
161    }
162}
163
164impl From<Errors> for Response {
165    fn from(errors: Errors) -> Self {
166        errors.into_response()
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    fn sample() -> Errors {
175        Errors::new()
176            .with("email", "The email field is required.")
177            .with("email", "The email field must be a valid email address.")
178            .with("age", "The age field must be at least 18.")
179    }
180
181    #[test]
182    fn an_empty_bag_reports_itself_as_empty() {
183        let errors = Errors::new();
184        assert!(errors.is_empty());
185        assert_eq!(errors.len(), 0);
186        assert!(!errors.has("email"));
187        assert_eq!(errors.first("email"), None);
188        assert!(errors.get("email").is_empty());
189    }
190
191    #[test]
192    fn messages_are_grouped_by_field_and_kept_in_order() {
193        let errors = sample();
194
195        assert!(errors.has("email"));
196        assert_eq!(errors.first("email"), Some("The email field is required."));
197        assert_eq!(errors.get("email").len(), 2);
198        assert_eq!(errors.all().len(), 2, "two fields failed");
199        assert_eq!(errors.len(), 3, "three messages in total");
200        assert_eq!(errors.fields().collect::<Vec<_>>(), ["age", "email"]);
201    }
202
203    #[test]
204    fn the_summary_counts_the_failures_it_did_not_show() {
205        assert_eq!(Errors::new().summary(), "The given data was invalid.");
206        assert_eq!(Errors::new().with("a", "One.").summary(), "One.");
207        assert_eq!(
208            Errors::new().with("a", "One.").with("a", "Two.").summary(),
209            "One. (and 1 more error)"
210        );
211        assert_eq!(sample().summary(), "The age field must be at least 18. (and 2 more errors)");
212    }
213
214    #[test]
215    fn the_body_has_laravels_422_shape() {
216        let body = Errors::new()
217            .with("email", "The email field is required.")
218            .to_json();
219
220        assert_eq!(
221            body.to_string(),
222            r#"{"errors":{"email":["The email field is required."]},"message":"The email field is required."}"#
223        );
224        assert_eq!(body.get("errors.email.0").unwrap().as_str(), Some("The email field is required."));
225    }
226
227    #[test]
228    fn a_json_client_gets_the_422_envelope() {
229        let response = sample().with_json(true).into_response();
230
231        assert_eq!(response.status, Status::UNPROCESSABLE);
232        assert_eq!(response.headers.content_type(), Some("application/json"));
233        assert!(response.body_string().contains(r#""errors":{"age":["#));
234    }
235
236    #[test]
237    fn a_browser_gets_a_plain_body_it_can_read() {
238        let response = sample().into_response();
239
240        assert_eq!(response.status, Status::UNPROCESSABLE);
241        assert_eq!(response.headers.content_type(), Some("text/plain"));
242        assert!(response.body_string().contains("The email field is required."));
243    }
244
245    #[test]
246    fn a_hand_written_message_interpolates_the_attribute() {
247        let messages = Messages::new().attribute("dob", "date of birth");
248        let mut errors = Errors::new();
249        errors.add_interpolated(&messages, "dob", "The :attribute field is in the future.");
250
251        assert_eq!(errors.first("dob"), Some("The date of birth field is in the future."));
252    }
253
254    #[test]
255    fn errors_display_as_their_summary() {
256        assert_eq!(sample().to_string(), sample().summary());
257    }
258}