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 fn flash_into_session(self) {
149 let errors = self.errors;
150 let old = self.old_input;
151 crate::session::session_mut(|session| {
152 session.flash("_validation_errors", &errors);
153 if let Some(serde_json::Value::Object(map)) = old {
154 for (k, v) in map {
155 let stringified = match v {
156 serde_json::Value::String(s) => s,
157 serde_json::Value::Null => continue,
158 other => other.to_string(),
159 };
160 session.flash(&format!("_old_input.{k}"), &stringified);
161 }
162 }
163 });
164 }
165}
166
167fn is_same_origin(url: &str) -> bool {
173 if url.starts_with('/') {
175 return true;
176 }
177 false
179}
180
181impl std::fmt::Display for ValidationError {
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 let messages: Vec<String> = self
184 .errors
185 .iter()
186 .flat_map(|(field, msgs)| msgs.iter().map(move |m| format!("{field}: {m}")))
187 .collect();
188 write!(f, "{}", messages.join(", "))
189 }
190}
191
192impl std::error::Error for ValidationError {}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn test_validation_error_add() {
200 let mut errors = ValidationError::new();
201 errors.add("email", "The email field is required.");
202 errors.add("email", "The email must be a valid email address.");
203 errors.add("password", "The password must be at least 8 characters.");
204
205 assert!(!errors.is_empty());
206 assert!(errors.has("email"));
207 assert!(errors.has("password"));
208 assert!(!errors.has("name"));
209 assert_eq!(errors.count(), 3);
210 }
211
212 #[test]
213 fn test_validation_error_first() {
214 let mut errors = ValidationError::new();
215 errors.add("email", "First error");
216 errors.add("email", "Second error");
217
218 assert_eq!(errors.first("email"), Some(&"First error".to_string()));
219 assert_eq!(errors.first("name"), None);
220 }
221
222 #[test]
223 fn test_validation_error_to_json() {
224 let mut errors = ValidationError::new();
225 errors.add("email", "Required");
226
227 let json = errors.to_json();
228 assert!(json.get("message").is_some());
229 assert!(json.get("errors").is_some());
230 }
231
232 #[test]
235 fn test_redirect_back_returns_302_to_fallback_when_no_referer() {
236 let mut errors = ValidationError::new();
237 errors.add("email", "required");
238 let response = errors.redirect_back(None);
239 let resp = response.unwrap();
241 assert_eq!(resp.status_code(), 302);
242 let hyper_resp = resp.into_hyper();
243 let location = hyper_resp
244 .headers()
245 .get("Location")
246 .and_then(|v| v.to_str().ok());
247 assert_eq!(location, Some("/"));
248 }
249
250 #[test]
251 fn test_redirect_back_with_explicit_referer() {
252 let mut errors = ValidationError::new();
253 errors.add("name", "required");
254 let response = errors.redirect_back(Some("/dashboard/prodotti/nuovo"));
255 let resp = response.unwrap();
256 assert_eq!(resp.status_code(), 302);
257 let hyper_resp = resp.into_hyper();
258 let location = hyper_resp
259 .headers()
260 .get("Location")
261 .and_then(|v| v.to_str().ok());
262 assert_eq!(location, Some("/dashboard/prodotti/nuovo"));
263 }
264
265 #[test]
266 fn test_redirect_back_rejects_external_referer() {
267 let mut errors = ValidationError::new();
269 errors.add("name", "required");
270 let response = errors.redirect_back(Some("https://evil.example.com/phishing"));
271 let resp = response.unwrap();
272 assert_eq!(resp.status_code(), 302);
273 let hyper_resp = resp.into_hyper();
274 let location = hyper_resp
275 .headers()
276 .get("Location")
277 .and_then(|v| v.to_str().ok());
278 assert_eq!(location, Some("/"));
279 }
280
281 #[test]
282 fn test_redirect_to_returns_302_to_explicit_url() {
283 let mut errors = ValidationError::new();
284 errors.add("slug", "invalid");
285 let response = errors.redirect_to("/settings?tab=generale");
286 let resp = response.unwrap();
287 assert_eq!(resp.status_code(), 302);
288 let hyper_resp = resp.into_hyper();
289 let location = hyper_resp
290 .headers()
291 .get("Location")
292 .and_then(|v| v.to_str().ok());
293 assert_eq!(location, Some("/settings?tab=generale"));
294 }
295
296 #[test]
297 fn test_with_old_input_chaining() {
298 let mut errors = ValidationError::new();
302 errors.add("email", "required");
303 let data = serde_json::json!({"email": "bad@"});
304 let e = errors.with_old_input(&data);
306 assert!(!e.is_empty());
307 }
308}