Skip to main content

modo/extractor/
form.rs

1use axum::body::Body;
2use axum::extract::FromRequest;
3use http::{Request, header};
4use serde::de::DeserializeOwned;
5
6use crate::sanitize::Sanitize;
7
8/// Axum extractor that deserializes a URL-encoded form body into `T` and then sanitizes it.
9///
10/// `T` must implement both [`serde::de::DeserializeOwned`] and [`crate::sanitize::Sanitize`].
11///
12/// Repeated form keys, nested structs, and `Vec<Struct>` rows all deserialize via `serde_qs`
13/// form-encoding mode. Flat repeats (`tag=a&tag=b`) populate `Vec<scalar>` fields,
14/// `client[name]=…` populates a nested struct, and indexed brackets (`contacts[0][kind]=…`)
15/// populate `Vec<Struct>` rows. For per-row dynamic forms, the indexed form is required so
16/// the deserializer can group fields into the correct row.
17///
18/// The body is read through [`axum::body::Bytes`], so any
19/// [`axum::extract::DefaultBodyLimit`] (or `RequestBodyLimit` middleware) applied to the
20/// router is honored — oversized bodies short-circuit with `413 Payload Too Large`
21/// before deserialization runs.
22///
23/// # Errors
24///
25/// The [`FromRequest::Rejection`] is [`crate::Error`]. A `400 Bad Request` is returned if
26/// the body is not valid `application/x-www-form-urlencoded` data or cannot be deserialized
27/// into `T`. If the body exceeds the configured limit, the inner `Bytes` extractor
28/// surfaces a `413 Payload Too Large` instead. The error renders via its
29/// [`IntoResponse`](axum::response::IntoResponse) impl.
30///
31/// # Example
32///
33/// ```rust,no_run
34/// use modo::extractor::FormRequest;
35/// use modo::sanitize::Sanitize;
36/// use serde::Deserialize;
37///
38/// #[derive(Deserialize)]
39/// struct Contact { kind: String, value: String, comment: String }
40///
41/// #[derive(Deserialize)]
42/// struct NewClient {
43///     name: String,
44///     work_days: Vec<u8>,        // multi-select checkbox group
45///     contacts: Vec<Contact>,    // contacts[0][kind]=…&contacts[0][value]=…
46/// }
47///
48/// impl Sanitize for NewClient {
49///     fn sanitize(&mut self) { self.name = self.name.trim().to_string(); }
50/// }
51///
52/// async fn create(FormRequest(form): FormRequest<NewClient>) {
53///     // form.contacts has one entry per submitted row
54/// }
55/// ```
56pub struct FormRequest<T>(pub T);
57
58impl<S, T> FromRequest<S> for FormRequest<T>
59where
60    S: Send + Sync,
61    T: DeserializeOwned + Sanitize,
62{
63    type Rejection = crate::error::Error;
64
65    async fn from_request(req: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
66        if !has_form_content_type(&req) {
67            return Err(crate::error::Error::bad_request(
68                "expected `application/x-www-form-urlencoded` content type",
69            ));
70        }
71
72        let bytes = axum::body::Bytes::from_request(req, state)
73            .await
74            .map_err(|e| {
75                crate::error::Error::new(e.status(), format!("failed to read body: {e}"))
76            })?;
77
78        let mut value: T = serde_qs::Config::new()
79            .use_form_encoding(true)
80            .deserialize_bytes(&bytes)
81            .map_err(|e| crate::error::Error::bad_request(format!("invalid form data: {e}")))?;
82        value.sanitize();
83        Ok(FormRequest(value))
84    }
85}
86
87fn has_form_content_type<B>(req: &Request<B>) -> bool {
88    let Some(value) = req.headers().get(header::CONTENT_TYPE) else {
89        return false;
90    };
91    let Ok(text) = value.to_str() else {
92        return false;
93    };
94    let mime_type = text.split(';').next().unwrap_or("").trim();
95    mime_type.eq_ignore_ascii_case("application/x-www-form-urlencoded")
96}