Skip to main content

modo/extractor/
query.rs

1use axum::extract::FromRequestParts;
2use http::request::Parts;
3use serde::de::DeserializeOwned;
4
5use crate::sanitize::Sanitize;
6
7/// Axum extractor that deserializes URL query parameters into `T` and then sanitizes it.
8///
9/// `T` must implement both [`serde::de::DeserializeOwned`] and [`crate::sanitize::Sanitize`].
10///
11/// Repeated query keys deserialize into `Vec<…>` fields — for example `?tags=a&tags=b&tags=c`
12/// populates a `tags: Vec<String>` field with three elements. Nested keys
13/// (`?filter[status]=active`) populate nested struct fields, and indexed brackets
14/// (`?items[0][id]=…`) populate `Vec<Struct>` rows. Browsers that percent-encode
15/// brackets (`?filter%5Bstatus%5D=active`, `?items%5B0%5D%5Bid%5D=…`) decode to the
16/// same shape — both forms are accepted.
17///
18/// Because this extractor implements [`FromRequestParts`] rather than `FromRequest`, it
19/// can be combined with body extractors on the same handler. To make `Query` optional
20/// (i.e. `Option<Query<T>>`), axum 0.8 requires an explicit `OptionalFromRequestParts`
21/// impl — this crate does not provide one, so use a type whose fields are `Option<_>`
22/// instead.
23///
24/// # Errors
25///
26/// The [`FromRequestParts::Rejection`] is [`crate::Error`]. A `400 Bad Request` is
27/// returned if the query string cannot be deserialized into `T`. The error renders via its
28/// [`IntoResponse`](axum::response::IntoResponse) impl.
29///
30/// # Example
31///
32/// ```rust,no_run
33/// use modo::extractor::Query;
34/// use modo::sanitize::Sanitize;
35/// use serde::Deserialize;
36///
37/// #[derive(Deserialize)]
38/// struct Filter { status: String, role: String }
39///
40/// #[derive(Deserialize)]
41/// struct SearchParams {
42///     q: String,
43///     page: Option<u32>,
44///     tags: Vec<String>,   // ?tags=web&tags=axum
45///     filter: Filter,      // ?filter[status]=active&filter[role]=admin
46/// }
47///
48/// impl Sanitize for SearchParams {
49///     fn sanitize(&mut self) { self.q = self.q.trim().to_lowercase(); }
50/// }
51///
52/// async fn search(Query(p): Query<SearchParams>) {
53///     // p.filter.status is "active"
54/// }
55/// ```
56pub struct Query<T>(pub T);
57
58impl<S, T> FromRequestParts<S> for Query<T>
59where
60    S: Send + Sync,
61    T: DeserializeOwned + Sanitize,
62{
63    type Rejection = crate::error::Error;
64
65    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
66        let query = parts.uri.query().unwrap_or("");
67        let mut value: T = serde_qs::Config::new()
68            .use_form_encoding(true)
69            .deserialize_str(query)
70            .map_err(|e| crate::error::Error::bad_request(format!("invalid query: {e}")))?;
71        value.sanitize();
72        Ok(Query(value))
73    }
74}