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}