1use axum::{
2 Form, Json,
3 extract::{FromRequest, Request},
4 http::StatusCode,
5 response::{Html, IntoResponse, Response},
6};
7use std::collections::HashMap;
8pub use validator::Validate;
9
10#[derive(Debug)]
28pub enum ValidationError {
29 ExtractionError {
31 message: String,
33 is_htmx: bool,
35 },
36 ValidationError {
38 errors: validator::ValidationErrors,
40 is_htmx: bool,
42 },
43}
44
45impl std::fmt::Display for ValidationError {
46 #[cfg_attr(mutants, mutants::skip)]
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 match self {
49 ValidationError::ExtractionError { message, .. } => {
50 write!(f, "Extraction error: {}", message)
51 }
52 ValidationError::ValidationError { errors, .. } => {
53 write!(f, "Validation error: {:?}", errors)
54 }
55 }
56 }
57}
58
59impl std::error::Error for ValidationError {}
60
61fn extraction_failure_message(status: StatusCode) -> &'static str {
67 match status {
68 StatusCode::UNSUPPORTED_MEDIA_TYPE => "Unsupported request content type.",
69 StatusCode::PAYLOAD_TOO_LARGE => "Request body is too large.",
70 _ => "The submitted data could not be read or has an invalid format.",
71 }
72}
73
74fn extraction_error(status: StatusCode, detail: String, is_htmx: bool) -> ValidationError {
75 tracing::debug!(
76 target: "rullst::validation",
77 status = status.as_u16(),
78 detail = %detail,
79 "request extraction failed"
80 );
81 ValidationError::ExtractionError {
82 message: extraction_failure_message(status).to_string(),
83 is_htmx,
84 }
85}
86
87const VALIDATION_STATUS_HEADER: &str = "x-rullst-validation-status";
89
90fn htmx_fragment(status: StatusCode, html: String) -> Response {
92 let mut response = (StatusCode::OK, Html(html)).into_response();
93 response.headers_mut().insert(
94 axum::http::HeaderName::from_static(VALIDATION_STATUS_HEADER),
95 axum::http::HeaderValue::from(status.as_u16()),
96 );
97 response
98}
99
100fn format_errors(errors: &validator::ValidationErrors) -> HashMap<String, Vec<String>> {
101 let mut map = HashMap::new();
102 for (field, field_errors) in errors.field_errors() {
103 let messages: Vec<String> = field_errors
104 .iter()
105 .map(|fe| {
106 fe.message
107 .as_ref()
108 .map(|m| m.to_string())
109 .unwrap_or_else(|| format!("Invalid value for field '{}'", field))
110 })
111 .collect();
112 map.insert(field.to_string(), messages);
113 }
114 map
115}
116
117impl IntoResponse for ValidationError {
118 fn into_response(self) -> Response {
119 match self {
120 ValidationError::ExtractionError { message, is_htmx } => {
121 if is_htmx {
122 let html_error = format!(
123 r#"<div class="p-4 mb-4 rounded-lg bg-red-950/50 border border-red-500/30 text-red-200 text-sm">
124 <span class="font-semibold text-red-400">Request Error:</span> {}
125 </div>"#,
126 crate::html::escape_str(&message)
127 );
128 htmx_fragment(StatusCode::BAD_REQUEST, html_error)
129 } else {
130 let mut err_map = HashMap::new();
131 err_map.insert("error".to_string(), vec![message]);
132 (StatusCode::BAD_REQUEST, Json(err_map)).into_response()
133 }
134 }
135 ValidationError::ValidationError { errors, is_htmx } => {
136 let formatted = format_errors(&errors);
137 if is_htmx {
138 let mut list_items = String::new();
140 for (field, msgs) in &formatted {
141 for msg in msgs {
142 let _ = std::fmt::Write::write_fmt(
143 &mut list_items,
144 format_args!(
145 r#"<li><span class="font-semibold text-red-300 capitalize">{}</span>: {}</li>"#,
146 crate::html::escape_str(field),
147 crate::html::escape_str(msg)
148 ),
149 );
150 }
151 }
152
153 let html_content = format!(
154 r#"<div class="p-4 mb-4 rounded-lg bg-red-950/50 border border-red-500/30 text-red-200 text-sm animate-pulse-subtle">
155 <div class="flex items-center gap-2 mb-2 font-semibold text-red-400">
156 <svg aria-hidden="true" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
157 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
158 </svg>
159 <span>Validation Failed</span>
160 </div>
161 <ul class="list-disc list-inside space-y-1">
162 {}
163 </ul>
164 </div>"#,
165 list_items
166 );
167
168 htmx_fragment(StatusCode::UNPROCESSABLE_ENTITY, html_content)
169 } else {
170 let mut response_body = HashMap::new();
171 response_body.insert("errors", formatted);
172 (StatusCode::UNPROCESSABLE_ENTITY, Json(response_body)).into_response()
173 }
174 }
175 }
176 }
177}
178
179#[derive(Debug)]
181pub struct ValidatedForm<T>(pub T);
182
183impl<T, S> FromRequest<S> for ValidatedForm<T>
184where
185 T: validator::Validate + serde::de::DeserializeOwned + 'static,
186 S: Send + Sync,
187{
188 type Rejection = ValidationError;
189
190 async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
191 let is_htmx = req
192 .headers()
193 .get("HX-Request")
194 .and_then(|v| v.to_str().ok())
195 .map(|v| v == "true")
196 .unwrap_or(false);
197
198 let Form(value) = Form::<T>::from_request(req, state)
199 .await
200 .map_err(|e| extraction_error(e.status(), e.body_text(), is_htmx))?;
201
202 value
203 .validate()
204 .map_err(|errors| ValidationError::ValidationError { errors, is_htmx })?;
205
206 Ok(ValidatedForm(value))
207 }
208}
209
210#[derive(Debug)]
212pub struct ValidatedJson<T>(pub T);
213
214impl<T, S> FromRequest<S> for ValidatedJson<T>
215where
216 T: validator::Validate + serde::de::DeserializeOwned + 'static,
217 S: Send + Sync,
218{
219 type Rejection = ValidationError;
220
221 #[cfg_attr(mutants, mutants::skip)]
222 async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
223 let is_htmx = req
224 .headers()
225 .get("HX-Request")
226 .and_then(|v| v.to_str().ok())
227 .map(|v| v == "true")
228 .unwrap_or(false);
229
230 let Json(value) = Json::<T>::from_request(req, state)
231 .await
232 .map_err(|e| extraction_error(e.status(), e.body_text(), is_htmx))?;
233
234 value
235 .validate()
236 .map_err(|errors| ValidationError::ValidationError { errors, is_htmx })?;
237
238 Ok(ValidatedJson(value))
239 }
240}
241
242#[cfg(test)]
243#[allow(clippy::unwrap_used, clippy::expect_used)]
244mod tests {
245 use super::*;
246 use axum::http::Request;
247 use serde::Deserialize;
248 use validator::Validate;
249
250 #[derive(Debug, Deserialize, Validate, Clone)]
251 struct TestPayload {
252 #[validate(length(min = 3, message = "Username too short"))]
253 username: String,
254 #[validate(email(message = "Must be a valid email"))]
255 email: String,
256 }
257
258 #[tokio::test]
259 async fn test_validation_success() {
260 let _payload = TestPayload {
261 username: "venelouis".to_string(),
262 email: "vene@rullst.dev".to_string(),
263 };
264
265 let req = Request::builder()
267 .method("POST")
268 .header("content-type", "application/x-www-form-urlencoded")
269 .body(axum::body::Body::from(
270 "username=venelouis&email=vene%40rullst.dev",
271 ))
272 .unwrap();
273
274 let validated = ValidatedForm::<TestPayload>::from_request(req, &())
275 .await
276 .unwrap();
277 assert_eq!(validated.0.username, "venelouis");
278 assert_eq!(validated.0.email, "vene@rullst.dev");
279
280 let req_json = Request::builder()
282 .header("content-type", "application/json")
283 .body(axum::body::Body::from(
284 r#"{"username": "venelouis", "email": "vene@rullst.dev"}"#,
285 ))
286 .unwrap();
287
288 let validated_json = ValidatedJson::<TestPayload>::from_request(req_json, &())
289 .await
290 .unwrap();
291 assert_eq!(validated_json.0.username, "venelouis");
292 }
293
294 #[tokio::test]
295 async fn test_validation_failure_json() {
296 let req = Request::builder()
297 .header("content-type", "application/json")
298 .body(axum::body::Body::from(
299 r#"{"username": "ab", "email": "invalid-email"}"#,
300 ))
301 .unwrap();
302
303 let err = ValidatedJson::<TestPayload>::from_request(req, &())
304 .await
305 .unwrap_err();
306
307 match err {
308 ValidationError::ValidationError { errors, is_htmx } => {
309 assert!(!is_htmx);
310 let formatted = format_errors(&errors);
311 assert!(formatted.contains_key("username"));
312 assert!(formatted.contains_key("email"));
313 assert_eq!(formatted.get("username").unwrap()[0], "Username too short");
314 assert_eq!(formatted.get("email").unwrap()[0], "Must be a valid email");
315 }
316 _ => panic!("Expected ValidationError"),
317 }
318 }
319
320 #[tokio::test]
321 async fn test_validation_failure_htmx() {
322 let req = Request::builder()
323 .method("POST")
324 .header("content-type", "application/x-www-form-urlencoded")
325 .header("HX-Request", "true")
326 .body(axum::body::Body::from("username=ab&email=invalid-email"))
327 .unwrap();
328
329 let err = ValidatedForm::<TestPayload>::from_request(req, &())
330 .await
331 .unwrap_err();
332
333 match err {
334 ValidationError::ValidationError { errors, is_htmx } => {
335 assert!(is_htmx);
336 let response = ValidationError::ValidationError { errors, is_htmx }.into_response();
337 assert_eq!(response.status(), StatusCode::OK);
339 assert_eq!(response.headers()[VALIDATION_STATUS_HEADER], "422");
340
341 let body_bytes = axum::body::to_bytes(response.into_body(), 10000)
342 .await
343 .unwrap();
344 let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
345 assert!(body_str.contains("Validation Failed"));
346 assert!(body_str.contains("username"));
347 assert!(body_str.contains("email"));
348 }
349 _ => panic!("Expected ValidationError"),
350 }
351 }
352
353 #[tokio::test]
354 async fn test_extraction_error_responses() {
355 let err_htmx = ValidationError::ExtractionError {
356 message: "Malformed form data".to_string(),
357 is_htmx: true,
358 };
359 assert!(format!("{}", err_htmx).contains("Malformed form data"));
360
361 let resp_htmx = err_htmx.into_response();
362 assert_eq!(resp_htmx.status(), StatusCode::OK);
363 assert_eq!(resp_htmx.headers()[VALIDATION_STATUS_HEADER], "400");
364
365 let err_json = ValidationError::ExtractionError {
366 message: "Invalid JSON syntax".to_string(),
367 is_htmx: false,
368 };
369 let resp_json = err_json.into_response();
370 assert_eq!(resp_json.status(), StatusCode::BAD_REQUEST);
371 assert!(!resp_json.headers().contains_key(VALIDATION_STATUS_HEADER));
372 }
373
374 #[derive(Debug, Deserialize, Validate)]
375 struct RolePayload {
376 #[allow(dead_code)]
377 role: Role,
378 }
379
380 #[derive(Debug, Deserialize)]
381 #[serde(rename_all = "lowercase")]
382 enum Role {
383 Admin,
384 }
385
386 async fn body_string(response: Response) -> String {
387 let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
388 .await
389 .unwrap();
390 String::from_utf8(bytes.to_vec()).unwrap()
391 }
392
393 #[tokio::test]
394 async fn htmx_extraction_error_does_not_echo_request_input() {
395 let payload = "%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E";
396 let req = Request::builder()
397 .method("POST")
398 .header("content-type", "application/x-www-form-urlencoded")
399 .header("HX-Request", "true")
400 .body(axum::body::Body::from(format!("role={payload}")))
401 .unwrap();
402 let err = ValidatedForm::<RolePayload>::from_request(req, &())
403 .await
404 .unwrap_err();
405 let body = body_string(err.into_response()).await;
406 assert!(!body.contains("<img"), "{body}");
407 assert!(!body.contains("onerror"), "{body}");
408 assert!(body.contains("could not be read"), "{body}");
409
410 let req = Request::builder()
411 .header("content-type", "application/json")
412 .header("HX-Request", "true")
413 .body(axum::body::Body::from(r#"{"role":"<div hx-get=\"/x\">"}"#))
414 .unwrap();
415 let err = ValidatedJson::<RolePayload>::from_request(req, &())
416 .await
417 .unwrap_err();
418 let body = body_string(err.into_response()).await;
419 assert!(!body.contains("hx-get"), "{body}");
420
421 let req = Request::builder()
422 .header("content-type", "text/plain")
423 .body(axum::body::Body::from("{}"))
424 .unwrap();
425 let err = ValidatedJson::<RolePayload>::from_request(req, &())
426 .await
427 .unwrap_err();
428 assert_eq!(
429 err.to_string(),
430 "Extraction error: Unsupported request content type."
431 );
432 }
433
434 #[tokio::test]
435 async fn htmx_fragments_escape_messages_and_fields() {
436 let err = ValidationError::ExtractionError {
437 message: "<script>alert(1)</script>".to_string(),
438 is_htmx: true,
439 };
440 let body = body_string(err.into_response()).await;
441 assert!(!body.contains("<script>"), "{body}");
442 assert!(body.contains("<script>"), "{body}");
443
444 let mut errors = validator::ValidationErrors::new();
445 let mut field_error = validator::ValidationError::new("custom");
446 field_error.message = Some("<b onmouseover=x>bad</b>".into());
447 errors.add("name", field_error);
448 let body = body_string(
449 ValidationError::ValidationError {
450 errors,
451 is_htmx: true,
452 }
453 .into_response(),
454 )
455 .await;
456 assert!(!body.contains("<b onmouseover"), "{body}");
457 assert!(body.contains("<b onmouseover=x>"), "{body}");
458 }
459}