ferro_rs/validation/
error.rs1use serde::Serialize;
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Default, Serialize)]
11pub struct ValidationError {
12 errors: HashMap<String, Vec<String>>,
14 #[serde(skip)]
18 old_input: Option<serde_json::Value>,
19}
20
21impl ValidationError {
22 pub fn new() -> Self {
24 Self::default()
25 }
26
27 pub fn add(&mut self, field: &str, message: impl Into<String>) {
29 self.errors
30 .entry(field.to_string())
31 .or_default()
32 .push(message.into());
33 }
34
35 pub fn is_empty(&self) -> bool {
37 self.errors.is_empty()
38 }
39
40 pub fn has(&self, field: &str) -> bool {
42 self.errors.contains_key(field)
43 }
44
45 pub fn get(&self, field: &str) -> Option<&Vec<String>> {
47 self.errors.get(field)
48 }
49
50 pub fn first(&self, field: &str) -> Option<&String> {
52 self.errors.get(field).and_then(|v| v.first())
53 }
54
55 pub fn all(&self) -> &HashMap<String, Vec<String>> {
57 &self.errors
58 }
59
60 pub fn count(&self) -> usize {
62 self.errors.values().map(|v| v.len()).sum()
63 }
64
65 pub fn messages(&self) -> Vec<&String> {
67 self.errors.values().flatten().collect()
68 }
69
70 pub fn into_messages(self) -> HashMap<String, Vec<String>> {
73 self.errors
74 }
75
76 pub fn to_json(&self) -> serde_json::Value {
78 serde_json::json!({
79 "message": "The given data was invalid.",
80 "errors": self.errors
81 })
82 }
83
84 pub fn with_old_input(mut self, data: &serde_json::Value) -> Self {
99 self.old_input = Some(data.clone());
100 self
101 }
102
103 pub fn redirect_back(self, referer: Option<&str>) -> crate::http::Response {
117 let target = match referer {
119 Some(r) if is_same_origin(r) => r.to_string(),
120 _ => "/".to_string(),
121 };
122 self.flash_into_session();
123 crate::http::Redirect::to(target).into()
124 }
125
126 pub fn redirect_to(self, url: impl Into<String>) -> crate::http::Response {
140 self.flash_into_session();
141 crate::http::Redirect::to(url.into()).into()
142 }
143
144 pub fn into_action_error(self, url: impl Into<String>) -> crate::http::action::ActionError {
161 self.flash_into_session();
162 crate::http::action::ActionError::validation_failed(url)
163 }
164
165 fn flash_into_session(self) {
170 let errors = self.errors;
171 let old = self.old_input;
172 crate::session::session_mut(|session| {
173 session.flash("_validation_errors", &errors);
174 if let Some(serde_json::Value::Object(map)) = old {
175 for (k, v) in map {
176 let stringified = match v {
177 serde_json::Value::String(s) => s,
178 serde_json::Value::Null => continue,
179 other => other.to_string(),
180 };
181 session.flash(&format!("_old_input.{k}"), &stringified);
182 }
183 }
184 });
185 }
186}
187
188fn is_same_origin(url: &str) -> bool {
194 if url.starts_with('/') {
196 return true;
197 }
198 false
200}
201
202impl std::fmt::Display for ValidationError {
203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204 let messages: Vec<String> = self
205 .errors
206 .iter()
207 .flat_map(|(field, msgs)| msgs.iter().map(move |m| format!("{field}: {m}")))
208 .collect();
209 write!(f, "{}", messages.join(", "))
210 }
211}
212
213impl std::error::Error for ValidationError {}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn test_validation_error_add() {
221 let mut errors = ValidationError::new();
222 errors.add("email", "The email field is required.");
223 errors.add("email", "The email must be a valid email address.");
224 errors.add("password", "The password must be at least 8 characters.");
225
226 assert!(!errors.is_empty());
227 assert!(errors.has("email"));
228 assert!(errors.has("password"));
229 assert!(!errors.has("name"));
230 assert_eq!(errors.count(), 3);
231 }
232
233 #[test]
234 fn test_validation_error_first() {
235 let mut errors = ValidationError::new();
236 errors.add("email", "First error");
237 errors.add("email", "Second error");
238
239 assert_eq!(errors.first("email"), Some(&"First error".to_string()));
240 assert_eq!(errors.first("name"), None);
241 }
242
243 #[test]
244 fn test_validation_error_to_json() {
245 let mut errors = ValidationError::new();
246 errors.add("email", "Required");
247
248 let json = errors.to_json();
249 assert!(json.get("message").is_some());
250 assert!(json.get("errors").is_some());
251 }
252
253 #[test]
256 fn test_redirect_back_returns_302_to_fallback_when_no_referer() {
257 let mut errors = ValidationError::new();
258 errors.add("email", "required");
259 let response = errors.redirect_back(None);
260 let resp = response.unwrap();
262 assert_eq!(resp.status_code(), 302);
263 let hyper_resp = resp.into_hyper();
264 let location = hyper_resp
265 .headers()
266 .get("Location")
267 .and_then(|v| v.to_str().ok());
268 assert_eq!(location, Some("/"));
269 }
270
271 #[test]
272 fn test_redirect_back_with_explicit_referer() {
273 let mut errors = ValidationError::new();
274 errors.add("name", "required");
275 let response = errors.redirect_back(Some("/dashboard/prodotti/nuovo"));
276 let resp = response.unwrap();
277 assert_eq!(resp.status_code(), 302);
278 let hyper_resp = resp.into_hyper();
279 let location = hyper_resp
280 .headers()
281 .get("Location")
282 .and_then(|v| v.to_str().ok());
283 assert_eq!(location, Some("/dashboard/prodotti/nuovo"));
284 }
285
286 #[test]
287 fn test_redirect_back_rejects_external_referer() {
288 let mut errors = ValidationError::new();
290 errors.add("name", "required");
291 let response = errors.redirect_back(Some("https://evil.example.com/phishing"));
292 let resp = response.unwrap();
293 assert_eq!(resp.status_code(), 302);
294 let hyper_resp = resp.into_hyper();
295 let location = hyper_resp
296 .headers()
297 .get("Location")
298 .and_then(|v| v.to_str().ok());
299 assert_eq!(location, Some("/"));
300 }
301
302 #[test]
303 fn test_redirect_to_returns_302_to_explicit_url() {
304 let mut errors = ValidationError::new();
305 errors.add("slug", "invalid");
306 let response = errors.redirect_to("/settings?tab=generale");
307 let resp = response.unwrap();
308 assert_eq!(resp.status_code(), 302);
309 let hyper_resp = resp.into_hyper();
310 let location = hyper_resp
311 .headers()
312 .get("Location")
313 .and_then(|v| v.to_str().ok());
314 assert_eq!(location, Some("/settings?tab=generale"));
315 }
316
317 #[test]
318 fn test_with_old_input_chaining() {
319 let mut errors = ValidationError::new();
323 errors.add("email", "required");
324 let data = serde_json::json!({"email": "bad@"});
325 let e = errors.with_old_input(&data);
327 assert!(!e.is_empty());
328 }
329}