Skip to main content

tako_rs_extractors/
query_multi.rs

1//! Multi-value query extractor.
2//!
3//! `serde_urlencoded` (the parser behind [`crate::query::Query`]) treats each
4//! key as scalar, so `?tag=a&tag=b` becomes a single `tag` value (the last one
5//! wins). This module exposes [`QueryMulti<T>`](crate::query_multi::QueryMulti) backed by `serde_html_form`,
6//! which preserves repeated keys and decodes them into `Vec`-shaped fields.
7//!
8//! It also recognises CSV-style multi values inside a single key
9//! (`?tags=a,b,c`) when configured via [`QueryMultiOptions::csv_key`](crate::query_multi::QueryMultiOptions::csv_key).
10
11use std::borrow::Cow;
12
13use http::StatusCode;
14use http::request::Parts;
15use serde::de::DeserializeOwned;
16use tako_rs_core::extractors::FromRequest;
17use tako_rs_core::extractors::FromRequestParts;
18use tako_rs_core::responder::Responder;
19use tako_rs_core::types::Request;
20
21/// Multi-value query extractor — preserves repeated keys and arrays.
22///
23/// # Examples
24///
25/// ```rust,ignore
26/// use serde::Deserialize;
27/// use tako::extractors::query_multi::QueryMulti;
28///
29/// #[derive(Deserialize)]
30/// struct Filter {
31///   tag: Vec<String>,
32///   sort: Option<String>,
33/// }
34///
35/// // ?tag=a&tag=b&sort=date
36/// async fn handler(QueryMulti(f): QueryMulti<Filter>) -> String {
37///   format!("tags={:?}, sort={:?}", f.tag, f.sort)
38/// }
39/// ```
40pub struct QueryMulti<T>(pub T);
41
42/// Options controlling CSV-style expansion before delegating to
43/// `serde_html_form`. CSV keys are expanded so `?tags=a,b,c` becomes
44/// `tags=a&tags=b&tags=c` before parsing.
45#[derive(Debug, Clone, Default)]
46pub struct QueryMultiOptions {
47  csv_keys: Vec<&'static str>,
48}
49
50impl QueryMultiOptions {
51  /// Adds a key whose CSV value should be expanded into repeated entries.
52  pub fn csv_key(mut self, key: &'static str) -> Self {
53    self.csv_keys.push(key);
54    self
55  }
56
57  /// Internal: rewrite the query string by expanding CSV values for the
58  /// configured keys. Skips keys not in `csv_keys` (passes them through).
59  ///
60  /// CSV detection works on the URL-decoded value so `?tags=hello%2Cworld`
61  /// (a percent-encoded comma) splits the same way as `?tags=hello,world` —
62  /// previously only literal commas triggered the split, which was an
63  /// interop bug because the percent-encoded form is what well-behaved
64  /// clients produce.
65  fn rewrite<'a>(&self, query: &'a str) -> Cow<'a, str> {
66    if self.csv_keys.is_empty() {
67      return Cow::Borrowed(query);
68    }
69
70    let mut out = String::with_capacity(query.len());
71    let mut first = true;
72    for pair in query.split('&').filter(|p| !p.is_empty()) {
73      let (key, value) = match pair.find('=') {
74        Some(idx) => (&pair[..idx], &pair[idx + 1..]),
75        None => (pair, ""),
76      };
77      // EXT-9: compare the *decoded* key — a client sending
78      // `?ta%67s=…` (percent-encoded `g`) would otherwise bypass the
79      // CSV-split rewrite because raw `ta%67s` does not equal `tags`.
80      let decoded_key = urlencoding::decode(key).unwrap_or(Cow::Borrowed(key));
81      let decoded_value = urlencoding::decode(value).unwrap_or(Cow::Borrowed(value));
82      if self.csv_keys.contains(&decoded_key.as_ref()) && decoded_value.contains(',') {
83        for part in decoded_value.split(',') {
84          if !first {
85            out.push('&');
86          }
87          first = false;
88          out.push_str(key);
89          out.push('=');
90          // Re-encode the part so the rewritten query string remains a
91          // valid `application/x-www-form-urlencoded` payload that the
92          // downstream parser will decode again identically.
93          out.push_str(&urlencoding::encode(part));
94        }
95      } else {
96        if !first {
97          out.push('&');
98        }
99        first = false;
100        out.push_str(pair);
101      }
102    }
103    Cow::Owned(out)
104  }
105}
106
107/// Error returned by [`QueryMulti`].
108#[derive(Debug)]
109pub enum QueryMultiError {
110  /// Underlying `serde_html_form` deserialization failure.
111  DeserializationError(String),
112}
113
114impl Responder for QueryMultiError {
115  fn into_response(self) -> tako_rs_core::types::Response {
116    match self {
117      Self::DeserializationError(e) => (
118        StatusCode::BAD_REQUEST,
119        format!("failed to deserialize query: {e}"),
120      )
121        .into_response(),
122    }
123  }
124}
125
126fn lookup_options(extensions: &http::Extensions) -> QueryMultiOptions {
127  extensions
128    .get::<QueryMultiOptions>()
129    .cloned()
130    .unwrap_or_default()
131}
132
133fn parse<T: DeserializeOwned>(query: &str, opts: &QueryMultiOptions) -> Result<T, QueryMultiError> {
134  let rewritten = opts.rewrite(query);
135  serde_html_form::from_str::<T>(rewritten.as_ref())
136    .map_err(|e| QueryMultiError::DeserializationError(e.to_string()))
137}
138
139impl<'a, T> FromRequest<'a> for QueryMulti<T>
140where
141  T: DeserializeOwned + Send + 'a,
142{
143  type Error = QueryMultiError;
144
145  fn from_request(
146    req: &'a mut Request,
147  ) -> impl core::future::Future<Output = core::result::Result<Self, Self::Error>> + Send + 'a {
148    let opts = lookup_options(req.extensions());
149    let q = req.uri().query().unwrap_or("").to_string();
150    futures_util::future::ready(parse::<T>(&q, &opts).map(QueryMulti))
151  }
152}
153
154impl<'a, T> FromRequestParts<'a> for QueryMulti<T>
155where
156  T: DeserializeOwned + Send + 'a,
157{
158  type Error = QueryMultiError;
159
160  fn from_request_parts(
161    parts: &'a mut Parts,
162  ) -> impl core::future::Future<Output = core::result::Result<Self, Self::Error>> + Send + 'a {
163    let opts = lookup_options(&parts.extensions);
164    let q = parts.uri.query().unwrap_or("").to_string();
165    futures_util::future::ready(parse::<T>(&q, &opts).map(QueryMulti))
166  }
167}