Skip to main content

rullst_core/
validation.rs

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/// Error type returned by [`ValidatedForm`] and [`ValidatedJson`] extractors.
11/// Automatically renders HTMX-friendly HTML error components for HTMX requests,
12/// or standard JSON `422`/`400` responses for REST clients.
13///
14/// HTMX 1.x and 2.x swap only successful responses by default, so an HTMX
15/// request (`HX-Request: true`) receives the fragment with `200 OK` and the
16/// `X-Rullst-Validation-Status` header set to the `400` or `422` a REST client
17/// would receive. htmx swaps it into the request's `hx-target` with its
18/// `hx-swap`; client scripts can read that header to tell a validation failure
19/// from success.
20///
21/// Every message, field name and validator message is HTML-escaped before it is
22/// placed in the HTMX fragment. The built-in extractors never copy the
23/// deserializer's error text (which can echo request input) into
24/// [`ValidationError::ExtractionError`]; they use a fixed message chosen from the
25/// rejection's status and log the detail server-side at `debug` level on the
26/// `rullst::validation` target.
27#[derive(Debug)]
28pub enum ValidationError {
29    /// A deserialization error occurred before validation could run (e.g. malformed JSON body).
30    ExtractionError {
31        /// Human-readable description of the extraction failure.
32        message: String,
33        /// `true` if the request was triggered by HTMX (`HX-Request: true` header present).
34        is_htmx: bool,
35    },
36    /// The payload was extracted successfully but failed `validator::Validate` constraints.
37    ValidationError {
38        /// The set of field-level validation errors returned by the `validator` crate.
39        errors: validator::ValidationErrors,
40        /// `true` if the request was triggered by HTMX (`HX-Request: true` header present).
41        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
61/// Fixed client-facing text for an extraction failure.
62///
63/// Rejection details from `serde` echo request input (for example
64/// ``unknown variant `<payload>` ``), so they are logged server-side and never
65/// returned to the client.
66fn 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
87/// Response header carrying the REST status of an HTMX validation fragment.
88const VALIDATION_STATUS_HEADER: &str = "x-rullst-validation-status";
89
90/// Wraps an HTMX error fragment in a response htmx swaps by default.
91fn 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                    // Render premium visual UI list of validation errors
139                    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/// Extractor for validating form payloads
180#[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/// Extractor for validating JSON payloads
211#[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        // Form success
266        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        // Json success
281        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                // htmx swaps only 2xx/3xx responses, so the fragment is a 200.
338                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("&lt;script&gt;"), "{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("&lt;b onmouseover=x&gt;"), "{body}");
458    }
459}