Skip to main content

actus_server/
request.rs

1//! [`Request`] — the incoming HTTP request as seen by middleware, and the
2//! body-limiting / parameter-extraction that turns it into a handler's
3//! [`Params`].
4
5use actus_controller::{Params, Verb};
6use actus_reply::WebError;
7use bytes::Bytes;
8use http::HeaderMap;
9use http_body_util::{BodyExt, LengthLimitError, Limited};
10use std::collections::HashMap;
11use std::sync::Arc;
12use std::time::Duration;
13use tokio::sync::Semaphore;
14
15/// An incoming HTTP request, as seen by middleware and used to build the
16/// typed [`Params`] handed to a handler.
17#[derive(Clone, Debug)]
18pub struct Request {
19    /// The HTTP method (`GET`, `POST`, …).
20    pub method: http::Method,
21    /// The request path split on `/` into non-empty segments (no leading
22    /// empty segment); e.g. `/api/users` → `["api", "users"]`.
23    ///
24    /// On a request a host alias routed, this is the **aliased** path — the
25    /// alias's target with the host's labels filled in, then the path the
26    /// client sent (`["tenants", "acme", "orders", "7"]` for `GET /orders/7`
27    /// on `acme.example.com`) — and the `Host` header still names the host.
28    /// The server sets it before routing and before any middleware runs, so
29    /// every reader sees the path the router matched (see
30    /// `RouterBuilder::host_alias`).
31    pub path_parts: Vec<String>,
32    /// Query parameters as a multimap (each name → all its values, in order).
33    /// `application/x-www-form-urlencoded` body fields are appended into the
34    /// same map by [`Request::to_params`].
35    pub query_params: HashMap<String, Vec<String>>,
36    /// The raw request body. Empty for bodyless requests.
37    pub body: Bytes,
38    /// The request headers.
39    pub headers: HeaderMap,
40    /// The rate-limit *class* of the controller this request matched, as
41    /// declared by `#[controller(rate_limit = "…")]` (see
42    /// [`actus_controller::Controller::actus_rate_limit`]). `None` until the
43    /// server has matched a controller (it's set right after routing, before
44    /// the middleware `before` chain runs) and for controllers that declared
45    /// no class.
46    ///
47    /// This is the routing-derived projection a rate-limit [`crate::Middleware`]
48    /// reads to apply per-class policy — the framework supplies the label and
49    /// the `429` plumbing ([`WebError::TooManyRequests`]); the limiter and its
50    /// store stay application code. Unlike the wire-derived fields above, it's
51    /// populated by the framework rather than parsed from the request.
52    pub rate_limit_class: Option<&'static str>,
53}
54
55/// Fold `(name, value)` pairs into a multimap, preserving order for repeated
56/// keys (`a=1&a=2` → `{"a": ["1", "2"]}`).
57fn collect_pairs(pairs: Vec<(String, String)>) -> HashMap<String, Vec<String>> {
58    let mut map: HashMap<String, Vec<String>> = HashMap::new();
59    for (name, value) in pairs {
60        map.entry(name).or_default().push(value);
61    }
62    map
63}
64
65/// Buffer a request body, refusing to hold more than `max_bytes` in memory:
66/// an over-limit body becomes `WebError::PayloadTooLarge` (→ 413) instead of
67/// growing unbounded. Any other read failure (a truncated/aborted body) is a
68/// `400`.
69///
70/// If `budget` is `Some`, the per-request cap is *also* reserved from a
71/// shared semaphore — when the server-wide budget is exhausted, the request
72/// is refused with `WebError::Busy` (→ 503) and a short `Retry-After`. The
73/// reservation is conservative (pre-reserves `max_bytes` even for requests
74/// whose body turns out to be smaller); the alternative — per-chunk byte
75/// accounting — is more code for the same effective ceiling.
76async fn collect_body_capped<B>(
77    body: B,
78    max_bytes: usize,
79    budget: Option<&Arc<Semaphore>>,
80) -> Result<Bytes, WebError>
81where
82    B: hyper::body::Body<Data = Bytes>,
83    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
84{
85    // Reserve from the global byte budget before touching the body. Tokio
86    // `Semaphore::acquire_many` takes a `u32`; we cap accordingly. The
87    // permit stays alive for the duration of this function — when we
88    // return (Ok or Err) it's released.
89    let _permit = match budget {
90        Some(s) => {
91            let n = u32::try_from(max_bytes).unwrap_or(u32::MAX);
92            match s.clone().try_acquire_many_owned(n) {
93                Ok(p) => Some(p),
94                Err(_) => return Err(WebError::Busy(Some(Duration::from_secs(1)))),
95            }
96        }
97        None => None,
98    };
99
100    match Limited::new(body, max_bytes).collect().await {
101        Ok(collected) => Ok(collected.to_bytes()),
102        Err(e) if e.downcast_ref::<LengthLimitError>().is_some() => Err(WebError::PayloadTooLarge),
103        Err(e) => Err(WebError::BadRequest(format!(
104            "could not read request body: {e}"
105        ))),
106    }
107}
108
109impl Request {
110    /// Build the `Request` skeleton from a hyper request *without*
111    /// consuming the body. The body stream is returned alongside; the
112    /// caller passes it back to [`Request::collect_body`] once it has
113    /// resolved the right body-size cap (which may depend on the matched
114    /// route — see `Server::handle_request_inner`).
115    ///
116    /// This is the cheap half of [`Request::from_hyper`]: the per-request
117    /// allocations are just the `path_parts` Vec and `query_params`
118    /// HashMap; no IO happens. Use it when you need to inspect the
119    /// request shape before deciding how (or whether) to read the body.
120    pub fn from_hyper_parts(
121        req: hyper::Request<hyper::body::Incoming>,
122    ) -> (Self, hyper::body::Incoming) {
123        let (parts, body) = req.into_parts();
124
125        let path_parts: Vec<String> = parts
126            .uri
127            .path()
128            .trim_matches('/')
129            .split('/')
130            .map(String::from)
131            .filter(|s| !s.is_empty())
132            .collect();
133
134        let query_params = parts
135            .uri
136            .query()
137            .map(|q| {
138                collect_pairs(
139                    serde_urlencoded::from_str::<Vec<(String, String)>>(q).unwrap_or_default(),
140                )
141            })
142            .unwrap_or_default();
143
144        let skeleton = Self {
145            method: parts.method,
146            path_parts,
147            query_params,
148            // Filled by `collect_body`. Left empty on error so the
149            // skeleton-on-error contract holds.
150            body: Bytes::new(),
151            headers: parts.headers,
152            // Stamped by the server once the controller is matched (the
153            // skeleton predates routing, so it starts `None`).
154            rate_limit_class: None,
155        };
156        (skeleton, body)
157    }
158
159    /// Consume the body stream into this skeleton, capped at
160    /// `max_body_bytes` and (optionally) reserved against the
161    /// framework-wide inflight-bytes budget.
162    ///
163    /// Returns `Err((self, err))` on body failure (413, 400 truncated,
164    /// 503 budget exhausted) — `self` is the same skeleton (with `body`
165    /// still empty) the caller passed in, so the error response still
166    /// has the request headers etc. and flows through the after-chain
167    /// like every other reply.
168    //
169    // `result_large_err`: the `Err` carries the whole `Request` skeleton back
170    // on purpose (see above) — that is the feature, not an accident of size.
171    // Boxing it would change a `pub` signature on a 1.x crate, so the lint is
172    // silenced here rather than "fixed". The lint became default-warn in a
173    // clippy newer than the one this was written against; CI runs stable.
174    #[allow(clippy::result_large_err)]
175    pub async fn collect_body(
176        mut self,
177        body: hyper::body::Incoming,
178        max_body_bytes: usize,
179        inflight_budget: Option<&Arc<Semaphore>>,
180    ) -> Result<Self, (Self, WebError)> {
181        match collect_body_capped(body, max_body_bytes, inflight_budget).await {
182            Ok(body_bytes) => {
183                self.body = body_bytes;
184                Ok(self)
185            }
186            Err(e) => Err((self, e)),
187        }
188    }
189
190    /// Creates a new `Request` from a `hyper::Request`, buffering its body
191    /// (capped at `max_body_bytes` — see `Server::with_max_body_bytes`).
192    ///
193    /// Convenience wrapper around [`Request::from_hyper_parts`] +
194    /// [`Request::collect_body`]: useful for tests and for callers that
195    /// don't need to inspect the request shape before deciding the cap.
196    /// The server uses the two-step form directly so it can resolve the
197    /// cap from the matched controller (Phase 1) or route (Phase 2)
198    /// before reading the body.
199    ///
200    /// Returns `Err((skeleton, err))` when body collection fails (413
201    /// over the cap, 400 truncated, or 503 when the framework-level
202    /// `with_max_inflight_body_bytes` budget is exhausted). The returned
203    /// `Request` skeleton is populated with method / path / query /
204    /// headers — only `body` is empty — so the caller can route the
205    /// error through the normal reply pipeline.
206    //
207    // Same deliberate large `Err` as `collect_body`, same reason to keep it.
208    #[allow(clippy::result_large_err)]
209    pub async fn from_hyper(
210        req: hyper::Request<hyper::body::Incoming>,
211        max_body_bytes: usize,
212        inflight_budget: Option<&Arc<Semaphore>>,
213    ) -> Result<Self, (Self, WebError)> {
214        let (skeleton, body) = Self::from_hyper_parts(req);
215        skeleton
216            .collect_body(body, max_body_bytes, inflight_budget)
217            .await
218    }
219
220    /// Converts the request into a `Params` object for controller methods.
221    ///
222    /// Body handling is content-type discriminated. Each non-empty body
223    /// must declare its type via `Content-Type`; the four outcomes are:
224    ///
225    /// * `application/json` (or `application/...+json`) → parse JSON;
226    ///   `body = Some(value)`, `raw_body = original bytes`. A parse
227    ///   error becomes `WebError::BadRequest`.
228    /// * `application/x-www-form-urlencoded` → parse into fields and
229    ///   *append* them to the query multimap (so a form field with the
230    ///   same name as a query parameter accumulates rather than
231    ///   clobbering); `body = None`, `raw_body = bytes`. A parse error
232    ///   becomes `WebError::BadRequest`.
233    /// * any other content-type (`application/octet-stream`,
234    ///   `application/zip`, …) → `body = None`, `raw_body = bytes`.
235    ///   The handler reads `params.body_bytes()` directly.
236    /// * empty body → `body = None`, `raw_body = Bytes::new()`. The
237    ///   content-type header is ignored when there's nothing to parse.
238    ///
239    /// A non-empty body with **no** `Content-Type` header is rejected:
240    /// "discipline tightening" per design — the framework refuses to
241    /// guess, and the handler never sees a body whose shape it can't
242    /// trust. (This is a behavior change from the previous
243    /// auto-JSON-sniff path; auto-sniff silently dropped binary
244    /// payloads on the floor.)
245    pub fn to_params(&self) -> Result<Params, WebError> {
246        let mut all_params = self.query_params.clone();
247
248        let content_type = self
249            .headers
250            .get(http::header::CONTENT_TYPE)
251            .and_then(|v| v.to_str().ok())
252            .map(|s| {
253                s.split(';')
254                    .next()
255                    .unwrap_or("")
256                    .trim()
257                    .to_ascii_lowercase()
258            });
259
260        let json_body = if self.body.is_empty() {
261            None
262        } else {
263            match content_type.as_deref() {
264                Some(ct) if is_json_content_type(ct) => {
265                    Some(serde_json::from_slice(&self.body).map_err(|e| {
266                        WebError::BadRequest(format!("body is not valid JSON: {e}"))
267                    })?)
268                }
269                Some("application/x-www-form-urlencoded") => {
270                    let form_pairs: Vec<(String, String)> =
271                        serde_urlencoded::from_bytes(&self.body).map_err(|e| {
272                            WebError::BadRequest(format!("body is not valid form-urlencoded: {e}"))
273                        })?;
274                    for (name, value) in form_pairs {
275                        all_params.entry(name).or_default().push(value);
276                    }
277                    None
278                }
279                Some(_) => None,
280                None => {
281                    return Err(WebError::BadRequest(
282                        "non-empty request body requires a Content-Type header".into(),
283                    ));
284                }
285            }
286        };
287
288        // Propagate headers as a lowercase-keyed multimap so controllers can
289        // do case-insensitive lookup *and* see every value when a header
290        // appears more than once (`Forwarded`, `Via`, etc. — common when a
291        // proxy chain prepends one entry per hop). Values that aren't valid
292        // UTF-8 are skipped. Receipt order within a name is preserved.
293        let mut headers: HashMap<String, Vec<String>> = HashMap::new();
294        for (name, value) in self.headers.iter() {
295            if let Ok(v) = value.to_str() {
296                headers
297                    .entry(name.as_str().to_ascii_lowercase())
298                    .or_default()
299                    .push(v.to_string());
300            }
301        }
302
303        Ok(Params::new(
304            method_to_verb(&self.method),
305            all_params,
306            json_body,
307            self.body.clone(),
308            headers,
309        ))
310    }
311}
312
313/// Returns true for the JSON media types we parse: bare
314/// `application/json` and the `application/<subtype>+json`
315/// structured-suffix family (per RFC 6839 §3.1) so callers can use
316/// e.g. `application/vnd.example+json` without surprise.
317fn is_json_content_type(ct: &str) -> bool {
318    ct == "application/json" || ct.ends_with("+json")
319}
320
321/// Whether the request's `Content-Encoding` names a content coding: anything
322/// but `identity`, which RFC 9110 §12.5.3 makes a synonym for "no encoding".
323/// Codings are case-insensitive (§8.4.1), the field is a list that may span
324/// several header lines, and empty list elements are ignored. A value that is
325/// not visible ASCII names nothing Actus can vouch for, so it counts as one.
326pub(crate) fn has_content_coding(headers: &HeaderMap) -> bool {
327    headers
328        .get_all(http::header::CONTENT_ENCODING)
329        .iter()
330        .any(|value| match value.to_str() {
331            Ok(list) => list
332                .split(',')
333                .map(str::trim)
334                .any(|coding| !coding.is_empty() && !coding.eq_ignore_ascii_case("identity")),
335            Err(_) => true,
336        })
337}
338
339/// Whether the request's `Transfer-Encoding` is anything but a lone
340/// `chunked` — the one transfer coding hyper decodes. hyper refuses a request
341/// whose *last* coding is not `chunked`, but passes `gzip, chunked` with only
342/// the chunked framing removed, leaving the body gzip-coded.
343pub(crate) fn has_undecoded_transfer_coding(headers: &HeaderMap) -> bool {
344    let mut codings = Vec::new();
345    for value in headers.get_all(http::header::TRANSFER_ENCODING) {
346        let Ok(list) = value.to_str() else {
347            return true;
348        };
349        codings.extend(list.split(',').map(str::trim).filter(|c| !c.is_empty()));
350    }
351    match codings.as_slice() {
352        [] => false,
353        [only] => !only.eq_ignore_ascii_case("chunked"),
354        _ => true,
355    }
356}
357
358fn method_to_verb(method: &http::Method) -> Verb {
359    match method {
360        m if m == http::Method::GET => Verb::GET,
361        m if m == http::Method::POST => Verb::POST,
362        m if m == http::Method::PUT => Verb::PUT,
363        m if m == http::Method::DELETE => Verb::DELETE,
364        m if m == http::Method::PATCH => Verb::PATCH,
365        m if m == http::Method::HEAD => Verb::HEAD,
366        m if m == http::Method::OPTIONS => Verb::OPTIONS,
367        _ => Verb::GET,
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use http::HeaderValue;
375
376    fn req(content_type: Option<&str>, body: &[u8]) -> Request {
377        let mut headers = HeaderMap::new();
378        if let Some(ct) = content_type {
379            headers.insert(
380                http::header::CONTENT_TYPE,
381                HeaderValue::from_str(ct).unwrap(),
382            );
383        }
384        Request {
385            method: http::Method::POST,
386            path_parts: vec!["whatever".into()],
387            query_params: HashMap::new(),
388            body: Bytes::copy_from_slice(body),
389            headers,
390            rate_limit_class: None,
391        }
392    }
393
394    #[test]
395    fn empty_body_no_content_type_is_ok() {
396        let params = req(None, b"").to_params().expect("empty + no CT is fine");
397        assert!(params.body_bytes().is_empty());
398    }
399
400    #[test]
401    fn json_body_is_parsed() {
402        let params = req(Some("application/json"), br#"{"x":1}"#)
403            .to_params()
404            .expect("valid JSON");
405        // Round-trip through the parsed view.
406        let v = params.json_body().expect("body present");
407        assert_eq!(v["x"], 1);
408        // Raw bytes are also preserved.
409        assert_eq!(params.body_bytes().as_ref(), br#"{"x":1}"#);
410    }
411
412    #[test]
413    fn json_body_with_charset_param_is_parsed() {
414        // `Content-Type: application/json; charset=utf-8` is canonical.
415        // The discriminator splits on `;` and trims, so the parameter is
416        // ignored as long as the media type is json.
417        let params = req(Some("application/json; charset=utf-8"), br#"{"x":1}"#)
418            .to_params()
419            .expect("valid JSON with charset");
420        assert_eq!(params.json_body().unwrap()["x"], 1);
421    }
422
423    #[test]
424    fn vendor_plus_json_subtype_is_parsed() {
425        // RFC 6839 structured suffix: `application/vnd.example+json` is
426        // semantically JSON. We support that family for compatibility
427        // with API conventions that use vendor media types.
428        let params = req(Some("application/vnd.example+json"), br#"{"x":1}"#)
429            .to_params()
430            .expect("valid +json");
431        assert_eq!(params.json_body().unwrap()["x"], 1);
432    }
433
434    #[test]
435    fn malformed_json_is_a_400() {
436        match req(Some("application/json"), b"not json").to_params() {
437            Err(WebError::BadRequest(msg)) => assert!(msg.contains("valid JSON"), "msg = {msg:?}"),
438            Err(other) => panic!("expected BadRequest, got {other:?}"),
439            Ok(_) => panic!("must reject malformed JSON"),
440        }
441    }
442
443    #[test]
444    fn form_body_merges_into_query() {
445        let params = req(
446            Some("application/x-www-form-urlencoded"),
447            b"username=alice&kind=admin",
448        )
449        .to_params()
450        .expect("valid form");
451        // Form fields are merged into the query map; `body` (parsed
452        // JSON view) is `None` because the body wasn't JSON.
453        assert!(params.json_body().is_err());
454        // Raw body still travels for handlers that want it.
455        assert_eq!(params.body_bytes().as_ref(), b"username=alice&kind=admin");
456    }
457
458    #[test]
459    fn binary_body_round_trips() {
460        let zip_bytes = b"PK\x03\x04\x00\x00fake-zip";
461        let params = req(Some("application/zip"), zip_bytes)
462            .to_params()
463            .expect("opaque CT is fine");
464        // Not parsed as JSON — the parsed view is empty.
465        assert!(params.json_body().is_err());
466        // Raw bytes are preserved verbatim for `params.body_bytes()`.
467        assert_eq!(params.body_bytes().as_ref(), zip_bytes);
468    }
469
470    #[test]
471    fn non_empty_body_without_content_type_is_a_400() {
472        match req(None, b"some payload").to_params() {
473            Err(WebError::BadRequest(msg)) => {
474                assert!(msg.contains("Content-Type"), "msg = {msg:?}")
475            }
476            Err(other) => panic!("expected BadRequest, got {other:?}"),
477            Ok(_) => panic!("must reject non-empty body without Content-Type"),
478        }
479    }
480
481    /// Headers holding one `name` line per value.
482    fn lines(name: http::HeaderName, values: &[&[u8]]) -> HeaderMap {
483        let mut headers = HeaderMap::new();
484        for value in values {
485            headers.append(name.clone(), HeaderValue::from_bytes(value).unwrap());
486        }
487        headers
488    }
489
490    #[test]
491    fn identity_and_absence_are_no_content_coding() {
492        let coded =
493            |values: &[&[u8]]| has_content_coding(&lines(http::header::CONTENT_ENCODING, values));
494        assert!(!coded(&[]));
495        assert!(!coded(&[b"identity"]));
496        assert!(!coded(&[b"IDENTITY"]));
497        assert!(!coded(&[b" identity , ,"]));
498        assert!(!coded(&[b""]));
499    }
500
501    #[test]
502    fn any_other_content_coding_counts() {
503        let coded =
504            |values: &[&[u8]]| has_content_coding(&lines(http::header::CONTENT_ENCODING, values));
505        assert!(coded(&[b"gzip"]));
506        assert!(coded(&[b"GZip"]));
507        assert!(coded(&[b"br"]));
508        assert!(coded(&[b"identity, gzip"]));
509        // The field may span several lines; one coding anywhere counts.
510        assert!(coded(&[b"identity", b"zstd"]));
511        // Not visible ASCII: nothing Actus can vouch for.
512        assert!(coded(&[b"gz\xffip"]));
513    }
514
515    #[test]
516    fn a_lone_chunked_is_the_only_transfer_coding_decoded() {
517        let undecoded = |values: &[&[u8]]| {
518            has_undecoded_transfer_coding(&lines(http::header::TRANSFER_ENCODING, values))
519        };
520        assert!(!undecoded(&[]));
521        assert!(!undecoded(&[b"chunked"]));
522        assert!(!undecoded(&[b"Chunked"]));
523        assert!(undecoded(&[b"gzip, chunked"]));
524        // hyper reads only the last line; the field is all of them.
525        assert!(undecoded(&[b"gzip", b"chunked"]));
526        assert!(undecoded(&[b"chunked, chunked"]));
527    }
528
529    #[test]
530    fn collect_pairs_keeps_repeated_keys_in_order() {
531        let m = collect_pairs(vec![
532            ("tag".into(), "a".into()),
533            ("page".into(), "1".into()),
534            ("tag".into(), "b".into()),
535        ]);
536        assert_eq!(m.get("tag").unwrap(), &["a".to_string(), "b".to_string()]);
537        assert_eq!(m.get("page").unwrap(), &["1".to_string()]);
538    }
539
540    #[tokio::test]
541    async fn body_within_limit_round_trips() {
542        let body = http_body_util::Full::new(Bytes::from_static(b"hello"));
543        assert_eq!(
544            collect_body_capped(body, 1024, None).await.unwrap(),
545            Bytes::from_static(b"hello")
546        );
547    }
548
549    #[tokio::test]
550    async fn body_over_limit_is_413() {
551        let body = http_body_util::Full::new(Bytes::from(vec![0u8; 2048]));
552        match collect_body_capped(body, 1024, None).await {
553            Err(WebError::PayloadTooLarge) => {}
554            other => panic!("expected PayloadTooLarge, got {other:?}"),
555        }
556    }
557
558    #[tokio::test]
559    async fn body_refused_when_inflight_budget_exhausted() {
560        // Budget = 100 bytes total. First request reserves its per-request
561        // cap of 80 bytes → ok. Second request also wants 80 bytes → 503.
562        let budget = Arc::new(Semaphore::new(100));
563        // Reserve some so we know the budget is partially used. Hold the
564        // permit to simulate an in-flight body.
565        let _hold = budget.clone().try_acquire_many_owned(80).expect("acquire");
566        let body = http_body_util::Full::new(Bytes::from_static(b"hello"));
567        match collect_body_capped(body, 80, Some(&budget)).await {
568            Err(WebError::Busy(Some(d))) => assert_eq!(d, Duration::from_secs(1)),
569            other => panic!("expected Busy, got {other:?}"),
570        }
571        // After the held permit drops, a request fits again.
572        drop(_hold);
573        let body = http_body_util::Full::new(Bytes::from_static(b"hello"));
574        assert!(collect_body_capped(body, 80, Some(&budget)).await.is_ok());
575    }
576
577    #[test]
578    fn duplicate_request_headers_survive_into_params() {
579        // hyper's HeaderMap preserves duplicates; `to_params` must too. A
580        // proxy chain stamping `Forwarded` twice is the canonical example.
581        let mut headers = HeaderMap::new();
582        headers.append(
583            http::HeaderName::from_static("forwarded"),
584            HeaderValue::from_static("for=1.2.3.4"),
585        );
586        headers.append(
587            http::HeaderName::from_static("forwarded"),
588            HeaderValue::from_static("for=10.0.0.1"),
589        );
590        let request = Request {
591            method: http::Method::GET,
592            path_parts: vec![],
593            query_params: HashMap::new(),
594            body: Bytes::new(),
595            headers,
596            rate_limit_class: None,
597        };
598        let params = request.to_params().expect("ok");
599        assert_eq!(params.header("Forwarded"), Some("for=1.2.3.4"));
600        assert_eq!(
601            params.header_all("Forwarded"),
602            ["for=1.2.3.4", "for=10.0.0.1"]
603        );
604    }
605
606    #[test]
607    fn form_body_appends_to_query_multimap_instead_of_overwriting() {
608        let mut query = HashMap::new();
609        query.insert("tag".to_string(), vec!["from-query".to_string()]);
610        let request = Request {
611            method: http::Method::POST,
612            path_parts: vec!["whatever".into()],
613            query_params: query,
614            body: Bytes::from_static(b"tag=from-body&note=hi"),
615            headers: {
616                let mut h = HeaderMap::new();
617                h.insert(
618                    http::header::CONTENT_TYPE,
619                    HeaderValue::from_static("application/x-www-form-urlencoded"),
620                );
621                h
622            },
623            rate_limit_class: None,
624        };
625        let params = request.to_params().expect("valid form");
626        // Query value survives; the form field with the same name is appended.
627        assert_eq!(params.get_all("tag").unwrap(), ["from-query", "from-body"]);
628        // First-wins for the scalar view.
629        assert_eq!(params.require("tag").unwrap(), "from-query");
630        assert_eq!(params.get_all("note").unwrap(), ["hi"]);
631    }
632}