Skip to main content

jerrycan_core/
extract.rs

1//! Request context and extractors (spec §4.1). Everything a handler needs is
2//! visible in its signature; each parameter implements [`FromRequest`].
3
4use crate::dep::DepResolver;
5use crate::error::{Error, Result};
6use crate::response::Json;
7use bytes::Bytes;
8use serde::de::DeserializeOwned;
9use std::future::Future;
10
11/// A live, incrementally-arriving request body: hyper's stream, pre-wrapped in
12/// the route's cumulative `Limited` cap and the per-frame read deadline.
13/// Unsync (hyper's body is not Sync); the lane lives inside one dispatch task.
14pub(crate) type StreamLane =
15    http_body_util::combinators::UnsyncBoxBody<Bytes, Box<dyn std::error::Error + Send + Sync>>;
16
17/// How the request body reaches the context. Buffered routes collect the body
18/// upfront (the v2.0b two-phase read); `.stream_body()` routes hand the live
19/// hyper stream straight through as a [`BodyLane::Stream`].
20pub(crate) enum BodyLane {
21    Buffered(Bytes),
22    /// `None` after a streaming consumer (Multipart, Task 7) took ownership.
23    Stream(Option<StreamLane>),
24}
25
26/// The connection's remote socket address, threaded from the accept loop onto
27/// `parts.extensions` so it survives into the handler. A newtype so the typemap
28/// lookup is unambiguous. `None` for synthetic requests (tasks, some tests).
29#[derive(Clone, Copy, Debug)]
30pub struct ClientAddr(pub std::net::SocketAddr);
31
32/// The mutable view of one in-flight request. Handlers receive extractors,
33/// not this type; middleware and the DI resolver work through it.
34pub struct RequestCtx {
35    pub(crate) parts: http::request::Parts,
36    pub(crate) body: BodyLane,
37    /// Path parameters captured by the router, in route order.
38    pub(crate) params: Vec<(String, String)>,
39    pub(crate) deps: DepResolver,
40    /// True only for a [`TaskContext`](crate::dep::TaskContext): resolution runs
41    /// outside an HTTP request, so HTTP-coupled extractors reject with JC1003.
42    pub(crate) is_task: bool,
43}
44
45impl RequestCtx {
46    /// Buffered-lane constructor: the body is already fully collected. The
47    /// convenience path used by the buffered dispatch route and every test
48    /// helper that hands over pre-read bytes.
49    pub(crate) fn new(parts: http::request::Parts, body: Bytes, deps: DepResolver) -> Self {
50        Self::with_lane(parts, BodyLane::Buffered(body), deps)
51    }
52
53    /// Lane-taking constructor: the streaming dispatch route hands the live
54    /// hyper stream lane straight through without buffering it upfront.
55    pub(crate) fn with_lane(
56        parts: http::request::Parts,
57        body: BodyLane,
58        deps: DepResolver,
59    ) -> Self {
60        Self {
61            parts,
62            body,
63            params: Vec::new(),
64            deps,
65            is_task: false,
66        }
67    }
68
69    /// The complete request body. Buffered lane: a cheap clone. Stream lane:
70    /// drains the stream (the route's `Limited` cap and per-frame deadline are
71    /// inside it) and CACHES the bytes, so repeated extractors keep working.
72    pub(crate) async fn drain_body(&mut self) -> Result<Bytes> {
73        match &mut self.body {
74            BodyLane::Buffered(bytes) => Ok(bytes.clone()),
75            BodyLane::Stream(slot) => {
76                // A `None` slot means a streaming consumer (Multipart, Task 7) took the
77                // lane and left it empty; a later drain on the same request lands here.
78                // This 500 is the intended post-Multipart contract, not dead code.
79                let stream = slot
80                    .take()
81                    .ok_or_else(|| Error::internal("request body was already consumed"))?;
82                use http_body_util::BodyExt;
83                let collected = stream.collect().await.map_err(map_stream_error)?;
84                let bytes = collected.to_bytes();
85                self.body = BodyLane::Buffered(bytes.clone());
86                Ok(bytes)
87            }
88        }
89    }
90
91    pub fn method(&self) -> &http::Method {
92        &self.parts.method
93    }
94    pub fn uri(&self) -> &http::Uri {
95        &self.parts.uri
96    }
97    pub fn headers(&self) -> &http::HeaderMap {
98        &self.parts.headers
99    }
100
101    /// The named path parameter captured by the router, if present. Unlike
102    /// `Path<T>` (which binds the leaf-most param), a guard can address a specific
103    /// mount param by name — e.g. the tenant fk `club_id` under `/clubs/{club_id}`.
104    pub fn param(&self, name: &str) -> Option<&str> {
105        self.params
106            .iter()
107            .find(|(k, _)| k == name)
108            .map(|(_, v)| v.as_str())
109    }
110
111    /// The remote peer's socket address, if the transport provided one. Set by the
112    /// serve loop from `accept()`; absent for task contexts and synthetic requests.
113    /// Rate limiting uses the IP here as its last-resort partition key; treat it as
114    /// the raw TCP peer (a proxy's address behind a load balancer).
115    pub fn peer_addr(&self) -> Option<std::net::SocketAddr> {
116        self.parts.extensions.get::<ClientAddr>().map(|c| c.0)
117    }
118
119    /// Remove a typed extension from the request parts. jerrycan-realtime takes
120    /// hyper's `OnUpgrade` handle this way to run a WebSocket after replying 101.
121    /// Remove-not-get: the handle is single-use and `!Clone`.
122    pub fn take_extension<T: Send + Sync + 'static>(&mut self) -> Option<T> {
123        self.parts.extensions.remove::<T>()
124    }
125}
126
127/// Map a stream-lane read failure onto the stable codes: the route's
128/// cumulative cap → 413, a frame that never arrived → 408 (same code the
129/// buffered read path uses), anything else (client vanished mid-upload) → 400.
130pub(crate) fn map_stream_error(e: Box<dyn std::error::Error + Send + Sync>) -> Error {
131    if e.downcast_ref::<http_body_util::LengthLimitError>()
132        .is_some()
133    {
134        return Error::payload_too_large();
135    }
136    if e.downcast_ref::<crate::serve::RecvTimeout>().is_some() {
137        return Error::new(
138            http::StatusCode::REQUEST_TIMEOUT,
139            "JC0408",
140            "timed out reading the request body",
141        );
142    }
143    Error::bad_request("request body failed mid-read")
144}
145
146/// Types that can be produced from the request. Implemented by all extractors
147/// and by `Dep<T>` (see `dep` module).
148pub trait FromRequest: Sized + Send {
149    fn from_request(ctx: &mut RequestCtx) -> impl Future<Output = Result<Self>> + Send;
150}
151
152/// Typed path parameter: `Path<i64>` binds the LEAF-MOST (last) captured
153/// parameter; use a tuple to address all parameters root→leaf — `Path<(A, B)>` /
154/// `Path<(A, B, C)>` grab two/three `{param}`s in route order. Param types are
155/// the sealed [`PathParam`] set (integers, `String`, `bool`, floats, `char`);
156/// custom newtypes opt in through the [`path_param!`](crate::path_param) macro.
157pub struct Path<T>(pub T);
158
159/// Crate-internal seal for [`PathParam`]. Hidden from docs, but `pub` so the
160/// [`path_param!`](crate::path_param) macro can name it from outside this module
161/// — the trait below stays the real gate, and `path_param!` is its sanctioned door.
162#[doc(hidden)]
163pub mod sealed {
164    pub trait Sealed {}
165}
166
167/// Types extractable from one path segment. The built-in set (integers,
168/// `String`, `bool`, floats, `char`) is sealed; custom param types (id newtypes)
169/// join it through the [`path_param!`](crate::path_param) macro, which is the
170/// only sanctioned way to implement this trait outside the crate.
171pub trait PathParam: sealed::Sealed + Sized + Send {
172    fn parse_param(name: &str, raw: &str) -> Result<Self>;
173}
174
175macro_rules! impl_path_param {
176    ($($t:ty),* $(,)?) => {$(
177        impl sealed::Sealed for $t {}
178        impl PathParam for $t {
179            fn parse_param(name: &str, raw: &str) -> Result<Self> {
180                raw.parse::<$t>().map_err(|e| {
181                    Error::bad_request(format!("invalid path parameter `{name}`: {e}"))
182                })
183            }
184        }
185    )*};
186}
187
188/// Admit a custom newtype as a [`Path`] parameter. The type must implement
189/// [`FromStr`](std::str::FromStr) with a `Display` error; a parse failure maps
190/// to the same `JC0400` invalid-path-parameter error the built-in impls produce.
191///
192/// ```
193/// # use jerrycan_core as jerrycan;
194/// #[derive(Debug)]
195/// struct LeadId(i64);
196/// impl std::str::FromStr for LeadId {
197///     type Err = std::num::ParseIntError;
198///     fn from_str(s: &str) -> Result<Self, Self::Err> { Ok(LeadId(s.parse()?)) }
199/// }
200/// jerrycan::path_param!(LeadId);
201/// ```
202#[macro_export]
203macro_rules! path_param {
204    ($($t:ty),* $(,)?) => {$(
205        impl $crate::extract::sealed::Sealed for $t {}
206        impl $crate::extract::PathParam for $t {
207            fn parse_param(name: &str, raw: &str) -> $crate::Result<Self> {
208                raw.parse::<$t>().map_err(|e| {
209                    $crate::Error::bad_request(format!("invalid path parameter `{name}`: {e}"))
210                })
211            }
212        }
213    )*};
214}
215impl_path_param!(
216    i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, f32, f64, bool, char, String,
217);
218
219impl<T: PathParam> FromRequest for Path<T> {
220    async fn from_request(ctx: &mut RequestCtx) -> Result<Self> {
221        if ctx.is_task {
222            return Err(Error::task_context());
223        }
224        // Binds the leaf-most (last) captured parameter, so a route mounted under
225        // a param-carrying prefix (e.g. `/ws/{ws}` + `/leads/{id}`) addresses its
226        // own `{id}` rather than the mount's `{ws}`. Tuples address all of them.
227        let (name, raw) = ctx
228            .params
229            .last()
230            .ok_or_else(|| Error::internal("route has no path parameters"))?;
231        T::parse_param(name, raw).map(Path)
232    }
233}
234
235impl<A: PathParam, B: PathParam> FromRequest for Path<(A, B)> {
236    async fn from_request(ctx: &mut RequestCtx) -> Result<Self> {
237        if ctx.is_task {
238            return Err(Error::task_context());
239        }
240        let [a, b] = take_params::<2>(ctx)?;
241        Ok(Path((
242            A::parse_param(&a.0, &a.1)?,
243            B::parse_param(&b.0, &b.1)?,
244        )))
245    }
246}
247
248impl<A: PathParam, B: PathParam, C: PathParam> FromRequest for Path<(A, B, C)> {
249    async fn from_request(ctx: &mut RequestCtx) -> Result<Self> {
250        if ctx.is_task {
251            return Err(Error::task_context());
252        }
253        let [a, b, c] = take_params::<3>(ctx)?;
254        Ok(Path((
255            A::parse_param(&a.0, &a.1)?,
256            B::parse_param(&b.0, &b.1)?,
257            C::parse_param(&c.0, &c.1)?,
258        )))
259    }
260}
261
262impl<A: PathParam, B: PathParam, C: PathParam, D: PathParam> FromRequest for Path<(A, B, C, D)> {
263    async fn from_request(ctx: &mut RequestCtx) -> Result<Self> {
264        if ctx.is_task {
265            return Err(Error::task_context());
266        }
267        let [a, b, c, d] = take_params::<4>(ctx)?;
268        Ok(Path((
269            A::parse_param(&a.0, &a.1)?,
270            B::parse_param(&b.0, &b.1)?,
271            C::parse_param(&c.0, &c.1)?,
272            D::parse_param(&d.0, &d.1)?,
273        )))
274    }
275}
276
277impl<A: PathParam, B: PathParam, C: PathParam, D: PathParam, E: PathParam> FromRequest
278    for Path<(A, B, C, D, E)>
279{
280    async fn from_request(ctx: &mut RequestCtx) -> Result<Self> {
281        if ctx.is_task {
282            return Err(Error::task_context());
283        }
284        let [a, b, c, d, e] = take_params::<5>(ctx)?;
285        Ok(Path((
286            A::parse_param(&a.0, &a.1)?,
287            B::parse_param(&b.0, &b.1)?,
288            C::parse_param(&c.0, &c.1)?,
289            D::parse_param(&d.0, &d.1)?,
290            E::parse_param(&e.0, &e.1)?,
291        )))
292    }
293}
294
295impl<A: PathParam, B: PathParam, C: PathParam, D: PathParam, E: PathParam, F: PathParam> FromRequest
296    for Path<(A, B, C, D, E, F)>
297{
298    async fn from_request(ctx: &mut RequestCtx) -> Result<Self> {
299        if ctx.is_task {
300            return Err(Error::task_context());
301        }
302        let [a, b, c, d, e, f] = take_params::<6>(ctx)?;
303        Ok(Path((
304            A::parse_param(&a.0, &a.1)?,
305            B::parse_param(&b.0, &b.1)?,
306            C::parse_param(&c.0, &c.1)?,
307            D::parse_param(&d.0, &d.1)?,
308            E::parse_param(&e.0, &e.1)?,
309            F::parse_param(&f.0, &f.1)?,
310        )))
311    }
312}
313
314/// First N captured params, cloned in route order. Fewer than N is a routing
315/// bug (the route declared fewer `{params}` than the handler expects) — 500.
316fn take_params<const N: usize>(ctx: &RequestCtx) -> Result<[(String, String); N]> {
317    if ctx.params.len() < N {
318        return Err(Error::internal(format!(
319            "route captures {} path parameter(s) but the handler expects {N}",
320            ctx.params.len()
321        )));
322    }
323    Ok(std::array::from_fn(|i| ctx.params[i].clone()))
324}
325
326/// A by-name view of the request's captured path parameters. Where [`Path<T>`]
327/// binds positionally (the leaf-most segment, or a root→leaf tuple), `PathParams`
328/// reads a SPECIFIC mount param BY NAME — the accessor a DI factory needs, since
329/// a factory resolves each argument through [`FromRequest`] and cannot borrow
330/// `&RequestCtx` to call [`RequestCtx::param`]. The membership-verifying tenancy
331/// guard uses it to read the tenant fk `club_id` under `/clubs/{club_id}` even
332/// when a leaf `{id}` follows (issues #78/#79). Rejects a task context (JC1003),
333/// like every other HTTP-coupled extractor.
334pub struct PathParams(Vec<(String, String)>);
335
336impl PathParams {
337    /// The value of the named path parameter captured by the router, or `None`
338    /// if this route captured no param by that name.
339    pub fn get(&self, name: &str) -> Option<&str> {
340        self.0
341            .iter()
342            .find(|(k, _)| k == name)
343            .map(|(_, v)| v.as_str())
344    }
345}
346
347impl FromRequest for PathParams {
348    async fn from_request(ctx: &mut RequestCtx) -> Result<Self> {
349        if ctx.is_task {
350            return Err(Error::task_context());
351        }
352        Ok(PathParams(ctx.params.clone()))
353    }
354}
355
356/// Typed query string: `Query<MyParams>` via serde.
357pub struct Query<T>(pub T);
358
359impl<T: DeserializeOwned + Send> FromRequest for Query<T> {
360    async fn from_request(ctx: &mut RequestCtx) -> Result<Self> {
361        if ctx.is_task {
362            return Err(Error::task_context());
363        }
364        let q = ctx.parts.uri.query().unwrap_or("");
365        serde_urlencoded::from_str::<T>(q)
366            .map(Query)
367            .map_err(|e| Error::bad_request(format!("invalid query string: {e}")))
368    }
369}
370
371impl<T: DeserializeOwned + Send> FromRequest for Json<T> {
372    async fn from_request(ctx: &mut RequestCtx) -> Result<Self> {
373        if ctx.is_task {
374            return Err(Error::task_context());
375        }
376        let body = ctx.drain_body().await?;
377        serde_json::from_slice::<T>(&body)
378            .map(Json)
379            .map_err(|e| Error::unprocessable(format!("invalid JSON body: {e}")))
380    }
381}
382
383/// Read-only access to request headers in a handler signature.
384pub struct Headers(pub(crate) http::HeaderMap);
385
386impl Headers {
387    /// Header value as a &str, or None if absent or non-ASCII.
388    pub fn get(&self, name: &str) -> Option<&str> {
389        self.0.get(name).and_then(|v| v.to_str().ok())
390    }
391}
392
393impl FromRequest for Headers {
394    async fn from_request(ctx: &mut RequestCtx) -> Result<Self> {
395        if ctx.is_task {
396            return Err(Error::task_context());
397        }
398        Ok(Headers(ctx.headers().clone()))
399    }
400}
401
402/// The request body as EXACT bytes — the extractor for webhook signature
403/// verification, where the digest must cover the wire bytes, not a re-serialized
404/// value. Works on buffered routes (cheap clone) and `stream_body()` routes
405/// (drains and caches). See the auth docs for the Stripe/Twilio recipes.
406pub struct RawBody(pub Bytes);
407
408impl FromRequest for RawBody {
409    async fn from_request(ctx: &mut RequestCtx) -> Result<Self> {
410        if ctx.is_task {
411            return Err(Error::task_context());
412        }
413        Ok(RawBody(ctx.drain_body().await?))
414    }
415}
416
417/// Optional extraction: `Some` when the inner extractor succeeds, `None` on
418/// ANY extraction failure. For genuinely optional inputs — the canonical use
419/// is optional auth (`Option<CurrentUser>` on a route that also accepts a
420/// signed URL). Do NOT use it to paper over malformed required input: the
421/// failure reason is discarded by design — reach for `Result<T, Error>` when
422/// the failure must stay observable.
423impl<T: FromRequest> FromRequest for Option<T> {
424    async fn from_request(ctx: &mut RequestCtx) -> Result<Self> {
425        Ok(T::from_request(ctx).await.ok())
426    }
427}
428
429/// Error-PRESERVING optional extraction: `Ok(v)` when the inner extractor
430/// succeeds, `Err(e)` carrying ITS error when it fails — the extraction itself
431/// never fails the request, so the handler decides. For routes that accept
432/// more than one credential and must keep the guard's real status when the
433/// fallback also misses (#109): a private tenant bucket's download takes
434/// `Result<Dep<Tenant>, Error>` and, past the signed-URL branch, propagates
435/// with `let tenant = tenant?;` — a missing session stays 401 and a
436/// non-member's guard failure stays 403, instead of `Option<T>` collapsing
437/// both into a rebound 401.
438impl<T: FromRequest> FromRequest for Result<T, Error> {
439    async fn from_request(ctx: &mut RequestCtx) -> Result<Self> {
440        Ok(T::from_request(ctx).await)
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::dep::DepEnv;
448    use std::sync::Arc;
449
450    fn ctx(uri: &str, body: &str) -> RequestCtx {
451        let req = http::Request::builder()
452            .method(http::Method::GET)
453            .uri(uri)
454            .body(())
455            .unwrap();
456        let (parts, ()) = req.into_parts();
457        RequestCtx::new(
458            parts,
459            Bytes::from(body.to_string()),
460            DepResolver::new(Arc::new(DepEnv::default()), Default::default()),
461        )
462    }
463
464    #[tokio::test]
465    async fn peer_addr_is_none_without_a_socket_and_readable_when_set() {
466        let mut c = ctx("/x", "");
467        assert!(c.peer_addr().is_none());
468        let addr: std::net::SocketAddr = "203.0.113.7:5000".parse().unwrap();
469        c.parts.extensions.insert(crate::extract::ClientAddr(addr));
470        assert_eq!(c.peer_addr(), Some(addr));
471    }
472
473    #[tokio::test]
474    async fn path_extracts_typed_param() {
475        let mut c = ctx("/todos/42", "");
476        c.params.push(("id".into(), "42".into()));
477        let Path(id): Path<i64> = Path::<i64>::from_request(&mut c).await.unwrap();
478        assert_eq!(id, 42);
479    }
480
481    #[test]
482    fn ctx_param_reads_a_named_captured_param() {
483        // WHY: a tenancy/ownership guard must address a SPECIFIC mount param by
484        // name — the tenant fk `club_id` under `/clubs/{club_id}` — not just the
485        // leaf-most param that `Path<T>` binds. `param(name)` is that named read;
486        // it underpins membership verification (issues #78/#79). Absent → None.
487        let mut c = ctx("/x", "");
488        c.params.push(("club_id".into(), "42".into()));
489        c.params.push(("id".into(), "7".into()));
490        assert_eq!(c.param("club_id"), Some("42"));
491        assert_eq!(c.param("id"), Some("7"));
492        assert_eq!(c.param("missing"), None);
493    }
494
495    #[tokio::test]
496    async fn path_params_reads_named_and_rejects_task() {
497        // WHY: a DI factory resolves each arg via `FromRequest` (it cannot borrow
498        // `&RequestCtx`), and `Path<T>` binds only the leaf-most segment — so the
499        // membership-verifying tenancy guard (issues #78/#79) needs a by-NAME read
500        // of a specific mount param (the tenant fk `club_id` under `/clubs/{club_id}`,
501        // even when a leaf `{id}` follows). `PathParams` is that FromRequest accessor.
502        let mut c = ctx("/x", "");
503        c.params.push(("club_id".into(), "42".into()));
504        c.params.push(("id".into(), "7".into()));
505        let p = PathParams::from_request(&mut c).await.unwrap();
506        assert_eq!(p.get("club_id"), Some("42"));
507        assert_eq!(p.get("id"), Some("7"));
508        assert_eq!(p.get("missing"), None);
509
510        // A task context has no request path — reject like every HTTP extractor (JC1003).
511        let mut task = ctx("/x", "");
512        task.is_task = true;
513        let err = PathParams::from_request(&mut task).await.err().unwrap();
514        assert_eq!(err.code(), "JC1003");
515    }
516
517    #[tokio::test]
518    async fn path_with_wrong_type_is_400() {
519        let mut c = ctx("/todos/abc", "");
520        c.params.push(("id".into(), "abc".into()));
521        let err = Path::<i64>::from_request(&mut c).await.err().unwrap();
522        assert_eq!(err.code(), "JC0400");
523    }
524
525    #[tokio::test]
526    async fn path_missing_param_is_500() {
527        // No params captured by the router → internal error (route declared a param
528        // the trie never filled), surfaced as JC0500.
529        let mut c = ctx("/todos", "");
530        let err = Path::<i64>::from_request(&mut c).await.err().unwrap();
531        assert_eq!(err.code(), "JC0500");
532    }
533
534    #[tokio::test]
535    async fn query_deserializes_struct() {
536        #[derive(serde::Deserialize)]
537        struct Page {
538            limit: u32,
539            offset: u32,
540        }
541        let mut c = ctx("/todos?limit=10&offset=20", "");
542        let Query(p): Query<Page> = Query::from_request(&mut c).await.unwrap();
543        assert_eq!((p.limit, p.offset), (10, 20));
544    }
545
546    #[tokio::test]
547    async fn option_extractor_yields_none_on_failure_and_some_on_success() {
548        // WHY: a private bucket's GET must accept EITHER a session OR a signed
549        // URL — the handler needs optional extraction instead of a hard 401
550        // from the extractor. Option<T> is None on ANY extraction failure.
551        #[derive(serde::Deserialize)]
552        struct P {
553            n: i64,
554        }
555        async fn probe(q: Option<Query<P>>) -> Result<Json<Option<i64>>> {
556            Ok(Json(q.map(|Query(p)| p.n)))
557        }
558        let t = crate::App::new()
559            .route("/probe", crate::get(probe))
560            .into_test();
561        assert_eq!(t.get("/probe?n=7").await.text(), "7");
562        // Missing/malformed query → None, not a 400.
563        assert_eq!(t.get("/probe").await.text(), "null");
564        assert_eq!(t.get("/probe?n=not-a-number").await.text(), "null");
565    }
566
567    #[tokio::test]
568    async fn result_extractor_preserves_the_inner_error() {
569        // WHY (#109): Option<T> discards WHY the inner extractor failed, so a
570        // route that accepts a session OR a signed URL collapses a guard's 403
571        // into a blanket 401. Result<T, Error> keeps the inner error for the
572        // handler to propagate (`let v = v?;`) — extraction itself never fails
573        // the request, so the fallback credential path still runs first.
574        struct Gate;
575        impl FromRequest for Gate {
576            async fn from_request(_ctx: &mut RequestCtx) -> Result<Self> {
577                Err(Error::forbidden())
578            }
579        }
580        async fn probe(gate: Result<Gate, Error>) -> Result<Json<&'static str>> {
581            let _gate = gate?;
582            Ok(Json("open"))
583        }
584        let t = crate::App::new()
585            .route("/probe", crate::get(probe))
586            .into_test();
587        let res = t.get("/probe").await;
588        assert_eq!(
589            res.status().as_u16(),
590            403,
591            "the inner error's status survives; body: {}",
592            res.text()
593        );
594        assert!(res.text().contains("JC0403"), "body: {}", res.text());
595        // The success side passes the value through as Ok(Ok(v)).
596        let mut c = ctx("/x", "");
597        let ok = <Result<Headers, Error> as FromRequest>::from_request(&mut c)
598            .await
599            .expect("outer extraction never fails");
600        assert!(ok.is_ok(), "success is Ok(Ok(_))");
601    }
602
603    #[tokio::test]
604    async fn single_path_param_binds_the_leaf_segment() {
605        use crate::prelude::*;
606        async fn show(Path(id): Path<i64>) -> Result<Json<i64>> {
607            Ok(Json(id))
608        }
609        let t = App::new()
610            .mount(
611                "/ws/{ws}",
612                Module::new("leads").route("/leads/{id}", get(show)),
613            )
614            .into_test();
615        assert_eq!(
616            t.get("/ws/7/leads/42").await.json::<i64>(),
617            42,
618            "leaf param, not mount param"
619        );
620    }
621
622    #[tokio::test]
623    async fn tuples_still_read_root_to_leaf() {
624        use crate::prelude::*;
625        async fn pair(Path((ws, id)): Path<(i64, i64)>) -> Result<Json<(i64, i64)>> {
626            Ok(Json((ws, id)))
627        }
628        let t = App::new()
629            .mount(
630                "/ws/{ws}",
631                Module::new("leads").route("/leads/{id}", get(pair)),
632            )
633            .into_test();
634        assert_eq!(t.get("/ws/7/leads/42").await.json::<(i64, i64)>(), (7, 42));
635    }
636
637    /// Issue #283: a route nested THREE param-levels deep must bind a 3-tuple that
638    /// reads root→leaf. This is the runtime counterpart of the generator fix: a
639    /// handler under `/accounts/{account_id}/contacts/{contact_id}/notes/{id}` binds
640    /// `Path<(i64,i64,i64)>`, and `take_params::<3>` reads (account_id, contact_id,
641    /// id) POSITIONALLY. Before the generator fix the handler bound only the local
642    /// 2-tuple `(contact_id, id)`, which core resolves to (account_id, contact_id) —
643    /// the leaf id `999` was never read and the route was dead.
644    #[tokio::test]
645    async fn deep_nested_triple_reads_root_to_leaf() {
646        use crate::prelude::*;
647        async fn triple(
648            Path((account_id, contact_id, id)): Path<(i64, i64, i64)>,
649        ) -> Result<Json<(i64, i64, i64)>> {
650            Ok(Json((account_id, contact_id, id)))
651        }
652        let t = App::new()
653            .mount(
654                "/accounts/{account_id}",
655                Module::new("contacts").mount(
656                    "/contacts/{contact_id}",
657                    Module::new("notes").route("/notes/{id}", get(triple)),
658                ),
659            )
660            .into_test();
661        assert_eq!(
662            t.get("/accounts/111/contacts/222/notes/999")
663                .await
664                .json::<(i64, i64, i64)>(),
665            (111, 222, 999),
666            "the 3-tuple must read account_id=111, contact_id=222, id=999 positionally"
667        );
668    }
669
670    #[tokio::test]
671    async fn path_param_macro_admits_custom_newtypes() {
672        use crate::prelude::*;
673        #[derive(Debug)]
674        struct LeadId(i64);
675        impl std::str::FromStr for LeadId {
676            type Err = std::num::ParseIntError;
677            fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
678                Ok(LeadId(s.parse()?))
679            }
680        }
681        crate::path_param!(LeadId);
682        async fn show(Path(id): Path<LeadId>) -> Result<Json<i64>> {
683            Ok(Json(id.0))
684        }
685        let t = App::new().route("/leads/{id}", get(show)).into_test();
686        assert_eq!(t.get("/leads/42").await.json::<i64>(), 42);
687    }
688
689    #[tokio::test]
690    async fn raw_body_yields_exact_bytes_and_coexists_with_headers() {
691        use crate::prelude::*;
692        async fn verify(headers: Headers, body: RawBody) -> Result<Json<(usize, bool)>> {
693            let signed = headers.get("x-signature").is_some();
694            Ok(Json((body.0.len(), signed)))
695        }
696        let t = App::new().route("/hook", post(verify)).into_test();
697        let res = t
698            .post_bytes_with("/hook", b"{\"raw\": 1}", &[("x-signature", "abc")])
699            .await;
700        assert_eq!(res.status().as_u16(), 200);
701        assert_eq!(res.json::<(usize, bool)>(), (10, true));
702    }
703
704    #[tokio::test]
705    async fn raw_body_drains_a_stream_route_transparently() {
706        use crate::prelude::*;
707        async fn len(body: RawBody) -> Result<Json<usize>> {
708            Ok(Json(body.0.len()))
709        }
710        let t = App::new().route("/up", post(len).stream_body()).into_test();
711        let payload = vec![b'x'; 100]; // > one 13-byte test frame
712        let res = t.post_bytes("/up", &payload).await;
713        assert_eq!(res.json::<usize>(), 100);
714    }
715
716    #[tokio::test]
717    async fn json_body_deserializes_and_bad_json_is_422() {
718        #[derive(serde::Deserialize)]
719        struct NewTodo {
720            title: String,
721        }
722        let mut c = ctx("/todos", r#"{"title":"x"}"#);
723        let Json(t): Json<NewTodo> = Json::from_request(&mut c).await.unwrap();
724        assert_eq!(t.title, "x");
725
726        let mut bad = ctx("/todos", r#"{"title":"#);
727        let err = Json::<NewTodo>::from_request(&mut bad).await.err().unwrap();
728        assert_eq!(err.code(), "JC0422");
729    }
730
731    /// Build a stream-lane RequestCtx directly, optionally capping it with
732    /// `Limited` — the in-process analogue of the serve-time stream lane,
733    /// without a socket. Frames the body in one chunk; that is enough for the
734    /// caching/limit unit tests (frame straddling is exercised by TestApp).
735    fn stream_ctx(body: &[u8], limit: Option<usize>) -> RequestCtx {
736        use http_body_util::BodyExt;
737        use http_body_util::combinators::UnsyncBoxBody;
738        let req = http::Request::builder().uri("/up").body(()).unwrap();
739        let (parts, ()) = req.into_parts();
740        let bytes = Bytes::copy_from_slice(body);
741        let lane: StreamLane = match limit {
742            Some(limit) => {
743                let limited = http_body_util::Limited::new(
744                    http_body_util::Full::<Bytes>::new(bytes).map_err(
745                        |never| -> Box<dyn std::error::Error + Send + Sync> { match never {} },
746                    ),
747                    limit,
748                );
749                UnsyncBoxBody::new(limited.map_err(Into::into))
750            }
751            None => {
752                let full = http_body_util::Full::<Bytes>::new(bytes);
753                UnsyncBoxBody::new(full.map_err(
754                    |never| -> Box<dyn std::error::Error + Send + Sync> { match never {} },
755                ))
756            }
757        };
758        RequestCtx::with_lane(
759            parts,
760            BodyLane::Stream(Some(lane)),
761            DepResolver::new(Arc::new(DepEnv::default()), Default::default()),
762        )
763    }
764
765    #[tokio::test]
766    async fn stream_routes_deliver_the_body_and_enforce_the_limit() {
767        use crate::prelude::*;
768        async fn echo(Json(v): Json<serde_json::Value>) -> Result<Json<serde_json::Value>> {
769            Ok(Json(v))
770        }
771        let t = App::new()
772            .route("/up", post(echo).stream_body().body_limit(64))
773            .into_test();
774        // Json over a STREAM lane drains transparently.
775        let res = t.post_json("/up", &serde_json::json!({"k": "v"})).await;
776        assert_eq!(res.status().as_u16(), 200);
777        // Cumulative limit still applies on the stream lane: oversize → 413.
778        let big = serde_json::json!({"k": "x".repeat(200)});
779        let res = t.post_json("/up", &big).await;
780        assert_eq!(res.status().as_u16(), 413, "body: {}", res.text());
781    }
782
783    #[tokio::test]
784    async fn drain_body_twice_caches_the_stream_bytes() {
785        // The caching contract: a stream lane is drained once and cached back
786        // into Buffered, so a SECOND extractor on the same request keeps working
787        // instead of seeing an already-consumed stream.
788        use bytes::Bytes;
789        let mut c = stream_ctx(br#"{"k":"v"}"#, None);
790        let first = c.drain_body().await.unwrap();
791        assert_eq!(first, Bytes::from_static(br#"{"k":"v"}"#));
792        let second = c.drain_body().await.unwrap();
793        assert_eq!(second, first, "second drain returns the cached bytes");
794    }
795
796    #[tokio::test]
797    async fn stream_lane_over_limit_maps_to_413() {
798        // A stream lane whose Limited cap trips mid-drain surfaces as 413,
799        // exactly like the buffered read path.
800        let mut c = stream_ctx(&[b'x'; 200], Some(64));
801        let err = c.drain_body().await.err().unwrap();
802        assert_eq!(err.code(), "JC0413");
803    }
804
805    #[tokio::test]
806    async fn limit_trips_through_the_timed_recv_wrapper_still_map_to_413() {
807        // The serve-time lane wraps `Limited` in `TimedRecvBody` (the per-frame
808        // read-deadline guard); only the unwrapped `Limited` is covered above.
809        // If `TimedRecvBody`'s `map_err(Into::into)` ever double-boxed the
810        // error, `downcast_ref::<LengthLimitError>()` in `map_stream_error`
811        // would miss it and 413s would silently degrade to 400s. Build the
812        // exact serve.rs lane shape and assert the cap still maps to 413.
813        use crate::serve::TimedRecvBody;
814        use http_body_util::BodyExt;
815        use http_body_util::combinators::UnsyncBoxBody;
816        use std::time::Duration;
817
818        let req = http::Request::builder().uri("/up").body(()).unwrap();
819        let (parts, ()) = req.into_parts();
820        let over_limit_body = http_body_util::Full::<Bytes>::new(Bytes::from_static(&[b'x'; 200]))
821            .map_err(|never| -> Box<dyn std::error::Error + Send + Sync> { match never {} });
822        let lane: StreamLane = UnsyncBoxBody::new(TimedRecvBody::new(
823            http_body_util::Limited::new(over_limit_body, 64),
824            Duration::from_secs(5),
825        ));
826        let mut c = RequestCtx::with_lane(
827            parts,
828            BodyLane::Stream(Some(lane)),
829            DepResolver::new(Arc::new(DepEnv::default()), Default::default()),
830        );
831        let err = c.drain_body().await.err().unwrap();
832        assert_eq!(err.code(), "JC0413");
833        assert_eq!(err.status().as_u16(), 413);
834    }
835}