Skip to main content

iroh_http_core/http/server/
stack.rs

1//! Tower stack composition shared by [`crate::http::serve`] (server) and
2//! [`crate::http::client::fetch_request`] (client).
3//!
4//! Closes Slice B of #182.
5//!
6//! Before this slice the per-connection pipeline was assembled by
7//! [`super::pipeline::build_stack`] from a hand-coded
8//! `PipelineParams` struct, while the outgoing-fetch pipeline was assembled
9//! inline in `client.rs` from a one-off `HyperClientSvc` wrapper.
10//! Compression on serve and decompression on fetch composed with different
11//! code paths to do structurally the same thing.
12//!
13//! After this slice there is exactly one composition function per direction
14//! ([`build_stack`] for inbound, [`build_client_stack`] for outbound),
15//! both consume the same typed [`StackConfig`], and every middleware is
16//! driven by a dedicated `apply_*` factory taking [`ServeService`] →
17//! [`ServeService`] (axum-style boxed-per-layer composition). Toggling
18//! a `cfg` field off produces a service whose runtime shape is identical
19//! to the layer never having been built (no-op early-return).
20//!
21//! Layer ordering (outermost first), inbound:
22//!
23//! ```text
24//! [body limit →] HandleLayerError → [load shed →] [timeout →]
25//!   [compression →] [decompression →] svc
26//! ```
27//!
28//! Layer ordering (outermost first), outbound:
29//!
30//! ```text
31//! [decompression →] hyper SendRequest
32//! ```
33//!
34//! ## Why two functions, one config
35//!
36//! Server and client use the same primitive layers from `tower-http`, but
37//! in opposite roles: a server *responds* compressed (compression layer)
38//! and *accepts* compressed requests (decompression layer); a client *sends*
39//! plain and *accepts* compressed responses (decompression layer). A single
40//! [`StackConfig`] captures the operator's intent; the direction-specific
41//! function decides which subset to apply.
42//!
43//! Slice D (#186) replaces [`build_client_stack`]'s ad-hoc hyper wrapper
44//! with a proper `Service<Request<Body>, Response<Body>, FetchError>`
45//! contract; until then, the wrapper lives here so the construction site
46//! is unified even if the inner type still spells `hyper::Error`.
47
48use std::convert::Infallible;
49use std::time::Duration;
50
51use bytes::Bytes;
52use tower::ServiceBuilder;
53
54use crate::http::body::BoxError;
55use crate::Body;
56
57// ── Public-facing config type ────────────────────────────────────────────────
58
59/// Compression options for response bodies (server) and outgoing requests
60/// (client; reserved).
61///
62/// Lives in this module because it configures the compression layer in
63/// [`build_stack`]/[`build_client_stack`]. Re-exported from the crate root
64/// so adapter callsites are unchanged.
65#[derive(Debug, Clone)]
66pub struct CompressionOptions {
67    /// Minimum body size in bytes before compression is applied.
68    /// Default: [`CompressionOptions::DEFAULT_MIN_BODY_BYTES`] (1 KiB).
69    pub min_body_bytes: usize,
70    /// Zstd compression level (1–22). `None` uses the zstd default (3).
71    pub level: Option<u32>,
72}
73
74impl CompressionOptions {
75    /// Default minimum body size before compression is applied.
76    ///
77    /// 1 KiB. Matches the documented default and the threshold most HTTP
78    /// servers tune to: below ~1 KiB the CPU cost of compression typically
79    /// outweighs the bandwidth savings on a single TCP/QUIC packet.
80    pub const DEFAULT_MIN_BODY_BYTES: usize = 1024;
81}
82
83impl Default for CompressionOptions {
84    fn default() -> Self {
85        Self {
86            min_body_bytes: Self::DEFAULT_MIN_BODY_BYTES,
87            level: None,
88        }
89    }
90}
91
92// ── Internal stack types ─────────────────────────────────────────────────────
93
94/// Server-side stack alias: type-erased `Service<Request<Body>>` returning
95/// `Response<Body>` with [`Infallible`] errors.
96///
97/// Boxing once at the construction site (in `server::serve_with_events`)
98/// means every future addition to the layer stack — `AddExtensionLayer`,
99/// `TraceLayer`, a response-signing layer, anything — is **one append**
100/// in the builder without rippling a new concrete type signature through
101/// the per-bistream code path.
102pub(crate) type ServeService =
103    tower::util::BoxCloneService<hyper::Request<Body>, hyper::Response<Body>, Infallible>;
104
105/// Client-side stack alias.
106///
107/// Carries `hyper::Error` (Slice D will replace this with a typed
108/// `FetchError`). Boxed via [`tower::util::BoxService`] rather than
109/// [`tower::util::BoxCloneService`] because hyper's `SendRequest` is not
110/// `Clone`; the boxed service is consumed by a single `oneshot` call.
111pub(crate) type ClientService =
112    tower::util::BoxService<hyper::Request<Body>, hyper::Response<Body>, hyper::Error>;
113
114/// Tower stack configuration shared by [`build_stack`] and
115/// [`build_client_stack`].
116///
117/// Each field is consumed by a dedicated `apply_*` factory in this module
118/// (see [`build_stack`]'s body). Factories are no-ops when their field is
119/// `None`/`false`, so toggling a field off produces a service whose
120/// runtime shape is identical to the layer never having been built.
121///
122/// Composition uses the axum-style boxed-per-layer pattern: every
123/// `apply_*` takes a [`ServeService`] and returns a [`ServeService`],
124/// performing its own `boxed_clone()`. This re-erases the inner
125/// `Service::Future` after each layer, so layer addition is decoupled
126/// from the type-soup of `Either<L::Service, S>` and `impl Layer<…>`
127/// return-position bound erasure (see Slice B (#184) carry-forward in
128/// Slice C (#185)).
129///
130/// Slice D (#186): made public so the pure-Rust
131/// [`crate::http::client::fetch_request`] can take `&StackConfig` directly.
132/// `#[non_exhaustive]` so adapters constructing literal `StackConfig`
133/// values (`StackConfig { timeout, decompression, ..default() }`) opt in
134/// explicitly and future fields don't break them.
135#[derive(Clone, Debug)]
136#[non_exhaustive]
137pub struct StackConfig {
138    /// Per-request timeout. `None` ⇒ no `TimeoutLayer` is applied.
139    pub timeout: Option<Duration>,
140    /// Maximum **wire** request body size before the server rejects with 413.
141    ///
142    /// This limit is applied **before** decompression, i.e. it counts compressed
143    /// bytes as they arrive from the QUIC stream. It is an effective guard
144    /// against network-level DoS (high-bandwidth senders), but it does **not**
145    /// protect against a compression bomb (a small compressed body that
146    /// decompresses to many GB). Use [`Self::max_request_body_decoded_bytes`]
147    /// for that.
148    pub max_request_body_wire_bytes: Option<usize>,
149    /// Maximum **decoded** request body size before the server rejects with 413.
150    ///
151    /// This limit is applied **after** decompression. It is the primary guard
152    /// against compression bombs: a zstd payload that is tiny on the wire but
153    /// expands to GB in memory is rejected once the decoded byte count crosses
154    /// this threshold. The default at the [`super::serve`] entry point
155    /// is 16 MiB (matching the documented behaviour that the old single-limit
156    /// field `max_request_body_bytes` had always promised but never delivered).
157    pub max_request_body_decoded_bytes: Option<usize>,
158    /// `true` ⇒ wrap with `LoadShedLayer` so saturated capacity returns 503
159    /// immediately rather than blocking the caller.
160    pub load_shed: bool,
161    /// Operator's compression configuration. `None` disables response
162    /// compression on the server side; ignored by [`build_client_stack`]
163    /// (clients do not yet compress request bodies).
164    pub compression: Option<CompressionOptions>,
165    /// `true` ⇒ apply `RequestDecompressionLayer` (server) /
166    /// `DecompressionLayer` (client). Defaults to `true` — every server
167    /// today accepts compressed requests, every client accepts compressed
168    /// responses.
169    pub decompression: bool,
170}
171
172impl Default for StackConfig {
173    fn default() -> Self {
174        Self {
175            timeout: None,
176            max_request_body_wire_bytes: None,
177            max_request_body_decoded_bytes: None,
178            load_shed: false,
179            compression: None,
180            decompression: true,
181        }
182    }
183}
184
185impl StackConfig {
186    /// Set [`Self::timeout`] using the builder pattern.
187    ///
188    /// Convenience constructor for external callers — `#[non_exhaustive]`
189    /// blocks struct-literal construction outside this crate, so use
190    /// `StackConfig::default().with_timeout(...)`.
191    #[must_use]
192    pub fn with_timeout(mut self, timeout: Option<Duration>) -> Self {
193        self.timeout = timeout;
194        self
195    }
196
197    /// Set [`Self::decompression`] using the builder pattern.
198    #[must_use]
199    pub fn with_decompression(mut self, decompression: bool) -> Self {
200        self.decompression = decompression;
201        self
202    }
203}
204
205// ── Server pipeline ──────────────────────────────────────────────────────────
206
207/// Compose the inbound per-connection tower stack.
208///
209/// Layer ordering (outermost first):
210///
211/// ```text
212///   wire body limit  →  load shed  →  request timeout
213///                    →  compression (response)
214///                    →  decompression (request)
215///                    →  decoded body limit
216///                    →  svc
217/// ```
218///
219/// Built bottom-up (innermost first) by chaining `apply_*` factories,
220/// each of which takes a [`ServeService`] and returns a [`ServeService`].
221/// Adding a new layer is a single line.
222///
223/// The two body-limit layers enforce different semantic guarantees:
224/// - **wire limit** (outermost): counts compressed bytes from the QUIC
225///   stream. Guards against high-bandwidth network floods.
226/// - **decoded limit** (inside decompression): counts bytes after
227///   `RequestDecompressionLayer` expands them. Guards against zstd/gzip
228///   compression bombs (#190).
229///
230/// `HandleLayerError` is owned **by each layer that can produce a
231/// `BoxError`** (load-shed, timeout) rather than threaded as a separate
232/// outer wrap: this keeps every factory's signature uniformly
233/// `ServeService → ServeService` and the chain composable in any order.
234/// See Slice C carry-forward (#185) for the design rationale.
235pub(crate) fn build_stack(svc: ServeService, cfg: &StackConfig) -> ServeService {
236    let svc = apply_body_limit(svc, cfg.max_request_body_decoded_bytes); // inside decompression
237    let svc = apply_decompression(svc, cfg.decompression);
238    let svc = apply_compression(svc, cfg.compression.as_ref());
239    let svc = apply_timeout(svc, cfg.timeout);
240    let svc = apply_load_shed(svc, cfg.load_shed);
241    apply_body_limit(svc, cfg.max_request_body_wire_bytes) // outermost: wire bytes
242}
243
244// ── Renormalisation helper ───────────────────────────────────────────────────
245
246/// Convert any body type back to [`Body`].
247///
248/// Used as the mapping function in `MapResponseBodyLayer` / `MapRequestBodyLayer`
249/// after every layer that changes the body type. Since [`Body::new`] already
250/// short-circuits via `try_downcast` when the input is already a `Body`, the
251/// cost of using this at every seam is essentially free for pass-through layers.
252fn renormalize_body<B>(body: B) -> Body
253where
254    B: http_body::Body<Data = Bytes> + Send + 'static,
255    B::Error: Into<BoxError>,
256{
257    Body::new(body)
258}
259
260// ── Layer factories ──────────────────────────────────────────────────────────
261
262/// `RequestBodyLimitLayer` rejects oversize bodies with 413. Wrapped in
263/// matching `MapRequestBody` / `MapResponseBody` to renormalise both
264/// directions back to [`Body`] (ADR-014 D2 / #175).
265pub(crate) fn apply_body_limit(svc: ServeService, limit: Option<usize>) -> ServeService {
266    use tower::ServiceExt;
267    use tower_http::limit::RequestBodyLimitLayer;
268    use tower_http::map_request_body::MapRequestBodyLayer;
269    use tower_http::map_response_body::MapResponseBodyLayer;
270
271    let Some(limit) = limit else {
272        return svc;
273    };
274    ServiceBuilder::new()
275        .layer(MapResponseBodyLayer::new(renormalize_body))
276        .layer(RequestBodyLimitLayer::new(limit))
277        .layer(MapRequestBodyLayer::new(renormalize_body))
278        .service(svc)
279        .boxed_clone()
280}
281
282/// `LoadShedLayer` returns `Overloaded` immediately when the inner
283/// service reports `Poll::Pending` from `poll_ready`. The error is
284/// converted to a 503 response by an inner [`HandleLayerErrorLayer`] so
285/// the resulting service still has `Error = Infallible`. No-op when
286/// `enabled = false`.
287pub(crate) fn apply_load_shed(svc: ServeService, enabled: bool) -> ServeService {
288    use crate::http::server::HandleLayerErrorLayer;
289    use tower::ServiceExt;
290    if !enabled {
291        return svc;
292    }
293    ServiceBuilder::new()
294        .layer(HandleLayerErrorLayer)
295        .layer(tower::load_shed::LoadShedLayer::new())
296        .service(svc)
297        .boxed_clone()
298}
299
300/// Per-request timeout. The `Elapsed` error is converted to a 408
301/// response by an inner [`HandleLayerErrorLayer`] so the resulting
302/// service still has `Error = Infallible`. No-op when `timeout = None`.
303pub(crate) fn apply_timeout(svc: ServeService, timeout: Option<Duration>) -> ServeService {
304    use crate::http::server::HandleLayerErrorLayer;
305    use tower::ServiceExt;
306    let Some(timeout) = timeout else {
307        return svc;
308    };
309    ServiceBuilder::new()
310        .layer(HandleLayerErrorLayer)
311        .layer(tower::timeout::TimeoutLayer::new(timeout))
312        .service(svc)
313        .boxed_clone()
314}
315
316/// Response compression with project-specific predicates (see
317/// [`build_compression_layer`]). No-op when `comp = None`.
318pub(crate) fn apply_compression(
319    svc: ServeService,
320    comp: Option<&CompressionOptions>,
321) -> ServeService {
322    use tower::ServiceExt;
323    use tower_http::map_response_body::MapResponseBodyLayer;
324
325    let Some(comp) = comp else {
326        return svc;
327    };
328    ServiceBuilder::new()
329        .layer(MapResponseBodyLayer::new(renormalize_body))
330        .layer(build_compression_layer(comp))
331        .service(svc)
332        .boxed_clone()
333}
334
335/// Request decompression. Wrapped in matching `MapRequestBody` /
336/// `MapResponseBody` to renormalise both body sides back to [`Body`].
337/// No-op when `enabled = false`.
338pub(crate) fn apply_decompression(svc: ServeService, enabled: bool) -> ServeService {
339    use tower::ServiceExt;
340    use tower_http::decompression::RequestDecompressionLayer;
341    use tower_http::map_request_body::MapRequestBodyLayer;
342    use tower_http::map_response_body::MapResponseBodyLayer;
343
344    if !enabled {
345        return svc;
346    }
347    ServiceBuilder::new()
348        .layer(MapResponseBodyLayer::new(renormalize_body))
349        .layer(RequestDecompressionLayer::new())
350        .layer(MapRequestBodyLayer::new(renormalize_body))
351        .service(svc)
352        .boxed_clone()
353}
354
355/// Build the `tower-http` compression layer with the project's predicate set.
356///
357/// Two custom predicates remain because tower-http does not ship built-ins
358/// for either:
359///
360/// 1. Skip if the response already carries `Content-Encoding` (handler
361///    returned a pre-encoded body — re-compressing would double-encode).
362/// 2. Honour `Cache-Control: no-transform` per RFC 9111 §5.2.2.7.
363pub(crate) fn build_compression_layer(
364    comp: &CompressionOptions,
365) -> tower_http::compression::CompressionLayer<impl tower_http::compression::Predicate> {
366    use http::{Extensions, HeaderMap, StatusCode, Version};
367    use tower_http::compression::{
368        predicate::{NotForContentType, Predicate, SizeAbove},
369        CompressionLayer, CompressionLevel,
370    };
371
372    let mut layer = CompressionLayer::new().zstd(true);
373    if let Some(level) = comp.level {
374        layer = layer.quality(CompressionLevel::Precise(level as i32));
375    }
376
377    let not_pre_compressed = |_: StatusCode, _: Version, h: &HeaderMap, _: &Extensions| {
378        !h.contains_key(http::header::CONTENT_ENCODING)
379    };
380    let not_no_transform = |_: StatusCode, _: Version, h: &HeaderMap, _: &Extensions| {
381        h.get(http::header::CACHE_CONTROL)
382            .and_then(|v| v.to_str().ok())
383            .map(|v| {
384                !v.split(',')
385                    .any(|d| d.trim().eq_ignore_ascii_case("no-transform"))
386            })
387            .unwrap_or(true)
388    };
389
390    let predicate = SizeAbove::new(comp.min_body_bytes.min(u16::MAX as usize) as u16)
391        .and(NotForContentType::IMAGES)
392        .and(NotForContentType::SSE)
393        .and(NotForContentType::const_new("audio/"))
394        .and(NotForContentType::const_new("video/"))
395        .and(NotForContentType::const_new("application/zstd"))
396        .and(NotForContentType::const_new("application/octet-stream"))
397        .and(not_pre_compressed)
398        .and(not_no_transform);
399
400    layer.compress_when(predicate)
401}
402
403// ── Client pipeline ──────────────────────────────────────────────────────────
404
405/// Compose the outbound per-request tower stack.
406///
407/// ```text
408///   [timeout →] decompression → incoming→Body → hyper SendRequest
409/// ```
410///
411/// Returns a [`ClientService`] — boxed once so the caller
412/// ([`crate::http::client::fetch_request`]) does not have to spell the inner
413/// type. Honours `cfg.decompression`. `cfg.timeout` is **not** wired into
414/// this stack — enforcing it as a tower layer would change the service
415/// error type away from `hyper::Error`. The pure-Rust caller
416/// [`crate::http::client::fetch_request`] applies `cfg.timeout` with an
417/// outer `tokio::time::timeout` instead. Per Slice D
418/// (#186) the inner `SendRequest` wrapper that used to be a named
419/// `HyperClientSvc` lives inline below.
420pub(crate) fn build_client_stack(
421    sender: hyper::client::conn::http1::SendRequest<Body>,
422    cfg: &StackConfig,
423) -> ClientService {
424    use tower::ServiceExt;
425    use tower_http::map_response_body::MapResponseBodyLayer;
426
427    // Inline `SendRequest<Body> as tower::Service` adapter. Lives inside
428    // `build_client_stack` so the wrapper has no name outside this
429    // function (Slice D acceptance #3).
430    struct SendRequestSvc(hyper::client::conn::http1::SendRequest<Body>);
431
432    impl tower::Service<hyper::Request<Body>> for SendRequestSvc {
433        type Response = hyper::Response<hyper::body::Incoming>;
434        type Error = hyper::Error;
435        type Future = std::pin::Pin<
436            Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
437        >;
438
439        fn poll_ready(
440            &mut self,
441            cx: &mut std::task::Context<'_>,
442        ) -> std::task::Poll<Result<(), Self::Error>> {
443            self.0.poll_ready(cx)
444        }
445
446        fn call(&mut self, req: hyper::Request<Body>) -> Self::Future {
447            Box::pin(self.0.send_request(req))
448        }
449    }
450
451    // Step 1: normalise hyper's `Incoming` to `Body` so subsequent layers
452    // operate on the canonical body type.
453    let svc = ServiceBuilder::new()
454        .layer(MapResponseBodyLayer::new(renormalize_body))
455        .service(SendRequestSvc(sender))
456        .boxed();
457
458    // Step 2: optional response decompression (always-on by default).
459    apply_client_decompression(svc, cfg.decompression)
460    // Per-request timeout is enforced by the caller in
461    // [`crate::http::client::fetch_request`] via `tokio::time::timeout` rather
462    // than a `TimeoutLayer` here, so the `ClientService` error type can
463    // stay `hyper::Error` and the timeout maps cleanly to
464    // `FetchError::Timeout` without going through `BoxError` downcasts.
465}
466
467/// Client-side equivalent of [`apply_decompression`]. No-op when
468/// `enabled = false`.
469pub(crate) fn apply_client_decompression(svc: ClientService, enabled: bool) -> ClientService {
470    use tower::ServiceExt;
471    use tower_http::decompression::DecompressionLayer;
472    use tower_http::map_response_body::MapResponseBodyLayer;
473
474    if !enabled {
475        return svc;
476    }
477    ServiceBuilder::new()
478        .layer(MapResponseBodyLayer::new(renormalize_body))
479        .layer(DecompressionLayer::new())
480        .service(svc)
481        .boxed()
482}
483
484#[cfg(test)]
485mod tests {
486    //! ADR-014 D2 / #175 + Slice B (#184) guardrail.
487    //!
488    //! Exercises the *real* per-bistream chain ([`build_stack`]) to prove
489    //! the structural property the issue was filed for: every layer in the
490    //! production pipeline composes uniformly into [`ServeService`], and a
491    //! request still flows through to a `Response<Body>`. If a future
492    //! change to the inner service's body or error type breaks uniformity,
493    //! `build_stack` itself stops compiling — the failure surfaces here,
494    //! not later in an integration test over the network.
495    //!
496    //! No hyper / no Iroh / no networking: feeds requests directly into
497    //! the boxed [`ServeService`] with `tower::ServiceExt::oneshot`.
498
499    use super::*;
500    use bytes::Bytes;
501    use http_body_util::BodyExt;
502    use std::convert::Infallible;
503    use tower::ServiceExt;
504
505    /// Stand-in for `IrohHttpService` shaped exactly like the real one
506    /// (`Service<Request<Body>, Response = Response<Body>, Error = Infallible>`).
507    /// Echoes the request body back in the response.
508    #[derive(Clone)]
509    struct EchoService;
510
511    impl tower::Service<hyper::Request<Body>> for EchoService {
512        type Response = hyper::Response<Body>;
513        type Error = Infallible;
514        type Future = std::pin::Pin<
515            Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
516        >;
517
518        fn poll_ready(
519            &mut self,
520            _cx: &mut std::task::Context<'_>,
521        ) -> std::task::Poll<Result<(), Self::Error>> {
522            std::task::Poll::Ready(Ok(()))
523        }
524
525        fn call(&mut self, req: hyper::Request<Body>) -> Self::Future {
526            Box::pin(async move {
527                let bytes = req
528                    .into_body()
529                    .collect()
530                    .await
531                    .map(|c| c.to_bytes())
532                    .unwrap_or_default();
533                Ok(hyper::Response::new(Body::full(bytes)))
534            })
535        }
536    }
537
538    fn default_cfg() -> StackConfig {
539        StackConfig {
540            timeout: None,
541            max_request_body_wire_bytes: Some(1024 * 1024),
542            max_request_body_decoded_bytes: Some(1024 * 1024),
543            load_shed: true,
544            compression: None,
545            decompression: true,
546        }
547    }
548
549    fn boxed_echo() -> ServeService {
550        ServiceBuilder::new().service(EchoService).boxed_clone()
551    }
552
553    /// Drives the *production* `build_stack`. Body limit, load-shed and
554    /// decompression are all in-circuit. If any of them stops composing
555    /// into `ServeService`, this fails to compile — that is the structural
556    /// guardrail.
557    #[tokio::test]
558    async fn real_chain_round_trips_a_request() {
559        let stack = build_stack(boxed_echo(), &default_cfg());
560
561        let req = hyper::Request::builder()
562            .uri("/")
563            .body(Body::full("ping"))
564            .unwrap();
565        let resp = stack.oneshot(req).await.expect("service infallible");
566        assert_eq!(resp.status(), hyper::StatusCode::OK);
567        let body = resp
568            .into_body()
569            .collect()
570            .await
571            .expect("body collect")
572            .to_bytes();
573        assert_eq!(body, Bytes::from_static(b"ping"));
574    }
575
576    /// Same chain, with response-side compression enabled. Proves the
577    /// `option_layer(comp_layer)` arm composes and produces the same
578    /// `ServeService` shape.
579    #[tokio::test]
580    async fn real_chain_with_compression_enabled_still_round_trips() {
581        let mut cfg = default_cfg();
582        cfg.compression = Some(CompressionOptions {
583            level: None,
584            min_body_bytes: 0,
585        });
586        let stack = build_stack(boxed_echo(), &cfg);
587
588        let req = hyper::Request::builder()
589            .uri("/")
590            .header("accept-encoding", "zstd")
591            .body(Body::full("ping"))
592            .unwrap();
593        let resp = stack.oneshot(req).await.expect("service infallible");
594        assert_eq!(resp.status(), hyper::StatusCode::OK);
595        // Body is opaque (may be zstd-encoded). We only assert structural
596        // success; wire-format coverage lives in the dedicated compression
597        // integration tests.
598        let _ = resp.into_body().collect().await;
599    }
600
601    /// #184 acceptance criterion 5 — extensibility regression test.
602    ///
603    /// Wraps the production `build_stack` output with one additional
604    /// `MapRequestBodyLayer::new(identity)` and asserts the request still
605    /// flows. Demonstrates ADR-014 D2's structural payoff at runtime: a
606    /// new layer is one append, no signature change anywhere downstream
607    /// because both `build_stack`'s input and output are
608    /// [`ServeService`]. If a future layer addition breaks the
609    /// "uniformly composes into `ServeService`" property this test fails
610    /// to compile.
611    #[tokio::test]
612    async fn build_stack_accepts_additional_outer_layer() {
613        use tower_http::map_request_body::MapRequestBodyLayer;
614
615        let inner = build_stack(boxed_echo(), &default_cfg());
616        let stack = ServiceBuilder::new()
617            .layer(MapRequestBodyLayer::new(|b: Body| b))
618            .service(inner);
619
620        let req = hyper::Request::builder()
621            .uri("/")
622            .body(Body::full("ping"))
623            .unwrap();
624        let resp = stack.oneshot(req).await.expect("service infallible");
625        assert_eq!(resp.status(), hyper::StatusCode::OK);
626        let body = resp
627            .into_body()
628            .collect()
629            .await
630            .expect("body collect")
631            .to_bytes();
632        assert_eq!(body, Bytes::from_static(b"ping"));
633    }
634}