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}