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::{OwnedSemaphorePermit, 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 request body: as sent, or — when `Server::with_gzip_request_bodies`
37    /// is on and it arrived gzip-coded — the content it encodes, with
38    /// `Content-Encoding` and `Content-Length` gone from [`Request::headers`].
39    /// Empty for bodyless requests.
40    pub body: Bytes,
41    /// The request headers.
42    pub headers: HeaderMap,
43    /// The rate-limit *class* of the controller this request matched, as
44    /// declared by `#[controller(rate_limit = "…")]` (see
45    /// [`actus_controller::Controller::actus_rate_limit`]). `None` until the
46    /// server has matched a controller (it's set right after routing, before
47    /// the middleware `before` chain runs) and for controllers that declared
48    /// no class.
49    ///
50    /// This is the routing-derived projection a rate-limit [`crate::Middleware`]
51    /// reads to apply per-class policy — the framework supplies the label and
52    /// the `429` plumbing ([`WebError::TooManyRequests`]); the limiter and its
53    /// store stay application code. Unlike the wire-derived fields above, it's
54    /// populated by the framework rather than parsed from the request.
55    pub rate_limit_class: Option<&'static str>,
56}
57
58/// Fold `(name, value)` pairs into a multimap, preserving order for repeated
59/// keys (`a=1&a=2` → `{"a": ["1", "2"]}`).
60fn collect_pairs(pairs: Vec<(String, String)>) -> HashMap<String, Vec<String>> {
61    let mut map: HashMap<String, Vec<String>> = HashMap::new();
62    for (name, value) in pairs {
63        map.entry(name).or_default().push(value);
64    }
65    map
66}
67
68/// Buffer a request body, refusing to hold more than `max_bytes` in memory:
69/// an over-limit body becomes `WebError::PayloadTooLarge` (→ 413) instead of
70/// growing unbounded. Any other read failure (a truncated/aborted body) is a
71/// `400`.
72///
73/// If `budget` is `Some`, the per-request cap is *also* reserved from a
74/// shared semaphore (see `reserve`).
75async fn collect_body_capped<B>(
76    body: B,
77    max_bytes: usize,
78    budget: Option<&Arc<Semaphore>>,
79) -> Result<Bytes, WebError>
80where
81    B: hyper::body::Body<Data = Bytes>,
82    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
83{
84    // The permit stays alive for the duration of this function — when we
85    // return (Ok or Err) it's released.
86    let _permit = reserve(budget, max_bytes)?;
87    match Limited::new(body, max_bytes).collect().await {
88        Ok(collected) => Ok(collected.to_bytes()),
89        Err(e) => Err(read_error(e)),
90    }
91}
92
93/// Reserve a body's cap from the server-wide inflight budget, before touching
94/// the body — when the budget is exhausted, the request is refused with
95/// `WebError::Busy` (→ 503) and a short `Retry-After`. The reservation is
96/// conservative (pre-reserves `max_bytes` even for requests whose body turns
97/// out to be smaller); the alternative — per-chunk byte accounting — is more
98/// code for the same effective ceiling. `None` budget, `None` permit.
99fn reserve(
100    budget: Option<&Arc<Semaphore>>,
101    max_bytes: usize,
102) -> Result<Option<OwnedSemaphorePermit>, WebError> {
103    let Some(s) = budget else {
104        return Ok(None);
105    };
106    // Tokio `Semaphore::acquire_many` takes a `u32`; we cap accordingly.
107    let n = u32::try_from(max_bytes).unwrap_or(u32::MAX);
108    match s.clone().try_acquire_many_owned(n) {
109        Ok(p) => Ok(Some(p)),
110        Err(_) => Err(WebError::Busy(Some(Duration::from_secs(1)))),
111    }
112}
113
114/// A failure reading the body itself: past the cap is `413`, anything else (a
115/// truncated or aborted body) a `400`.
116fn read_error(e: Box<dyn std::error::Error + Send + Sync>) -> WebError {
117    if e.downcast_ref::<LengthLimitError>().is_some() {
118        WebError::PayloadTooLarge
119    } else {
120        WebError::BadRequest(format!("could not read request body: {e}"))
121    }
122}
123
124/// Decoding a `gzip`-coded request body (`Server::with_gzip_request_bodies`).
125#[cfg(feature = "compression")]
126mod gzip {
127    use super::*;
128    use std::io::{self, Write};
129
130    /// Buffer a `gzip`-coded body as the content it encodes, holding both
131    /// sides to `max_bytes`: the coded bytes read, and the decoded bytes kept
132    /// — a few KB of gzip can decode to gigabytes. Past either limit the body
133    /// is a `413`; a corrupt or truncated stream is a `400`. Each frame is
134    /// decoded as it arrives (flate2 hands on its output 32 KiB at a time), so
135    /// memory stays at the cap, as for an unencoded body, and the inflight
136    /// budget is reserved the same way.
137    pub(super) async fn collect_capped<B>(
138        body: B,
139        max_bytes: usize,
140        budget: Option<&Arc<Semaphore>>,
141    ) -> Result<Bytes, WebError>
142    where
143        B: hyper::body::Body<Data = Bytes>,
144        B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
145    {
146        let _permit = reserve(budget, max_bytes)?;
147        let mut body = std::pin::pin!(Limited::new(body, max_bytes));
148        // `MultiGzDecoder`: a gzip stream may be several members back to back.
149        let mut decoder = flate2::write::MultiGzDecoder::new(CappedBuf {
150            bytes: Vec::new(),
151            cap: max_bytes,
152        });
153        while let Some(frame) = body.frame().await {
154            if let Some(data) = frame.map_err(read_error)?.data_ref() {
155                decoder.write_all(data).map_err(error)?;
156            }
157        }
158        // Finishing checks the gzip trailer (CRC and length), so a stream cut
159        // short fails here rather than passing as a shorter body.
160        let decoded = decoder.finish().map_err(error)?;
161        Ok(Bytes::from(decoded.bytes))
162    }
163
164    /// The decoded body, refusing to grow past the body cap: the write that
165    /// would cross it fails with [`DecodedTooLarge`].
166    struct CappedBuf {
167        bytes: Vec<u8>,
168        cap: usize,
169    }
170
171    impl Write for CappedBuf {
172        fn write(&mut self, data: &[u8]) -> io::Result<usize> {
173            if self.bytes.len() + data.len() > self.cap {
174                return Err(io::Error::other(DecodedTooLarge));
175            }
176            self.bytes.extend_from_slice(data);
177            Ok(data.len())
178        }
179
180        fn flush(&mut self) -> io::Result<()> {
181            Ok(())
182        }
183    }
184
185    /// A body decoded past the cap — told apart from a corrupt stream.
186    #[derive(Debug)]
187    struct DecodedTooLarge;
188
189    impl std::fmt::Display for DecodedTooLarge {
190        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191            f.write_str("the decoded request body exceeds the body cap")
192        }
193    }
194
195    impl std::error::Error for DecodedTooLarge {}
196
197    /// A decoding failure: past the cap is a `413`; anything else — not gzip,
198    /// corrupt, or cut short — a `400`.
199    fn error(e: io::Error) -> WebError {
200        if e.get_ref()
201            .is_some_and(|inner| inner.is::<DecodedTooLarge>())
202        {
203            WebError::PayloadTooLarge
204        } else {
205            WebError::BadRequest(format!("request body is not valid gzip: {e}"))
206        }
207    }
208
209    #[cfg(test)]
210    mod tests {
211        use super::*;
212        use flate2::Compression;
213        use flate2::write::GzEncoder;
214
215        fn gzip(data: &[u8]) -> Vec<u8> {
216            let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
217            encoder.write_all(data).unwrap();
218            encoder.finish().unwrap()
219        }
220
221        async fn decode(coded: Vec<u8>, max_bytes: usize) -> Result<Bytes, WebError> {
222            collect_capped(
223                http_body_util::Full::new(Bytes::from(coded)),
224                max_bytes,
225                None,
226            )
227            .await
228        }
229
230        #[tokio::test]
231        async fn a_gzip_body_decodes_to_its_content() {
232            let decoded = decode(gzip(b"hello, world"), 1024).await.unwrap();
233            assert_eq!(decoded, Bytes::from_static(b"hello, world"));
234        }
235
236        #[tokio::test]
237        async fn members_back_to_back_decode_in_order() {
238            let mut coded = gzip(b"hello, ");
239            coded.extend(gzip(b"world"));
240            let decoded = decode(coded, 1024).await.unwrap();
241            assert_eq!(decoded, Bytes::from_static(b"hello, world"));
242        }
243
244        #[tokio::test]
245        async fn decoding_past_the_cap_is_413() {
246            // 1 MiB of zeros codes to about 1 KiB: well under the cap coded,
247            // a thousand times over it decoded.
248            let coded = gzip(&vec![0u8; 1 << 20]);
249            assert!(coded.len() < 2048, "coded to {} bytes", coded.len());
250            match decode(coded, 2048).await {
251                Err(WebError::PayloadTooLarge) => {}
252                other => panic!("expected PayloadTooLarge, got {other:?}"),
253            }
254        }
255
256        #[tokio::test]
257        async fn coded_bytes_past_the_cap_are_413() {
258            // Incompressible bytes (xorshift) code to more than they decode
259            // to, so the content fits the cap and only its coding is over.
260            let mut x: u32 = 0x9e37_79b9;
261            let noise: Vec<u8> = (0..1000)
262                .map(|_| {
263                    x ^= x << 13;
264                    x ^= x >> 17;
265                    x ^= x << 5;
266                    (x >> 24) as u8
267                })
268                .collect();
269            let coded = gzip(&noise);
270            let cap = 1010;
271            assert!(
272                noise.len() <= cap && coded.len() > cap,
273                "coded to {}",
274                coded.len()
275            );
276            match decode(coded, cap).await {
277                Err(WebError::PayloadTooLarge) => {}
278                other => panic!("expected PayloadTooLarge, got {other:?}"),
279            }
280        }
281
282        #[tokio::test]
283        async fn not_gzip_corrupt_or_cut_short_is_400() {
284            let coded = gzip(b"hello, world");
285            let mut corrupt = coded.clone();
286            let middle = corrupt.len() / 2;
287            corrupt[middle] ^= 0xff;
288            let cut_short = coded[..coded.len() - 4].to_vec();
289            for (what, body) in [
290                ("not gzip", b"hello, world".to_vec()),
291                ("corrupt", corrupt),
292                ("cut short", cut_short),
293            ] {
294                match decode(body, 1024).await {
295                    Err(WebError::BadRequest(msg)) => {
296                        assert!(msg.contains("gzip"), "{what}: {msg}")
297                    }
298                    other => panic!("{what}: expected BadRequest, got {other:?}"),
299                }
300            }
301        }
302    }
303}
304
305impl Request {
306    /// Build the `Request` skeleton from a hyper request *without*
307    /// consuming the body. The body stream is returned alongside; the
308    /// caller passes it back to [`Request::collect_body`] once it has
309    /// resolved the right body-size cap (which may depend on the matched
310    /// route — see `Server::handle_request_inner`).
311    ///
312    /// This is the cheap half of [`Request::from_hyper`]: the per-request
313    /// allocations are just the `path_parts` Vec and `query_params`
314    /// HashMap; no IO happens. Use it when you need to inspect the
315    /// request shape before deciding how (or whether) to read the body.
316    pub fn from_hyper_parts(
317        req: hyper::Request<hyper::body::Incoming>,
318    ) -> (Self, hyper::body::Incoming) {
319        let (parts, body) = req.into_parts();
320
321        let path_parts: Vec<String> = parts
322            .uri
323            .path()
324            .trim_matches('/')
325            .split('/')
326            .map(String::from)
327            .filter(|s| !s.is_empty())
328            .collect();
329
330        let query_params = parts
331            .uri
332            .query()
333            .map(|q| {
334                collect_pairs(
335                    serde_urlencoded::from_str::<Vec<(String, String)>>(q).unwrap_or_default(),
336                )
337            })
338            .unwrap_or_default();
339
340        let skeleton = Self {
341            method: parts.method,
342            path_parts,
343            query_params,
344            // Filled by `collect_body`. Left empty on error so the
345            // skeleton-on-error contract holds.
346            body: Bytes::new(),
347            headers: parts.headers,
348            // Stamped by the server once the controller is matched (the
349            // skeleton predates routing, so it starts `None`).
350            rate_limit_class: None,
351        };
352        (skeleton, body)
353    }
354
355    /// Consume the body stream into this skeleton, capped at
356    /// `max_body_bytes` and (optionally) reserved against the
357    /// framework-wide inflight-bytes budget.
358    ///
359    /// Returns `Err((self, err))` on body failure (413, 400 truncated,
360    /// 503 budget exhausted) — `self` is the same skeleton (with `body`
361    /// still empty) the caller passed in, so the error response still
362    /// has the request headers etc. and flows through the after-chain
363    /// like every other reply.
364    //
365    // `result_large_err`: the `Err` carries the whole `Request` skeleton back
366    // on purpose (see above) — that is the feature, not an accident of size.
367    // Boxing it would change a `pub` signature on a 1.x crate, so the lint is
368    // silenced here rather than "fixed". The lint became default-warn in a
369    // clippy newer than the one this was written against; CI runs stable.
370    #[allow(clippy::result_large_err)]
371    pub async fn collect_body(
372        mut self,
373        body: hyper::body::Incoming,
374        max_body_bytes: usize,
375        inflight_budget: Option<&Arc<Semaphore>>,
376    ) -> Result<Self, (Self, WebError)> {
377        match collect_body_capped(body, max_body_bytes, inflight_budget).await {
378            Ok(body_bytes) => {
379                self.body = body_bytes;
380                Ok(self)
381            }
382            Err(e) => Err((self, e)),
383        }
384    }
385
386    /// [`Request::collect_body`] for a `gzip`-coded body: buffers the content
387    /// it encodes, with `max_body_bytes` capping the decoded size as well as
388    /// the coded bytes read, then drops `Content-Encoding` and
389    /// `Content-Length` — they described the coded bytes — so middleware and
390    /// the handler see an ordinary body.
391    //
392    // Same deliberate large `Err` as `collect_body`.
393    #[cfg(feature = "compression")]
394    #[allow(clippy::result_large_err)]
395    pub(crate) async fn collect_gzip_body(
396        mut self,
397        body: hyper::body::Incoming,
398        max_body_bytes: usize,
399        inflight_budget: Option<&Arc<Semaphore>>,
400    ) -> Result<Self, (Self, WebError)> {
401        match gzip::collect_capped(body, max_body_bytes, inflight_budget).await {
402            Ok(decoded) => {
403                self.body = decoded;
404                self.headers.remove(http::header::CONTENT_ENCODING);
405                self.headers.remove(http::header::CONTENT_LENGTH);
406                Ok(self)
407            }
408            Err(e) => Err((self, e)),
409        }
410    }
411
412    /// Creates a new `Request` from a `hyper::Request`, buffering its body
413    /// (capped at `max_body_bytes` — see `Server::with_max_body_bytes`).
414    ///
415    /// Convenience wrapper around [`Request::from_hyper_parts`] +
416    /// [`Request::collect_body`]: useful for tests and for callers that
417    /// don't need to inspect the request shape before deciding the cap.
418    /// The server uses the two-step form directly so it can resolve the
419    /// cap from the matched controller (Phase 1) or route (Phase 2)
420    /// before reading the body.
421    ///
422    /// Returns `Err((skeleton, err))` when body collection fails (413
423    /// over the cap, 400 truncated, or 503 when the framework-level
424    /// `with_max_inflight_body_bytes` budget is exhausted). The returned
425    /// `Request` skeleton is populated with method / path / query /
426    /// headers — only `body` is empty — so the caller can route the
427    /// error through the normal reply pipeline.
428    //
429    // Same deliberate large `Err` as `collect_body`, same reason to keep it.
430    #[allow(clippy::result_large_err)]
431    pub async fn from_hyper(
432        req: hyper::Request<hyper::body::Incoming>,
433        max_body_bytes: usize,
434        inflight_budget: Option<&Arc<Semaphore>>,
435    ) -> Result<Self, (Self, WebError)> {
436        let (skeleton, body) = Self::from_hyper_parts(req);
437        skeleton
438            .collect_body(body, max_body_bytes, inflight_budget)
439            .await
440    }
441
442    /// Converts the request into a `Params` object for controller methods.
443    ///
444    /// Body handling is content-type discriminated. Each non-empty body
445    /// must declare its type via `Content-Type`; the four outcomes are:
446    ///
447    /// * `application/json` (or `application/...+json`) → parse JSON;
448    ///   `body = Some(value)`, `raw_body = original bytes`. A parse
449    ///   error becomes `WebError::BadRequest`.
450    /// * `application/x-www-form-urlencoded` → parse into fields and
451    ///   *append* them to the query multimap (so a form field with the
452    ///   same name as a query parameter accumulates rather than
453    ///   clobbering); `body = None`, `raw_body = bytes`. A parse error
454    ///   becomes `WebError::BadRequest`.
455    /// * any other content-type (`application/octet-stream`,
456    ///   `application/zip`, …) → `body = None`, `raw_body = bytes`.
457    ///   The handler reads `params.body_bytes()` directly.
458    /// * empty body → `body = None`, `raw_body = Bytes::new()`. The
459    ///   content-type header is ignored when there's nothing to parse.
460    ///
461    /// A non-empty body with **no** `Content-Type` header is rejected:
462    /// "discipline tightening" per design — the framework refuses to
463    /// guess, and the handler never sees a body whose shape it can't
464    /// trust. (This is a behavior change from the previous
465    /// auto-JSON-sniff path; auto-sniff silently dropped binary
466    /// payloads on the floor.)
467    pub fn to_params(&self) -> Result<Params, WebError> {
468        let mut all_params = self.query_params.clone();
469
470        let content_type = self
471            .headers
472            .get(http::header::CONTENT_TYPE)
473            .and_then(|v| v.to_str().ok())
474            .map(|s| {
475                s.split(';')
476                    .next()
477                    .unwrap_or("")
478                    .trim()
479                    .to_ascii_lowercase()
480            });
481
482        let json_body = if self.body.is_empty() {
483            None
484        } else {
485            match content_type.as_deref() {
486                Some(ct) if is_json_content_type(ct) => {
487                    Some(serde_json::from_slice(&self.body).map_err(|e| {
488                        WebError::BadRequest(format!("body is not valid JSON: {e}"))
489                    })?)
490                }
491                Some("application/x-www-form-urlencoded") => {
492                    let form_pairs: Vec<(String, String)> =
493                        serde_urlencoded::from_bytes(&self.body).map_err(|e| {
494                            WebError::BadRequest(format!("body is not valid form-urlencoded: {e}"))
495                        })?;
496                    for (name, value) in form_pairs {
497                        all_params.entry(name).or_default().push(value);
498                    }
499                    None
500                }
501                Some(_) => None,
502                None => {
503                    return Err(WebError::BadRequest(
504                        "non-empty request body requires a Content-Type header".into(),
505                    ));
506                }
507            }
508        };
509
510        // Propagate headers as a lowercase-keyed multimap so controllers can
511        // do case-insensitive lookup *and* see every value when a header
512        // appears more than once (`Forwarded`, `Via`, etc. — common when a
513        // proxy chain prepends one entry per hop). Values that aren't valid
514        // UTF-8 are skipped. Receipt order within a name is preserved.
515        let mut headers: HashMap<String, Vec<String>> = HashMap::new();
516        for (name, value) in self.headers.iter() {
517            if let Ok(v) = value.to_str() {
518                headers
519                    .entry(name.as_str().to_ascii_lowercase())
520                    .or_default()
521                    .push(v.to_string());
522            }
523        }
524
525        Ok(Params::new(
526            method_to_verb(&self.method),
527            all_params,
528            json_body,
529            self.body.clone(),
530            headers,
531        ))
532    }
533}
534
535/// Returns true for the JSON media types we parse: bare
536/// `application/json` and the `application/<subtype>+json`
537/// structured-suffix family (per RFC 6839 §3.1) so callers can use
538/// e.g. `application/vnd.example+json` without surprise.
539fn is_json_content_type(ct: &str) -> bool {
540    ct == "application/json" || ct.ends_with("+json")
541}
542
543/// The content coding a request body arrives in, per its `Content-Encoding`.
544#[derive(Clone, Copy, Debug, PartialEq, Eq)]
545pub(crate) enum ContentCoding {
546    /// None, or only `identity` — RFC 9110 §12.5.3's synonym for "no
547    /// encoding".
548    Identity,
549    /// Exactly one `gzip`, or `x-gzip`, its alias (RFC 9110 §8.4.1.3).
550    Gzip,
551    /// Anything else: another coding, codings stacked (`gzip, gzip`), or a
552    /// value that is not visible ASCII and so names nothing Actus can vouch
553    /// for.
554    Other,
555}
556
557/// Read the request's content coding. Codings are case-insensitive (RFC 9110
558/// §8.4.1), the field is a list that may span several header lines, and empty
559/// list elements and `identity` are skipped.
560pub(crate) fn content_coding(headers: &HeaderMap) -> ContentCoding {
561    let mut codings = Vec::new();
562    for value in headers.get_all(http::header::CONTENT_ENCODING) {
563        let Ok(list) = value.to_str() else {
564            return ContentCoding::Other;
565        };
566        codings.extend(
567            list.split(',')
568                .map(str::trim)
569                .filter(|c| !c.is_empty() && !c.eq_ignore_ascii_case("identity")),
570        );
571    }
572    match codings.as_slice() {
573        [] => ContentCoding::Identity,
574        [one] if one.eq_ignore_ascii_case("gzip") || one.eq_ignore_ascii_case("x-gzip") => {
575            ContentCoding::Gzip
576        }
577        _ => ContentCoding::Other,
578    }
579}
580
581/// Whether the request's `Transfer-Encoding` is anything but a lone
582/// `chunked` — the one transfer coding hyper decodes. hyper refuses a request
583/// whose *last* coding is not `chunked`, but passes `gzip, chunked` with only
584/// the chunked framing removed, leaving the body gzip-coded.
585pub(crate) fn has_undecoded_transfer_coding(headers: &HeaderMap) -> bool {
586    let mut codings = Vec::new();
587    for value in headers.get_all(http::header::TRANSFER_ENCODING) {
588        let Ok(list) = value.to_str() else {
589            return true;
590        };
591        codings.extend(list.split(',').map(str::trim).filter(|c| !c.is_empty()));
592    }
593    match codings.as_slice() {
594        [] => false,
595        [only] => !only.eq_ignore_ascii_case("chunked"),
596        _ => true,
597    }
598}
599
600fn method_to_verb(method: &http::Method) -> Verb {
601    match method {
602        m if m == http::Method::GET => Verb::GET,
603        m if m == http::Method::POST => Verb::POST,
604        m if m == http::Method::PUT => Verb::PUT,
605        m if m == http::Method::DELETE => Verb::DELETE,
606        m if m == http::Method::PATCH => Verb::PATCH,
607        m if m == http::Method::HEAD => Verb::HEAD,
608        m if m == http::Method::OPTIONS => Verb::OPTIONS,
609        _ => Verb::GET,
610    }
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616    use http::HeaderValue;
617
618    fn req(content_type: Option<&str>, body: &[u8]) -> Request {
619        let mut headers = HeaderMap::new();
620        if let Some(ct) = content_type {
621            headers.insert(
622                http::header::CONTENT_TYPE,
623                HeaderValue::from_str(ct).unwrap(),
624            );
625        }
626        Request {
627            method: http::Method::POST,
628            path_parts: vec!["whatever".into()],
629            query_params: HashMap::new(),
630            body: Bytes::copy_from_slice(body),
631            headers,
632            rate_limit_class: None,
633        }
634    }
635
636    #[test]
637    fn empty_body_no_content_type_is_ok() {
638        let params = req(None, b"").to_params().expect("empty + no CT is fine");
639        assert!(params.body_bytes().is_empty());
640    }
641
642    #[test]
643    fn json_body_is_parsed() {
644        let params = req(Some("application/json"), br#"{"x":1}"#)
645            .to_params()
646            .expect("valid JSON");
647        // Round-trip through the parsed view.
648        let v = params.json_body().expect("body present");
649        assert_eq!(v["x"], 1);
650        // Raw bytes are also preserved.
651        assert_eq!(params.body_bytes().as_ref(), br#"{"x":1}"#);
652    }
653
654    #[test]
655    fn json_body_with_charset_param_is_parsed() {
656        // `Content-Type: application/json; charset=utf-8` is canonical.
657        // The discriminator splits on `;` and trims, so the parameter is
658        // ignored as long as the media type is json.
659        let params = req(Some("application/json; charset=utf-8"), br#"{"x":1}"#)
660            .to_params()
661            .expect("valid JSON with charset");
662        assert_eq!(params.json_body().unwrap()["x"], 1);
663    }
664
665    #[test]
666    fn vendor_plus_json_subtype_is_parsed() {
667        // RFC 6839 structured suffix: `application/vnd.example+json` is
668        // semantically JSON. We support that family for compatibility
669        // with API conventions that use vendor media types.
670        let params = req(Some("application/vnd.example+json"), br#"{"x":1}"#)
671            .to_params()
672            .expect("valid +json");
673        assert_eq!(params.json_body().unwrap()["x"], 1);
674    }
675
676    #[test]
677    fn malformed_json_is_a_400() {
678        match req(Some("application/json"), b"not json").to_params() {
679            Err(WebError::BadRequest(msg)) => assert!(msg.contains("valid JSON"), "msg = {msg:?}"),
680            Err(other) => panic!("expected BadRequest, got {other:?}"),
681            Ok(_) => panic!("must reject malformed JSON"),
682        }
683    }
684
685    #[test]
686    fn form_body_merges_into_query() {
687        let params = req(
688            Some("application/x-www-form-urlencoded"),
689            b"username=alice&kind=admin",
690        )
691        .to_params()
692        .expect("valid form");
693        // Form fields are merged into the query map; `body` (parsed
694        // JSON view) is `None` because the body wasn't JSON.
695        assert!(params.json_body().is_err());
696        // Raw body still travels for handlers that want it.
697        assert_eq!(params.body_bytes().as_ref(), b"username=alice&kind=admin");
698    }
699
700    #[test]
701    fn binary_body_round_trips() {
702        let zip_bytes = b"PK\x03\x04\x00\x00fake-zip";
703        let params = req(Some("application/zip"), zip_bytes)
704            .to_params()
705            .expect("opaque CT is fine");
706        // Not parsed as JSON — the parsed view is empty.
707        assert!(params.json_body().is_err());
708        // Raw bytes are preserved verbatim for `params.body_bytes()`.
709        assert_eq!(params.body_bytes().as_ref(), zip_bytes);
710    }
711
712    #[test]
713    fn non_empty_body_without_content_type_is_a_400() {
714        match req(None, b"some payload").to_params() {
715            Err(WebError::BadRequest(msg)) => {
716                assert!(msg.contains("Content-Type"), "msg = {msg:?}")
717            }
718            Err(other) => panic!("expected BadRequest, got {other:?}"),
719            Ok(_) => panic!("must reject non-empty body without Content-Type"),
720        }
721    }
722
723    /// Headers holding one `name` line per value.
724    fn lines(name: http::HeaderName, values: &[&[u8]]) -> HeaderMap {
725        let mut headers = HeaderMap::new();
726        for value in values {
727            headers.append(name.clone(), HeaderValue::from_bytes(value).unwrap());
728        }
729        headers
730    }
731
732    #[test]
733    fn identity_and_absence_are_no_content_coding() {
734        let coding =
735            |values: &[&[u8]]| content_coding(&lines(http::header::CONTENT_ENCODING, values));
736        assert_eq!(coding(&[]), ContentCoding::Identity);
737        assert_eq!(coding(&[b"identity"]), ContentCoding::Identity);
738        assert_eq!(coding(&[b"IDENTITY"]), ContentCoding::Identity);
739        assert_eq!(coding(&[b" identity , ,"]), ContentCoding::Identity);
740        assert_eq!(coding(&[b""]), ContentCoding::Identity);
741    }
742
743    #[test]
744    fn a_single_gzip_is_gzip() {
745        let coding =
746            |values: &[&[u8]]| content_coding(&lines(http::header::CONTENT_ENCODING, values));
747        assert_eq!(coding(&[b"gzip"]), ContentCoding::Gzip);
748        assert_eq!(coding(&[b"GZip"]), ContentCoding::Gzip);
749        assert_eq!(coding(&[b"x-gzip"]), ContentCoding::Gzip);
750        assert_eq!(coding(&[b"identity, gzip"]), ContentCoding::Gzip);
751        assert_eq!(coding(&[b"identity", b"gzip"]), ContentCoding::Gzip);
752    }
753
754    #[test]
755    fn any_other_content_coding_is_other() {
756        let coding =
757            |values: &[&[u8]]| content_coding(&lines(http::header::CONTENT_ENCODING, values));
758        assert_eq!(coding(&[b"br"]), ContentCoding::Other);
759        // The field may span several lines; one coding anywhere counts.
760        assert_eq!(coding(&[b"identity", b"zstd"]), ContentCoding::Other);
761        // Stacked: decoding one gzip would leave the other in the body.
762        assert_eq!(coding(&[b"gzip, gzip"]), ContentCoding::Other);
763        assert_eq!(coding(&[b"gzip", b"br"]), ContentCoding::Other);
764        // Not visible ASCII: nothing Actus can vouch for.
765        assert_eq!(coding(&[b"gz\xffip"]), ContentCoding::Other);
766    }
767
768    #[test]
769    fn a_lone_chunked_is_the_only_transfer_coding_decoded() {
770        let undecoded = |values: &[&[u8]]| {
771            has_undecoded_transfer_coding(&lines(http::header::TRANSFER_ENCODING, values))
772        };
773        assert!(!undecoded(&[]));
774        assert!(!undecoded(&[b"chunked"]));
775        assert!(!undecoded(&[b"Chunked"]));
776        assert!(undecoded(&[b"gzip, chunked"]));
777        // hyper reads only the last line; the field is all of them.
778        assert!(undecoded(&[b"gzip", b"chunked"]));
779        assert!(undecoded(&[b"chunked, chunked"]));
780    }
781
782    #[test]
783    fn collect_pairs_keeps_repeated_keys_in_order() {
784        let m = collect_pairs(vec![
785            ("tag".into(), "a".into()),
786            ("page".into(), "1".into()),
787            ("tag".into(), "b".into()),
788        ]);
789        assert_eq!(m.get("tag").unwrap(), &["a".to_string(), "b".to_string()]);
790        assert_eq!(m.get("page").unwrap(), &["1".to_string()]);
791    }
792
793    #[tokio::test]
794    async fn body_within_limit_round_trips() {
795        let body = http_body_util::Full::new(Bytes::from_static(b"hello"));
796        assert_eq!(
797            collect_body_capped(body, 1024, None).await.unwrap(),
798            Bytes::from_static(b"hello")
799        );
800    }
801
802    #[tokio::test]
803    async fn body_over_limit_is_413() {
804        let body = http_body_util::Full::new(Bytes::from(vec![0u8; 2048]));
805        match collect_body_capped(body, 1024, None).await {
806            Err(WebError::PayloadTooLarge) => {}
807            other => panic!("expected PayloadTooLarge, got {other:?}"),
808        }
809    }
810
811    #[tokio::test]
812    async fn body_refused_when_inflight_budget_exhausted() {
813        // Budget = 100 bytes total. First request reserves its per-request
814        // cap of 80 bytes → ok. Second request also wants 80 bytes → 503.
815        let budget = Arc::new(Semaphore::new(100));
816        // Reserve some so we know the budget is partially used. Hold the
817        // permit to simulate an in-flight body.
818        let _hold = budget.clone().try_acquire_many_owned(80).expect("acquire");
819        let body = http_body_util::Full::new(Bytes::from_static(b"hello"));
820        match collect_body_capped(body, 80, Some(&budget)).await {
821            Err(WebError::Busy(Some(d))) => assert_eq!(d, Duration::from_secs(1)),
822            other => panic!("expected Busy, got {other:?}"),
823        }
824        // After the held permit drops, a request fits again.
825        drop(_hold);
826        let body = http_body_util::Full::new(Bytes::from_static(b"hello"));
827        assert!(collect_body_capped(body, 80, Some(&budget)).await.is_ok());
828    }
829
830    #[test]
831    fn duplicate_request_headers_survive_into_params() {
832        // hyper's HeaderMap preserves duplicates; `to_params` must too. A
833        // proxy chain stamping `Forwarded` twice is the canonical example.
834        let mut headers = HeaderMap::new();
835        headers.append(
836            http::HeaderName::from_static("forwarded"),
837            HeaderValue::from_static("for=1.2.3.4"),
838        );
839        headers.append(
840            http::HeaderName::from_static("forwarded"),
841            HeaderValue::from_static("for=10.0.0.1"),
842        );
843        let request = Request {
844            method: http::Method::GET,
845            path_parts: vec![],
846            query_params: HashMap::new(),
847            body: Bytes::new(),
848            headers,
849            rate_limit_class: None,
850        };
851        let params = request.to_params().expect("ok");
852        assert_eq!(params.header("Forwarded"), Some("for=1.2.3.4"));
853        assert_eq!(
854            params.header_all("Forwarded"),
855            ["for=1.2.3.4", "for=10.0.0.1"]
856        );
857    }
858
859    #[test]
860    fn form_body_appends_to_query_multimap_instead_of_overwriting() {
861        let mut query = HashMap::new();
862        query.insert("tag".to_string(), vec!["from-query".to_string()]);
863        let request = Request {
864            method: http::Method::POST,
865            path_parts: vec!["whatever".into()],
866            query_params: query,
867            body: Bytes::from_static(b"tag=from-body&note=hi"),
868            headers: {
869                let mut h = HeaderMap::new();
870                h.insert(
871                    http::header::CONTENT_TYPE,
872                    HeaderValue::from_static("application/x-www-form-urlencoded"),
873                );
874                h
875            },
876            rate_limit_class: None,
877        };
878        let params = request.to_params().expect("valid form");
879        // Query value survives; the form field with the same name is appended.
880        assert_eq!(params.get_all("tag").unwrap(), ["from-query", "from-body"]);
881        // First-wins for the scalar view.
882        assert_eq!(params.require("tag").unwrap(), "from-query");
883        assert_eq!(params.get_all("note").unwrap(), ["hi"]);
884    }
885}