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¬e=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}