rustlavel_validation/
errors.rs1use crate::messages::Messages;
9use rustlavel_core::Json;
10use rustlavel_http::{IntoResponse, Response, Status};
11use std::collections::BTreeMap;
12
13#[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 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 pub fn first(&self, field: &str) -> Option<&str> {
41 self.fields.get(field)?.first().map(String::as_str)
42 }
43
44 pub fn get(&self, field: &str) -> &[String] {
46 self.fields.get(field).map_or(&[], Vec::as_slice)
47 }
48
49 pub fn all(&self) -> &BTreeMap<String, Vec<String>> {
51 &self.fields
52 }
53
54 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 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 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 pub fn with(mut self, field: impl Into<String>, message: impl Into<String>) -> Self {
87 self.add(field, message);
88 self
89 }
90
91 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 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 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
146impl 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}