fastapi_http/query.rs
1//! Query string parsing utilities.
2//!
3//! This module provides zero-copy query string parsing that handles:
4//! - Key-value pair extraction
5//! - Multi-value parameters (same key appearing multiple times)
6//! - Percent-decoding
7//! - Edge cases (empty values, missing values)
8//!
9//! # Example
10//!
11//! ```
12//! use fastapi_http::QueryString;
13//!
14//! let qs = QueryString::parse("a=1&b=2&a=3");
15//!
16//! // Single value access
17//! assert_eq!(qs.get("a"), Some("1"));
18//! assert_eq!(qs.get("b"), Some("2"));
19//!
20//! // Multi-value access
21//! let a_values: Vec<_> = qs.get_all("a").collect();
22//! assert_eq!(a_values, vec!["1", "3"]);
23//! ```
24
25use std::borrow::Cow;
26
27/// Maximum number of query parameters to parse.
28///
29/// This limit prevents algorithmic complexity DoS attacks where an attacker
30/// sends a query string with thousands of parameters. Parameters beyond this
31/// limit are silently ignored.
32pub const MAX_QUERY_PARAMS: usize = 256;
33
34/// A parsed query string with efficient access to parameters.
35///
36/// Query strings are parsed lazily - the input is stored and parsed
37/// on each access. For repeated access patterns, consider using
38/// `to_pairs()` to materialize the results.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct QueryString<'a> {
41 raw: &'a str,
42}
43
44impl<'a> QueryString<'a> {
45 /// Parse a query string (without the leading `?`).
46 ///
47 /// # Example
48 ///
49 /// ```
50 /// use fastapi_http::QueryString;
51 ///
52 /// let qs = QueryString::parse("name=alice&age=30");
53 /// assert_eq!(qs.get("name"), Some("alice"));
54 /// ```
55 #[must_use]
56 pub fn parse(raw: &'a str) -> Self {
57 Self { raw }
58 }
59
60 /// Returns true if the query string is empty.
61 #[must_use]
62 pub fn is_empty(&self) -> bool {
63 self.raw.is_empty()
64 }
65
66 /// Returns the raw query string.
67 #[must_use]
68 pub fn raw(&self) -> &'a str {
69 self.raw
70 }
71
72 /// Get the first value for a key.
73 ///
74 /// Returns `None` if the key doesn't exist.
75 /// Returns the raw (percent-encoded) value. Use `get_decoded` for decoded values.
76 ///
77 /// # Example
78 ///
79 /// ```
80 /// use fastapi_http::QueryString;
81 ///
82 /// let qs = QueryString::parse("name=alice&name=bob");
83 /// assert_eq!(qs.get("name"), Some("alice")); // First value
84 /// assert_eq!(qs.get("missing"), None);
85 /// ```
86 #[must_use]
87 pub fn get(&self, key: &str) -> Option<&'a str> {
88 self.pairs().find(|(k, _)| *k == key).map(|(_, v)| v)
89 }
90
91 /// Get all values for a key.
92 ///
93 /// Returns an iterator over all values for the given key.
94 ///
95 /// # Example
96 ///
97 /// ```
98 /// use fastapi_http::QueryString;
99 ///
100 /// let qs = QueryString::parse("color=red&color=blue&color=green");
101 /// let colors: Vec<_> = qs.get_all("color").collect();
102 /// assert_eq!(colors, vec!["red", "blue", "green"]);
103 /// ```
104 pub fn get_all(&self, key: &str) -> impl Iterator<Item = &'a str> {
105 self.pairs().filter(move |(k, _)| *k == key).map(|(_, v)| v)
106 }
107
108 /// Get the first value for a key, percent-decoded.
109 ///
110 /// Returns a `Cow` that is borrowed if no decoding was needed,
111 /// or owned if percent-decoding was performed.
112 ///
113 /// # Example
114 ///
115 /// ```
116 /// use fastapi_http::QueryString;
117 ///
118 /// let qs = QueryString::parse("msg=hello%20world");
119 /// assert_eq!(qs.get_decoded("msg").as_deref(), Some("hello world"));
120 /// ```
121 #[must_use]
122 pub fn get_decoded(&self, key: &str) -> Option<Cow<'a, str>> {
123 self.get(key).map(percent_decode)
124 }
125
126 /// Check if a key exists in the query string.
127 ///
128 /// # Example
129 ///
130 /// ```
131 /// use fastapi_http::QueryString;
132 ///
133 /// let qs = QueryString::parse("flag&name=alice");
134 /// assert!(qs.contains("flag"));
135 /// assert!(qs.contains("name"));
136 /// assert!(!qs.contains("missing"));
137 /// ```
138 #[must_use]
139 pub fn contains(&self, key: &str) -> bool {
140 self.pairs().any(|(k, _)| k == key)
141 }
142
143 /// Returns an iterator over all key-value pairs.
144 ///
145 /// Keys without values (like `?flag`) have empty string values.
146 /// Values are NOT percent-decoded; use `pairs_decoded` for that.
147 ///
148 /// # Example
149 ///
150 /// ```
151 /// use fastapi_http::QueryString;
152 ///
153 /// let qs = QueryString::parse("a=1&b=2&flag");
154 /// let pairs: Vec<_> = qs.pairs().collect();
155 /// assert_eq!(pairs, vec![("a", "1"), ("b", "2"), ("flag", "")]);
156 /// ```
157 pub fn pairs(&self) -> impl Iterator<Item = (&'a str, &'a str)> {
158 self.raw
159 .split('&')
160 .filter(|s| !s.is_empty())
161 .take(MAX_QUERY_PARAMS) // Limit to prevent DoS
162 .map(|pair| {
163 if let Some(eq_pos) = pair.find('=') {
164 (&pair[..eq_pos], &pair[eq_pos + 1..])
165 } else {
166 // Key without value: "flag" -> ("flag", "")
167 (pair, "")
168 }
169 })
170 }
171
172 /// Returns an iterator over all key-value pairs, with values percent-decoded.
173 ///
174 /// # Example
175 ///
176 /// ```
177 /// use fastapi_http::QueryString;
178 ///
179 /// let qs = QueryString::parse("name=hello%20world&id=123");
180 /// let pairs: Vec<_> = qs.pairs_decoded().collect();
181 /// assert_eq!(pairs[0].0, "name");
182 /// assert_eq!(&*pairs[0].1, "hello world");
183 /// ```
184 pub fn pairs_decoded(&self) -> impl Iterator<Item = (&'a str, Cow<'a, str>)> {
185 self.pairs().map(|(k, v)| (k, percent_decode(v)))
186 }
187
188 /// Collect all pairs into a vector.
189 ///
190 /// Useful when you need to iterate multiple times.
191 #[must_use]
192 pub fn to_pairs(&self) -> Vec<(&'a str, &'a str)> {
193 self.pairs().collect()
194 }
195
196 /// Count the number of parameters.
197 #[must_use]
198 pub fn len(&self) -> usize {
199 self.pairs().count()
200 }
201}
202
203impl Default for QueryString<'_> {
204 fn default() -> Self {
205 Self { raw: "" }
206 }
207}
208
209/// Percent-decode a string.
210///
211/// Returns a `Cow::Borrowed` if no decoding was needed (most common case),
212/// or `Cow::Owned` if percent sequences were decoded.
213///
214/// Handles:
215/// - Standard percent-encoding (%XX)
216/// - UTF-8 multi-byte sequences
217/// - Plus sign as space (common in form data)
218///
219/// Invalid sequences are left as-is for robustness.
220///
221/// # Example
222///
223/// ```
224/// use fastapi_http::percent_decode;
225///
226/// // No decoding needed - returns borrowed
227/// let simple = percent_decode("hello");
228/// assert!(matches!(simple, std::borrow::Cow::Borrowed(_)));
229///
230/// // Decoding needed - returns owned
231/// let encoded = percent_decode("hello%20world");
232/// assert_eq!(&*encoded, "hello world");
233///
234/// // Plus as space
235/// let plus = percent_decode("hello+world");
236/// assert_eq!(&*plus, "hello world");
237/// ```
238pub fn percent_decode(s: &str) -> Cow<'_, str> {
239 // Fast path: no encoding
240 if !s.contains('%') && !s.contains('+') {
241 return Cow::Borrowed(s);
242 }
243
244 let mut result = Vec::with_capacity(s.len());
245 let bytes = s.as_bytes();
246 let mut i = 0;
247
248 while i < bytes.len() {
249 match bytes[i] {
250 b'%' if i + 2 < bytes.len() => {
251 // Try to decode hex pair
252 if let (Some(hi), Some(lo)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2])) {
253 result.push(hi << 4 | lo);
254 i += 3;
255 } else {
256 // Invalid hex, keep as-is
257 result.push(b'%');
258 i += 1;
259 }
260 }
261 b'+' => {
262 // Plus as space (application/x-www-form-urlencoded)
263 result.push(b' ');
264 i += 1;
265 }
266 b => {
267 result.push(b);
268 i += 1;
269 }
270 }
271 }
272
273 // SAFETY: We only decode valid UTF-8 percent sequences,
274 // and non-encoded bytes pass through unchanged.
275 // Invalid UTF-8 will be handled by from_utf8_lossy.
276 Cow::Owned(String::from_utf8_lossy(&result).into_owned())
277}
278
279/// Convert a hex digit to its numeric value.
280fn hex_digit(b: u8) -> Option<u8> {
281 match b {
282 b'0'..=b'9' => Some(b - b'0'),
283 b'a'..=b'f' => Some(b - b'a' + 10),
284 b'A'..=b'F' => Some(b - b'A' + 10),
285 _ => None,
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn empty_query_string() {
295 let qs = QueryString::parse("");
296 assert!(qs.is_empty());
297 assert_eq!(qs.len(), 0);
298 assert_eq!(qs.get("any"), None);
299 }
300
301 #[test]
302 fn single_param() {
303 let qs = QueryString::parse("name=alice");
304 assert!(!qs.is_empty());
305 assert_eq!(qs.len(), 1);
306 assert_eq!(qs.get("name"), Some("alice"));
307 assert_eq!(qs.get("other"), None);
308 }
309
310 #[test]
311 fn multiple_params() {
312 let qs = QueryString::parse("a=1&b=2&c=3");
313 assert_eq!(qs.len(), 3);
314 assert_eq!(qs.get("a"), Some("1"));
315 assert_eq!(qs.get("b"), Some("2"));
316 assert_eq!(qs.get("c"), Some("3"));
317 }
318
319 #[test]
320 fn duplicate_keys() {
321 let qs = QueryString::parse("a=1&b=2&a=3");
322
323 // get() returns first value
324 assert_eq!(qs.get("a"), Some("1"));
325
326 // get_all() returns all values
327 let all_a: Vec<_> = qs.get_all("a").collect();
328 assert_eq!(all_a, vec!["1", "3"]);
329 }
330
331 #[test]
332 fn empty_value() {
333 let qs = QueryString::parse("name=&age=30");
334 assert_eq!(qs.get("name"), Some(""));
335 assert_eq!(qs.get("age"), Some("30"));
336 }
337
338 #[test]
339 fn key_without_value() {
340 let qs = QueryString::parse("flag&name=alice");
341 assert!(qs.contains("flag"));
342 assert_eq!(qs.get("flag"), Some(""));
343 assert_eq!(qs.get("name"), Some("alice"));
344 }
345
346 #[test]
347 fn percent_encoded_value() {
348 let qs = QueryString::parse("msg=hello%20world");
349 assert_eq!(qs.get("msg"), Some("hello%20world")); // raw
350 assert_eq!(qs.get_decoded("msg").as_deref(), Some("hello world")); // decoded
351 }
352
353 #[test]
354 fn plus_as_space() {
355 let qs = QueryString::parse("msg=hello+world");
356 assert_eq!(qs.get("msg"), Some("hello+world")); // raw
357 assert_eq!(qs.get_decoded("msg").as_deref(), Some("hello world")); // decoded
358 }
359
360 #[test]
361 fn utf8_encoded() {
362 // "café" encoded: caf%C3%A9
363 let qs = QueryString::parse("word=caf%C3%A9");
364 assert_eq!(qs.get_decoded("word").as_deref(), Some("café"));
365 }
366
367 #[test]
368 fn special_chars_encoded() {
369 // & encoded as %26, = encoded as %3D
370 let qs = QueryString::parse("data=a%26b%3Dc");
371 assert_eq!(qs.get_decoded("data").as_deref(), Some("a&b=c"));
372 }
373
374 #[test]
375 fn pairs_iterator() {
376 let qs = QueryString::parse("a=1&b=2&c=3");
377 let pairs: Vec<_> = qs.pairs().collect();
378 assert_eq!(pairs, vec![("a", "1"), ("b", "2"), ("c", "3")]);
379 }
380
381 #[test]
382 fn pairs_decoded_iterator() {
383 let qs = QueryString::parse("name=hello%20world&id=123");
384 let pairs: Vec<_> = qs.pairs_decoded().collect();
385 assert_eq!(pairs[0].0, "name");
386 assert_eq!(&*pairs[0].1, "hello world");
387 assert_eq!(pairs[1].0, "id");
388 assert_eq!(&*pairs[1].1, "123");
389 }
390
391 #[test]
392 fn to_pairs() {
393 let qs = QueryString::parse("x=1&y=2");
394 let pairs = qs.to_pairs();
395 assert_eq!(pairs, vec![("x", "1"), ("y", "2")]);
396 }
397
398 #[test]
399 fn contains() {
400 let qs = QueryString::parse("a=1&b=2");
401 assert!(qs.contains("a"));
402 assert!(qs.contains("b"));
403 assert!(!qs.contains("c"));
404 }
405
406 #[test]
407 fn raw_accessor() {
408 let qs = QueryString::parse("a=1&b=2");
409 assert_eq!(qs.raw(), "a=1&b=2");
410 }
411
412 #[test]
413 fn trailing_ampersand() {
414 let qs = QueryString::parse("a=1&b=2&");
415 assert_eq!(qs.len(), 2); // Empty segment is filtered
416 assert_eq!(qs.get("a"), Some("1"));
417 assert_eq!(qs.get("b"), Some("2"));
418 }
419
420 #[test]
421 fn leading_ampersand() {
422 let qs = QueryString::parse("&a=1&b=2");
423 assert_eq!(qs.len(), 2);
424 assert_eq!(qs.get("a"), Some("1"));
425 assert_eq!(qs.get("b"), Some("2"));
426 }
427
428 #[test]
429 fn percent_decode_no_encoding() {
430 let s = "hello";
431 let decoded = percent_decode(s);
432 assert!(matches!(decoded, Cow::Borrowed(_)));
433 assert_eq!(&*decoded, "hello");
434 }
435
436 #[test]
437 fn percent_decode_simple() {
438 assert_eq!(&*percent_decode("hello%20world"), "hello world");
439 assert_eq!(&*percent_decode("%2F"), "/");
440 assert_eq!(&*percent_decode("%3D"), "=");
441 }
442
443 #[test]
444 fn percent_decode_invalid_hex() {
445 // Invalid hex should be kept as-is
446 assert_eq!(&*percent_decode("%ZZ"), "%ZZ");
447 assert_eq!(&*percent_decode("%2"), "%2"); // Incomplete
448 }
449
450 #[test]
451 fn percent_decode_mixed() {
452 assert_eq!(&*percent_decode("a%20b%20c"), "a b c");
453 assert_eq!(&*percent_decode("hello+world%21"), "hello world!");
454 }
455
456 #[test]
457 fn hex_digit_values() {
458 assert_eq!(hex_digit(b'0'), Some(0));
459 assert_eq!(hex_digit(b'9'), Some(9));
460 assert_eq!(hex_digit(b'a'), Some(10));
461 assert_eq!(hex_digit(b'f'), Some(15));
462 assert_eq!(hex_digit(b'A'), Some(10));
463 assert_eq!(hex_digit(b'F'), Some(15));
464 assert_eq!(hex_digit(b'g'), None);
465 assert_eq!(hex_digit(b'Z'), None);
466 }
467
468 #[test]
469 fn default_is_empty() {
470 let qs = QueryString::default();
471 assert!(qs.is_empty());
472 assert_eq!(qs.len(), 0);
473 }
474
475 #[test]
476 fn acceptance_criteria_test() {
477 // Test the exact example from acceptance criteria:
478 // Parses ?a=1&b=2&a=3 into multi-value map correctly
479 let qs = QueryString::parse("a=1&b=2&a=3");
480
481 // First value for 'a'
482 assert_eq!(qs.get("a"), Some("1"));
483
484 // All values for 'a'
485 let all_a: Vec<_> = qs.get_all("a").collect();
486 assert_eq!(all_a, vec!["1", "3"]);
487
488 // Value for 'b'
489 assert_eq!(qs.get("b"), Some("2"));
490
491 // Total count
492 assert_eq!(qs.len(), 3);
493 }
494}