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
321fn method_to_verb(method: &http::Method) -> Verb {
322 match method {
323 m if m == http::Method::GET => Verb::GET,
324 m if m == http::Method::POST => Verb::POST,
325 m if m == http::Method::PUT => Verb::PUT,
326 m if m == http::Method::DELETE => Verb::DELETE,
327 m if m == http::Method::PATCH => Verb::PATCH,
328 m if m == http::Method::HEAD => Verb::HEAD,
329 m if m == http::Method::OPTIONS => Verb::OPTIONS,
330 _ => Verb::GET,
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use http::HeaderValue;
338
339 fn req(content_type: Option<&str>, body: &[u8]) -> Request {
340 let mut headers = HeaderMap::new();
341 if let Some(ct) = content_type {
342 headers.insert(
343 http::header::CONTENT_TYPE,
344 HeaderValue::from_str(ct).unwrap(),
345 );
346 }
347 Request {
348 method: http::Method::POST,
349 path_parts: vec!["whatever".into()],
350 query_params: HashMap::new(),
351 body: Bytes::copy_from_slice(body),
352 headers,
353 rate_limit_class: None,
354 }
355 }
356
357 #[test]
358 fn empty_body_no_content_type_is_ok() {
359 let params = req(None, b"").to_params().expect("empty + no CT is fine");
360 assert!(params.body_bytes().is_empty());
361 }
362
363 #[test]
364 fn json_body_is_parsed() {
365 let params = req(Some("application/json"), br#"{"x":1}"#)
366 .to_params()
367 .expect("valid JSON");
368 // Round-trip through the parsed view.
369 let v = params.json_body().expect("body present");
370 assert_eq!(v["x"], 1);
371 // Raw bytes are also preserved.
372 assert_eq!(params.body_bytes().as_ref(), br#"{"x":1}"#);
373 }
374
375 #[test]
376 fn json_body_with_charset_param_is_parsed() {
377 // `Content-Type: application/json; charset=utf-8` is canonical.
378 // The discriminator splits on `;` and trims, so the parameter is
379 // ignored as long as the media type is json.
380 let params = req(Some("application/json; charset=utf-8"), br#"{"x":1}"#)
381 .to_params()
382 .expect("valid JSON with charset");
383 assert_eq!(params.json_body().unwrap()["x"], 1);
384 }
385
386 #[test]
387 fn vendor_plus_json_subtype_is_parsed() {
388 // RFC 6839 structured suffix: `application/vnd.example+json` is
389 // semantically JSON. We support that family for compatibility
390 // with API conventions that use vendor media types.
391 let params = req(Some("application/vnd.example+json"), br#"{"x":1}"#)
392 .to_params()
393 .expect("valid +json");
394 assert_eq!(params.json_body().unwrap()["x"], 1);
395 }
396
397 #[test]
398 fn malformed_json_is_a_400() {
399 match req(Some("application/json"), b"not json").to_params() {
400 Err(WebError::BadRequest(msg)) => assert!(msg.contains("valid JSON"), "msg = {msg:?}"),
401 Err(other) => panic!("expected BadRequest, got {other:?}"),
402 Ok(_) => panic!("must reject malformed JSON"),
403 }
404 }
405
406 #[test]
407 fn form_body_merges_into_query() {
408 let params = req(
409 Some("application/x-www-form-urlencoded"),
410 b"username=alice&kind=admin",
411 )
412 .to_params()
413 .expect("valid form");
414 // Form fields are merged into the query map; `body` (parsed
415 // JSON view) is `None` because the body wasn't JSON.
416 assert!(params.json_body().is_err());
417 // Raw body still travels for handlers that want it.
418 assert_eq!(params.body_bytes().as_ref(), b"username=alice&kind=admin");
419 }
420
421 #[test]
422 fn binary_body_round_trips() {
423 let zip_bytes = b"PK\x03\x04\x00\x00fake-zip";
424 let params = req(Some("application/zip"), zip_bytes)
425 .to_params()
426 .expect("opaque CT is fine");
427 // Not parsed as JSON — the parsed view is empty.
428 assert!(params.json_body().is_err());
429 // Raw bytes are preserved verbatim for `params.body_bytes()`.
430 assert_eq!(params.body_bytes().as_ref(), zip_bytes);
431 }
432
433 #[test]
434 fn non_empty_body_without_content_type_is_a_400() {
435 match req(None, b"some payload").to_params() {
436 Err(WebError::BadRequest(msg)) => {
437 assert!(msg.contains("Content-Type"), "msg = {msg:?}")
438 }
439 Err(other) => panic!("expected BadRequest, got {other:?}"),
440 Ok(_) => panic!("must reject non-empty body without Content-Type"),
441 }
442 }
443
444 #[test]
445 fn collect_pairs_keeps_repeated_keys_in_order() {
446 let m = collect_pairs(vec![
447 ("tag".into(), "a".into()),
448 ("page".into(), "1".into()),
449 ("tag".into(), "b".into()),
450 ]);
451 assert_eq!(m.get("tag").unwrap(), &["a".to_string(), "b".to_string()]);
452 assert_eq!(m.get("page").unwrap(), &["1".to_string()]);
453 }
454
455 #[tokio::test]
456 async fn body_within_limit_round_trips() {
457 let body = http_body_util::Full::new(Bytes::from_static(b"hello"));
458 assert_eq!(
459 collect_body_capped(body, 1024, None).await.unwrap(),
460 Bytes::from_static(b"hello")
461 );
462 }
463
464 #[tokio::test]
465 async fn body_over_limit_is_413() {
466 let body = http_body_util::Full::new(Bytes::from(vec![0u8; 2048]));
467 match collect_body_capped(body, 1024, None).await {
468 Err(WebError::PayloadTooLarge) => {}
469 other => panic!("expected PayloadTooLarge, got {other:?}"),
470 }
471 }
472
473 #[tokio::test]
474 async fn body_refused_when_inflight_budget_exhausted() {
475 // Budget = 100 bytes total. First request reserves its per-request
476 // cap of 80 bytes → ok. Second request also wants 80 bytes → 503.
477 let budget = Arc::new(Semaphore::new(100));
478 // Reserve some so we know the budget is partially used. Hold the
479 // permit to simulate an in-flight body.
480 let _hold = budget.clone().try_acquire_many_owned(80).expect("acquire");
481 let body = http_body_util::Full::new(Bytes::from_static(b"hello"));
482 match collect_body_capped(body, 80, Some(&budget)).await {
483 Err(WebError::Busy(Some(d))) => assert_eq!(d, Duration::from_secs(1)),
484 other => panic!("expected Busy, got {other:?}"),
485 }
486 // After the held permit drops, a request fits again.
487 drop(_hold);
488 let body = http_body_util::Full::new(Bytes::from_static(b"hello"));
489 assert!(collect_body_capped(body, 80, Some(&budget)).await.is_ok());
490 }
491
492 #[test]
493 fn duplicate_request_headers_survive_into_params() {
494 // hyper's HeaderMap preserves duplicates; `to_params` must too. A
495 // proxy chain stamping `Forwarded` twice is the canonical example.
496 let mut headers = HeaderMap::new();
497 headers.append(
498 http::HeaderName::from_static("forwarded"),
499 HeaderValue::from_static("for=1.2.3.4"),
500 );
501 headers.append(
502 http::HeaderName::from_static("forwarded"),
503 HeaderValue::from_static("for=10.0.0.1"),
504 );
505 let request = Request {
506 method: http::Method::GET,
507 path_parts: vec![],
508 query_params: HashMap::new(),
509 body: Bytes::new(),
510 headers,
511 rate_limit_class: None,
512 };
513 let params = request.to_params().expect("ok");
514 assert_eq!(params.header("Forwarded"), Some("for=1.2.3.4"));
515 assert_eq!(
516 params.header_all("Forwarded"),
517 ["for=1.2.3.4", "for=10.0.0.1"]
518 );
519 }
520
521 #[test]
522 fn form_body_appends_to_query_multimap_instead_of_overwriting() {
523 let mut query = HashMap::new();
524 query.insert("tag".to_string(), vec!["from-query".to_string()]);
525 let request = Request {
526 method: http::Method::POST,
527 path_parts: vec!["whatever".into()],
528 query_params: query,
529 body: Bytes::from_static(b"tag=from-body¬e=hi"),
530 headers: {
531 let mut h = HeaderMap::new();
532 h.insert(
533 http::header::CONTENT_TYPE,
534 HeaderValue::from_static("application/x-www-form-urlencoded"),
535 );
536 h
537 },
538 rate_limit_class: None,
539 };
540 let params = request.to_params().expect("valid form");
541 // Query value survives; the form field with the same name is appended.
542 assert_eq!(params.get_all("tag").unwrap(), ["from-query", "from-body"]);
543 // First-wins for the scalar view.
544 assert_eq!(params.require("tag").unwrap(), "from-query");
545 assert_eq!(params.get_all("note").unwrap(), ["hi"]);
546 }
547}