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