Skip to main content

connectrpc/
interceptor.rs

1//! RPC-level interceptors.
2//!
3//! Interceptors are the typed equivalent of `tower` middleware: they wrap
4//! a single RPC *after* envelope decoding, decompression, and header
5//! parsing, and *before* the handler runs. Two surfaces:
6//!
7//! - **Unary** ([`Interceptor::intercept_unary`]): sees a [`UnaryRequest`]
8//!   (the [`Spec`](crate::Spec), headers, deadline, extensions, and a
9//!   lazily-decoded [`Payload`]) and a [`Next`] continuation, and returns
10//!   a [`UnaryResponse`].
11//! - **Streaming** ([`Interceptor::intercept_streaming`]): sees a
12//!   [`StreamRequest`], an inbound [`PayloadStream`], and a [`NextStream`]
13//!   continuation, and returns a [`StreamResponse`] carrying the outbound
14//!   [`PayloadStream`]. One method covers server-streaming,
15//!   client-streaming, and bidi by treating "one" as "stream of one".
16//!
17//! Streaming interceptors are **`Stream`-shaped, not connection-shaped.**
18//! `connect-go` exposes a `StreamingHandlerConn` with `Receive()`/`Send()`
19//! because Go handlers *push* — they call `stream.Send(res)`. Rust handlers
20//! *produce* — they return a [`Stream`](futures::Stream) the framework
21//! polls. There is no per-item `send()` call site to hook, so a conn
22//! wrapper would need a channel intermediary plus a pump task. Wrapping
23//! the inbound and outbound streams with adapters is the same expressive
24//! power without that cost, and matches the rest of the Rust ecosystem
25//! (`tower`, `tonic`, `axum` all work with `Stream`-shaped bodies).
26//!
27//! The first interceptor registered is the outermost: it runs first on
28//! the way in and last on the way out, exactly like wrapping a function
29//! call. This matches `connect-go`'s `WithInterceptors` ordering.
30//!
31//! ```text
32//! request ──▶ interceptor[0] ──▶ interceptor[1] ──▶ handler
33//!                  │                  │                 │
34//! response ◀───────┴──────────◀───────┴────────◀───────┘
35//! ```
36//!
37//! Register interceptors with
38//! [`ConnectRpcService::with_interceptor`](crate::ConnectRpcService::with_interceptor).
39//! When no interceptors are registered the dispatch path is byte-for-byte
40//! identical to a build without this module — there is no per-request
41//! cost for opting out.
42
43use std::sync::Arc;
44
45use bytes::Bytes;
46use futures::future::BoxFuture;
47use futures::stream::StreamExt;
48
49use crate::codec::CodecFormat;
50use crate::dispatcher::RequestStream;
51use crate::error::ConnectError;
52use crate::handler::BoxStream;
53use crate::payload::Payload;
54use crate::response::{EncodedResponse, RequestContext, Response};
55
56/// Re-export of [`async_trait::async_trait`] so interceptor authors don't
57/// need a direct `async-trait` dependency.
58///
59/// ```rust,ignore
60/// #[connectrpc::async_trait]
61/// impl connectrpc::Interceptor for MyInterceptor { /* ... */ }
62/// ```
63///
64/// The macro expansion references only `core` and the prelude — there is
65/// no runtime `async-trait` requirement.
66pub use async_trait::async_trait;
67
68/// A unary RPC interceptor.
69///
70/// Implement [`intercept_unary`](Interceptor::intercept_unary) to wrap a
71/// call. The default implementation is a passthrough — calling
72/// [`next.run(req)`](Next::run) — so an interceptor that only cares about
73/// (say) streaming RPCs in a future release is forwards-compatible.
74///
75/// Use [`unary_interceptor`] for a closure-shaped interceptor without a
76/// dedicated type.
77///
78/// `Interceptor` is an async trait. Annotate the impl with the
79/// [`connectrpc::async_trait`](crate::async_trait) re-export — there is
80/// no separate `async-trait` dependency to add.
81///
82/// # Example
83///
84/// ```rust,ignore
85/// struct LoggingInterceptor;
86///
87/// #[connectrpc::async_trait]
88/// impl Interceptor for LoggingInterceptor {
89///     async fn intercept_unary(
90///         &self,
91///         req: UnaryRequest,
92///         next: Next<'_>,
93///     ) -> Result<UnaryResponse, ConnectError> {
94///         // `ctx.path()` is the requested procedure path. The dispatch
95///         // path always sets it before an interceptor runs, including
96///         // for dynamic `Router` routes (which never carry a `Spec`) —
97///         // the `expect` documents that invariant rather than hiding a
98///         // default. Use `ctx.spec()` for the *resolved* method's static
99///         // metadata (`stream_type`, `idempotency`), not the name.
100///         //
101///         // `to_owned()` because `path()` borrows `req.ctx`, and `req`
102///         // is moved into `next.run` below.
103///         let path = req
104///             .ctx
105///             .path()
106///             .expect("dispatch sets path before interceptors run")
107///             .to_owned();
108///         tracing::info!(%path, "rpc start");
109///         let resp = next.run(req).await;
110///         tracing::info!(%path, ok = resp.is_ok(), "rpc end");
111///         resp
112///     }
113/// }
114/// ```
115#[async_trait::async_trait]
116pub trait Interceptor: Send + Sync + 'static {
117    /// Wrap a unary RPC. The default is a passthrough.
118    ///
119    /// Call [`next.run(req)`](Next::run) to continue. Returning without
120    /// calling it short-circuits the chain — neither inner interceptors
121    /// nor the handler run.
122    ///
123    /// # Errors
124    ///
125    /// Forward errors from `next.run` (handler or inner-interceptor
126    /// failures), or return your own to short-circuit.
127    async fn intercept_unary(
128        &self,
129        req: UnaryRequest,
130        next: Next<'_>,
131    ) -> Result<UnaryResponse, ConnectError> {
132        next.run(req).await
133    }
134
135    /// Wrap a streaming RPC. The default is a passthrough.
136    ///
137    /// Called once at stream establishment, before any messages flow.
138    /// Wrap `inbound` with a [`Stream`](futures::Stream) adapter to
139    /// observe, mutate, or filter incoming messages; wrap the body of the
140    /// returned [`StreamResponse`] the same way. Returning without calling
141    /// `next.run()` short-circuits the chain — neither inner interceptors
142    /// nor the handler run. Returning `Err` aborts the stream with an
143    /// error rendered in the protocol's streaming error format
144    /// (`EndStreamResponse` envelope for Connect, `grpc-status` trailer
145    /// for gRPC/gRPC-Web).
146    ///
147    /// All three streaming shapes route through this method. For
148    /// server-streaming `inbound` yields exactly one item; for
149    /// client-streaming the returned outbound stream yields exactly one
150    /// item. Read [`Spec::stream_type`](crate::Spec::stream_type) (when
151    /// [`spec()`](RequestContext::spec) is present) to branch on
152    /// cardinality.
153    ///
154    /// Cross-stream coordination — making a decision on an outbound item
155    /// based on what was observed inbound — needs shared state between the
156    /// two stream adapters (e.g. an `Arc<Mutex<..>>` captured by both).
157    /// This is rare; most interceptors observe one direction or none.
158    ///
159    /// # Example
160    ///
161    /// ```rust,ignore
162    /// #[connectrpc::async_trait]
163    /// impl Interceptor for AuthInterceptor {
164    ///     async fn intercept_streaming(
165    ///         &self,
166    ///         req: StreamRequest,
167    ///         inbound: PayloadStream,
168    ///         next: NextStream<'_>,
169    ///     ) -> Result<StreamResponse, ConnectError> {
170    ///         // Auth runs once at establishment, not per message.
171    ///         let path = req.ctx.path().expect("dispatch sets path");
172    ///         self.authorize(path, req.ctx.headers()).await?;
173    ///         next.run(req, inbound).await
174    ///     }
175    /// }
176    /// ```
177    ///
178    /// # Errors
179    ///
180    /// Forward errors from `next.run`, or return your own to short-circuit.
181    async fn intercept_streaming(
182        &self,
183        req: StreamRequest,
184        inbound: PayloadStream,
185        next: NextStream<'_>,
186    ) -> Result<StreamResponse, ConnectError> {
187        next.run(req, inbound).await
188    }
189}
190
191/// Construct an [`Interceptor`] from a closure.
192///
193/// The closure must be a higher-ranked `Fn` over the [`Next`] lifetime
194/// that returns a boxed future. The boilerplate is unavoidable — the
195/// trait method returns a boxed future — but the closure body is what
196/// you'd write in an `impl Interceptor` block:
197///
198/// ```rust,ignore
199/// let timing = unary_interceptor(|req, next| Box::pin(async move {
200///     let started = std::time::Instant::now();
201///     let resp = next.run(req).await;
202///     tracing::debug!(elapsed = ?started.elapsed(), "rpc");
203///     resp
204/// }));
205/// ```
206pub fn unary_interceptor<F>(f: F) -> impl Interceptor
207where
208    F: for<'a> Fn(UnaryRequest, Next<'a>) -> BoxFuture<'a, Result<UnaryResponse, ConnectError>>
209        + Send
210        + Sync
211        + 'static,
212{
213    struct FnInterceptor<F>(F);
214
215    #[async_trait::async_trait]
216    impl<F> Interceptor for FnInterceptor<F>
217    where
218        F: for<'a> Fn(UnaryRequest, Next<'a>) -> BoxFuture<'a, Result<UnaryResponse, ConnectError>>
219            + Send
220            + Sync
221            + 'static,
222    {
223        async fn intercept_unary(
224            &self,
225            req: UnaryRequest,
226            next: Next<'_>,
227        ) -> Result<UnaryResponse, ConnectError> {
228            (self.0)(req, next).await
229        }
230    }
231
232    FnInterceptor(f)
233}
234
235/// The continuation an [`Interceptor`] calls to run the rest of the chain.
236///
237/// `Next` holds the still-to-run interceptors and the terminal handler.
238/// [`run`](Next::run) consumes it: an interceptor can call `next.run(req)`
239/// at most once. Not calling it at all short-circuits the chain.
240pub struct Next<'a> {
241    rest: &'a [Arc<dyn Interceptor>],
242    terminal: &'a (dyn UnaryTerminal + 'a),
243}
244
245impl<'a> Next<'a> {
246    /// Construct the head of a chain.
247    pub(crate) fn new(
248        rest: &'a [Arc<dyn Interceptor>],
249        terminal: &'a (dyn UnaryTerminal + 'a),
250    ) -> Self {
251        Self { rest, terminal }
252    }
253
254    /// Run the rest of the chain — the next interceptor if any, otherwise
255    /// the terminal handler — and return its response.
256    ///
257    /// # Errors
258    ///
259    /// Returns whatever error the next interceptor or handler produced.
260    pub async fn run(self, req: UnaryRequest) -> Result<UnaryResponse, ConnectError> {
261        match self.rest.split_first() {
262            Some((head, tail)) => {
263                head.intercept_unary(
264                    req,
265                    Next {
266                        rest: tail,
267                        terminal: self.terminal,
268                    },
269                )
270                .await
271            }
272            None => self.terminal.call(req).await,
273        }
274    }
275}
276
277impl std::fmt::Debug for Next<'_> {
278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279        f.debug_struct("Next")
280            .field("remaining", &self.rest.len())
281            .finish_non_exhaustive()
282    }
283}
284
285/// The terminal step of an interceptor chain: decode the request body,
286/// invoke the handler, encode the response.
287///
288/// `pub(crate)` because the only producer is the dispatch path. Tests
289/// inside the crate can supply mocks.
290#[async_trait::async_trait]
291pub(crate) trait UnaryTerminal: Send + Sync {
292    async fn call(&self, req: UnaryRequest) -> Result<UnaryResponse, ConnectError>;
293}
294
295/// A unary RPC request as seen by an [`Interceptor`].
296///
297/// Carries the dispatch [`RequestContext`] (headers, deadline,
298/// extensions, [`Spec`](crate::Spec), negotiated protocol) and the
299/// lazily-decoded body. Both fields are public so an interceptor can
300/// rewrite headers, inject extensions, or replace the message and pass
301/// the mutated request to [`Next::run`].
302///
303/// `ctx.spec` is `Some(..)` for generated `FooServiceServer<T>`
304/// dispatchers and for [`Router`](crate::Router) routes registered
305/// through the generated `register()`; it is `None` only for low-level
306/// manual registrations without a
307/// [`Router::with_spec`](crate::Router::with_spec) call.
308///
309/// `#[non_exhaustive]` so future fields can be added without a
310/// breaking change. Construct with [`UnaryRequest::new`]; destructure
311/// with a trailing `..`.
312#[derive(Debug)]
313#[non_exhaustive]
314pub struct UnaryRequest {
315    /// The dispatch context. Mutating `ctx.headers` or `ctx.extensions`
316    /// before `next.run` propagates to the handler.
317    pub ctx: RequestContext,
318    /// The lazily-decoded request body. Call
319    /// [`set_message`](Payload::set_message) to replace it.
320    pub payload: Payload,
321}
322
323impl UnaryRequest {
324    /// Build a `UnaryRequest` from a dispatch context and wire-encoded
325    /// body. Used by the dispatch path and by test fixtures.
326    pub fn new(ctx: RequestContext, body: Bytes, format: CodecFormat) -> Self {
327        let payload = Payload::new(body, format).with_decode_options(ctx.decode_options().clone());
328        Self { ctx, payload }
329    }
330}
331
332/// A unary RPC response as seen by an [`Interceptor`].
333///
334/// Carries response metadata (headers, trailers, compression hint) and
335/// a lazily-decoded body, with the same shape as the handler-facing
336/// [`Response<B>`](crate::Response). All fields are public so an
337/// interceptor can read or rewrite the response on the way out.
338pub type UnaryResponse = Response<Payload>;
339
340impl UnaryResponse {
341    /// Build a `UnaryResponse` from an encoded handler response.
342    pub fn from_encoded(resp: EncodedResponse, format: CodecFormat) -> Self {
343        Response {
344            // Interceptors inspect and replace whole message bodies, so the
345            // payload has to be contiguous here. Flattening is a no-op unless
346            // the encoder segmented, and an interceptor is the one consumer
347            // that cannot work with segments.
348            body: Payload::new(resp.body.into_contiguous(), format),
349            headers: resp.headers,
350            trailers: resp.trailers,
351            compress: resp.compress,
352        }
353    }
354
355    /// Convert back to the dispatch path's encoded form.
356    ///
357    /// # Errors
358    ///
359    /// Returns an error if a replacement set with
360    /// [`Payload::set_message`] fails to re-encode.
361    pub fn into_encoded(self) -> Result<EncodedResponse, ConnectError> {
362        Ok(Response {
363            body: self.body.encoded()?.into(),
364            headers: self.headers,
365            trailers: self.trailers,
366            compress: self.compress,
367        })
368    }
369}
370
371// ============================================================================
372// Streaming
373// ============================================================================
374
375/// A stream of lazily-decoded message bodies, as seen by an
376/// [`Interceptor::intercept_streaming`].
377///
378/// The same type is used for both the inbound (client → server) and
379/// outbound (server → client) directions. Each item is a [`Payload`] —
380/// lazy decode, so an interceptor that never inspects message bodies pays
381/// only the per-item struct construction (no decode, no allocation beyond
382/// the wire `Bytes` refcount the dispatch path already holds).
383pub type PayloadStream = BoxStream<Result<Payload, ConnectError>>;
384
385/// A streaming RPC request as seen by an [`Interceptor`].
386///
387/// Mirrors [`UnaryRequest`] minus the `Payload` — stream messages arrive
388/// through the `inbound` [`PayloadStream`] passed to
389/// [`Interceptor::intercept_streaming`].
390///
391/// `#[non_exhaustive]` so future fields can be added without a breaking
392/// change. Construct with [`StreamRequest::new`]; destructure with a
393/// trailing `..`.
394#[derive(Debug)]
395#[non_exhaustive]
396pub struct StreamRequest {
397    /// The dispatch context. Mutating `ctx.headers` or `ctx.extensions`
398    /// before `next.run` propagates to the handler.
399    pub ctx: RequestContext,
400}
401
402impl StreamRequest {
403    /// Build a `StreamRequest` from a dispatch context. Used by the
404    /// dispatch path and by test fixtures.
405    pub fn new(ctx: RequestContext) -> Self {
406        Self { ctx }
407    }
408}
409
410/// A streaming RPC response as seen by an [`Interceptor`].
411///
412/// Carries response metadata (headers, trailers, compression hint) and
413/// the outbound [`PayloadStream`]. All fields are public so an interceptor
414/// can read or rewrite the response on the way out — wrap `body` with a
415/// [`Stream`](futures::Stream) adapter to observe or mutate outbound
416/// messages, or [`with_header`](Response::with_header) /
417/// [`with_trailer`](Response::with_trailer) to set metadata.
418///
419/// Note the trailers are set by the handler **before** the body stream is
420/// drained — an interceptor cannot delay setting a trailer until it has
421/// seen the last outbound item.
422pub type StreamResponse = Response<PayloadStream>;
423
424impl StreamResponse {
425    /// Build a `StreamResponse` from a dispatcher's encoded streaming
426    /// response by wrapping each body item in a [`Payload`].
427    pub fn from_encoded(
428        resp: Response<BoxStream<Result<Bytes, ConnectError>>>,
429        format: CodecFormat,
430    ) -> Self {
431        resp.map_body(move |stream| -> PayloadStream {
432            Box::pin(stream.map(move |item| item.map(|bytes| Payload::new(bytes, format))))
433        })
434    }
435
436    /// Convert back to the dispatch path's encoded form by re-encoding
437    /// each [`Payload`].
438    ///
439    /// Items whose [`Payload::encoded`] fails (a replacement that failed
440    /// to re-encode) become `Err` entries in the stream, which the
441    /// dispatch path renders as a streaming error and then ends the
442    /// stream. There is no fallible up-front conversion: stream items
443    /// haven't been produced yet.
444    pub fn into_encoded(self) -> Response<BoxStream<Result<Bytes, ConnectError>>> {
445        self.map_body(|stream| -> BoxStream<Result<Bytes, ConnectError>> {
446            Box::pin(stream.map(|item| item.and_then(|payload| payload.encoded())))
447        })
448    }
449}
450
451/// Construct an [`Interceptor`] from a streaming closure.
452///
453/// The streaming counterpart of [`unary_interceptor`]. The closure must be
454/// a higher-ranked `Fn` over the [`NextStream`] lifetime that returns a
455/// boxed future. The boilerplate is unavoidable — the trait method returns
456/// a boxed future — but the closure body is what you'd write in an
457/// `impl Interceptor` block:
458///
459/// ```rust,ignore
460/// let logging = streaming_interceptor(|req, inbound, next| Box::pin(async move {
461///     tracing::info!(path = req.ctx.path(), "stream open");
462///     next.run(req, inbound).await
463/// }));
464/// ```
465pub fn streaming_interceptor<F>(f: F) -> impl Interceptor
466where
467    F: for<'a> Fn(
468            StreamRequest,
469            PayloadStream,
470            NextStream<'a>,
471        ) -> BoxFuture<'a, Result<StreamResponse, ConnectError>>
472        + Send
473        + Sync
474        + 'static,
475{
476    struct FnInterceptor<F>(F);
477
478    #[async_trait::async_trait]
479    impl<F> Interceptor for FnInterceptor<F>
480    where
481        F: for<'a> Fn(
482                StreamRequest,
483                PayloadStream,
484                NextStream<'a>,
485            ) -> BoxFuture<'a, Result<StreamResponse, ConnectError>>
486            + Send
487            + Sync
488            + 'static,
489    {
490        async fn intercept_streaming(
491            &self,
492            req: StreamRequest,
493            inbound: PayloadStream,
494            next: NextStream<'_>,
495        ) -> Result<StreamResponse, ConnectError> {
496            (self.0)(req, inbound, next).await
497        }
498    }
499
500    FnInterceptor(f)
501}
502
503/// The continuation an [`Interceptor`] calls to run the rest of a
504/// streaming chain.
505///
506/// `NextStream` holds the still-to-run interceptors and the terminal
507/// handler. [`run`](NextStream::run) consumes it: an interceptor can call
508/// `next.run(req, inbound)` at most once. Not calling it short-circuits.
509pub struct NextStream<'a> {
510    rest: &'a [Arc<dyn Interceptor>],
511    terminal: &'a (dyn StreamTerminal + 'a),
512}
513
514impl<'a> NextStream<'a> {
515    /// Construct the head of a chain.
516    pub(crate) fn new(
517        rest: &'a [Arc<dyn Interceptor>],
518        terminal: &'a (dyn StreamTerminal + 'a),
519    ) -> Self {
520        Self { rest, terminal }
521    }
522
523    /// Run the rest of the chain — the next interceptor if any, otherwise
524    /// the terminal handler — and return its response.
525    ///
526    /// # Errors
527    ///
528    /// Returns whatever error the next interceptor or handler produced.
529    pub async fn run(
530        self,
531        req: StreamRequest,
532        inbound: PayloadStream,
533    ) -> Result<StreamResponse, ConnectError> {
534        match self.rest.split_first() {
535            Some((head, tail)) => {
536                head.intercept_streaming(
537                    req,
538                    inbound,
539                    NextStream {
540                        rest: tail,
541                        terminal: self.terminal,
542                    },
543                )
544                .await
545            }
546            None => self.terminal.call(req, inbound).await,
547        }
548    }
549}
550
551impl std::fmt::Debug for NextStream<'_> {
552    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
553        f.debug_struct("NextStream")
554            .field("remaining", &self.rest.len())
555            .finish_non_exhaustive()
556    }
557}
558
559/// The terminal step of a streaming interceptor chain: hand the inbound
560/// stream to the dispatcher and wrap the outbound stream.
561///
562/// `pub(crate)` because the only producer is the dispatch path. Tests
563/// inside the crate can supply mocks.
564#[async_trait::async_trait]
565pub(crate) trait StreamTerminal: Send + Sync {
566    async fn call(
567        &self,
568        req: StreamRequest,
569        inbound: PayloadStream,
570    ) -> Result<StreamResponse, ConnectError>;
571}
572
573/// Run a streaming interceptor chain against a closure terminal.
574///
575/// The streaming counterpart of [`run_chain`]. For **unit-testing** an
576/// [`Interceptor`] without a tower service or TCP listener.
577///
578/// ```rust,ignore
579/// let chain: Vec<Arc<dyn Interceptor>> = vec![Arc::new(MyInterceptor)];
580/// let resp = connectrpc::interceptor::run_chain_streaming(
581///     &chain,
582///     my_req,
583///     my_inbound_stream,
584///     |req, inbound| async move {
585///         // assert what the handler would see
586///         Ok(StreamResponse::from_encoded(/* ... */, CodecFormat::Proto))
587///     },
588/// )
589/// .await?;
590/// ```
591///
592/// # Errors
593///
594/// Returns whatever error the chain or terminal produces.
595pub async fn run_chain_streaming<F, Fut>(
596    interceptors: &[Arc<dyn Interceptor>],
597    req: StreamRequest,
598    inbound: PayloadStream,
599    terminal: F,
600) -> Result<StreamResponse, ConnectError>
601where
602    F: Fn(StreamRequest, PayloadStream) -> Fut + Send + Sync,
603    Fut: std::future::Future<Output = Result<StreamResponse, ConnectError>> + Send,
604{
605    struct FnTerminal<F>(F);
606
607    #[async_trait::async_trait]
608    impl<F, Fut> StreamTerminal for FnTerminal<F>
609    where
610        F: Fn(StreamRequest, PayloadStream) -> Fut + Send + Sync,
611        Fut: std::future::Future<Output = Result<StreamResponse, ConnectError>> + Send,
612    {
613        async fn call(
614            &self,
615            req: StreamRequest,
616            inbound: PayloadStream,
617        ) -> Result<StreamResponse, ConnectError> {
618            (self.0)(req, inbound).await
619        }
620    }
621
622    let terminal = FnTerminal(terminal);
623    NextStream::new(interceptors, &terminal)
624        .run(req, inbound)
625        .await
626}
627
628// ============================================================================
629// Streaming dispatch glue
630// ============================================================================
631
632/// Convert a [`PayloadStream`] back to the dispatcher's `RequestStream`
633/// (raw `Bytes`) by re-encoding each [`Payload`].
634fn payload_stream_to_request_stream(stream: PayloadStream) -> RequestStream {
635    Box::pin(stream.map(|item| item.and_then(|payload| payload.encoded())))
636}
637
638/// Wrap a dispatcher inbound `RequestStream` (raw `Bytes`) as a
639/// [`PayloadStream`] for the interceptor chain.
640fn request_stream_to_payload_stream(stream: RequestStream, format: CodecFormat) -> PayloadStream {
641    Box::pin(stream.map(move |item| item.map(|bytes| Payload::new(bytes, format))))
642}
643
644/// Run a server-streaming call through the interceptor chain, or skip
645/// straight to the dispatcher when there are no interceptors.
646///
647/// Server-streaming has a single request `Bytes`, presented to the
648/// interceptor as a 1-item inbound stream. The terminal pulls the single
649/// item back out and hands it to [`Dispatcher::call_server_streaming`].
650pub(crate) async fn call_server_streaming_intercepted<D: crate::Dispatcher>(
651    dispatcher: &D,
652    interceptors: &[Arc<dyn Interceptor>],
653    path: &str,
654    ctx: RequestContext,
655    body: Bytes,
656    format: CodecFormat,
657) -> Result<Response<BoxStream<Result<Bytes, ConnectError>>>, ConnectError> {
658    if interceptors.is_empty() {
659        return dispatcher
660            .call_server_streaming(path, ctx, body, format)
661            .await;
662    }
663    let terminal = ServerStreamingTerminal {
664        dispatcher,
665        path,
666        format,
667    };
668    let req = StreamRequest::new(ctx);
669    let inbound: PayloadStream = Box::pin(futures::stream::once(async move {
670        Ok(Payload::new(body, format))
671    }));
672    let resp = NextStream::new(interceptors, &terminal)
673        .run(req, inbound)
674        .await?;
675    Ok(resp.into_encoded())
676}
677
678/// Run a client-streaming call through the interceptor chain.
679///
680/// Client-streaming has an inbound stream and a single response, presented
681/// to the chain as a 1-item outbound stream. `call_client_streaming_intercepted`
682/// pulls the single item back out for the dispatch path.
683pub(crate) async fn call_client_streaming_intercepted<D: crate::Dispatcher>(
684    dispatcher: &D,
685    interceptors: &[Arc<dyn Interceptor>],
686    path: &str,
687    ctx: RequestContext,
688    requests: RequestStream,
689    format: CodecFormat,
690) -> Result<EncodedResponse, ConnectError> {
691    if interceptors.is_empty() {
692        return dispatcher
693            .call_client_streaming(path, ctx, requests, format)
694            .await;
695    }
696    let terminal = ClientStreamingTerminal {
697        dispatcher,
698        path,
699        format,
700    };
701    let req = StreamRequest::new(ctx);
702    let inbound = request_stream_to_payload_stream(requests, format);
703    let resp = NextStream::new(interceptors, &terminal)
704        .run(req, inbound)
705        .await?;
706    // The terminal produced a 1-item outbound stream; collapse it. The
707    // single item carries the (possibly replaced) response body. An
708    // interceptor that filtered the item away is a programming error;
709    // fail with `Internal` rather than send an empty response.
710    let Response {
711        body: mut stream,
712        headers,
713        trailers,
714        compress,
715    } = resp;
716    let body = match stream.next().await {
717        Some(Ok(payload)) => payload.encoded()?,
718        Some(Err(e)) => return Err(e),
719        None => {
720            return Err(ConnectError::internal(
721                "client-streaming interceptor consumed the response without replacing it",
722            ));
723        }
724    };
725    Ok(Response {
726        body: body.into(),
727        headers,
728        trailers,
729        compress,
730    })
731}
732
733/// Run a bidi-streaming call through the interceptor chain.
734pub(crate) async fn call_bidi_streaming_intercepted<D: crate::Dispatcher>(
735    dispatcher: &D,
736    interceptors: &[Arc<dyn Interceptor>],
737    path: &str,
738    ctx: RequestContext,
739    requests: RequestStream,
740    format: CodecFormat,
741) -> Result<Response<BoxStream<Result<Bytes, ConnectError>>>, ConnectError> {
742    if interceptors.is_empty() {
743        return dispatcher
744            .call_bidi_streaming(path, ctx, requests, format)
745            .await;
746    }
747    let terminal = BidiStreamingTerminal {
748        dispatcher,
749        path,
750        format,
751    };
752    let req = StreamRequest::new(ctx);
753    let inbound = request_stream_to_payload_stream(requests, format);
754    let resp = NextStream::new(interceptors, &terminal)
755        .run(req, inbound)
756        .await?;
757    Ok(resp.into_encoded())
758}
759
760/// `StreamTerminal` that hands off to the dispatcher's
761/// `call_server_streaming`.
762struct ServerStreamingTerminal<'a, D> {
763    dispatcher: &'a D,
764    path: &'a str,
765    format: CodecFormat,
766}
767
768#[async_trait::async_trait]
769impl<D: crate::Dispatcher> StreamTerminal for ServerStreamingTerminal<'_, D> {
770    async fn call(
771        &self,
772        req: StreamRequest,
773        mut inbound: PayloadStream,
774    ) -> Result<StreamResponse, ConnectError> {
775        // The dispatch path provided a 1-item inbound stream. An
776        // interceptor that filtered it away is a programming error;
777        // fail rather than dispatch with no body.
778        let body = match inbound.next().await {
779            Some(Ok(payload)) => payload.encoded()?,
780            Some(Err(e)) => return Err(e),
781            None => {
782                return Err(ConnectError::internal(
783                    "server-streaming interceptor consumed the request without replacing it",
784                ));
785            }
786        };
787        let resp = self
788            .dispatcher
789            .call_server_streaming(self.path, req.ctx, body, self.format)
790            .await?;
791        Ok(StreamResponse::from_encoded(resp, self.format))
792    }
793}
794
795/// `StreamTerminal` that hands off to the dispatcher's
796/// `call_client_streaming`.
797struct ClientStreamingTerminal<'a, D> {
798    dispatcher: &'a D,
799    path: &'a str,
800    format: CodecFormat,
801}
802
803#[async_trait::async_trait]
804impl<D: crate::Dispatcher> StreamTerminal for ClientStreamingTerminal<'_, D> {
805    async fn call(
806        &self,
807        req: StreamRequest,
808        inbound: PayloadStream,
809    ) -> Result<StreamResponse, ConnectError> {
810        let requests = payload_stream_to_request_stream(inbound);
811        let resp = self
812            .dispatcher
813            .call_client_streaming(self.path, req.ctx, requests, self.format)
814            .await?;
815        let format = self.format;
816        // Wrap the single response in a 1-item outbound stream so the
817        // chain has a uniform type. `call_client_streaming_intercepted`
818        // pulls it back out for the dispatch path.
819        Ok(resp.map_body(move |body| -> PayloadStream {
820            Box::pin(futures::stream::once(async move {
821                Ok(Payload::new(body.into_contiguous(), format))
822            }))
823        }))
824    }
825}
826
827/// `StreamTerminal` that hands off to the dispatcher's
828/// `call_bidi_streaming`.
829struct BidiStreamingTerminal<'a, D> {
830    dispatcher: &'a D,
831    path: &'a str,
832    format: CodecFormat,
833}
834
835#[async_trait::async_trait]
836impl<D: crate::Dispatcher> StreamTerminal for BidiStreamingTerminal<'_, D> {
837    async fn call(
838        &self,
839        req: StreamRequest,
840        inbound: PayloadStream,
841    ) -> Result<StreamResponse, ConnectError> {
842        let requests = payload_stream_to_request_stream(inbound);
843        let resp = self
844            .dispatcher
845            .call_bidi_streaming(self.path, req.ctx, requests, self.format)
846            .await?;
847        Ok(StreamResponse::from_encoded(resp, self.format))
848    }
849}
850
851/// Run an interceptor chain against a closure terminal.
852///
853/// The dispatch path constructs [`Next`] internally; this helper is for
854/// **unit-testing** an [`Interceptor`] without spinning up a tower
855/// service or a TCP listener. The `terminal` closure stands in for the
856/// handler.
857///
858/// ```rust,ignore
859/// let trace = Arc::new(Mutex::new(Vec::new()));
860/// let chain: Vec<Arc<dyn Interceptor>> = vec![Arc::new(MyInterceptor)];
861/// let resp = connectrpc::interceptor::run_chain(&chain, my_req, |req| async move {
862///     // assert what the handler would see
863///     Ok(UnaryResponse::from_encoded(EncodedResponse::new(Bytes::new().into()), CodecFormat::Proto))
864/// })
865/// .await?;
866/// ```
867///
868/// # Errors
869///
870/// Returns whatever error the chain or terminal produces.
871pub async fn run_chain<F, Fut>(
872    interceptors: &[Arc<dyn Interceptor>],
873    req: UnaryRequest,
874    terminal: F,
875) -> Result<UnaryResponse, ConnectError>
876where
877    F: Fn(UnaryRequest) -> Fut + Send + Sync,
878    Fut: std::future::Future<Output = Result<UnaryResponse, ConnectError>> + Send,
879{
880    struct FnTerminal<F>(F);
881
882    #[async_trait::async_trait]
883    impl<F, Fut> UnaryTerminal for FnTerminal<F>
884    where
885        F: Fn(UnaryRequest) -> Fut + Send + Sync,
886        Fut: std::future::Future<Output = Result<UnaryResponse, ConnectError>> + Send,
887    {
888        async fn call(&self, req: UnaryRequest) -> Result<UnaryResponse, ConnectError> {
889            (self.0)(req).await
890        }
891    }
892
893    let terminal = FnTerminal(terminal);
894    Next::new(interceptors, &terminal).run(req).await
895}
896
897/// Run a unary call through the interceptor chain, or skip straight to the
898/// dispatcher when there are no interceptors.
899///
900/// The empty-chain path makes a single `is_empty` check and delegates;
901/// it does not build a [`UnaryRequest`], a [`Next`], or any chain
902/// machinery. The [`Payload::new`] wrap is plain struct construction —
903/// no allocation, no decode — so a service with no interceptors pays
904/// nothing.
905pub(crate) async fn call_unary_intercepted<D: crate::Dispatcher>(
906    dispatcher: &D,
907    interceptors: &[Arc<dyn Interceptor>],
908    path: &str,
909    ctx: RequestContext,
910    body: Bytes,
911    format: CodecFormat,
912) -> Result<EncodedResponse, ConnectError> {
913    if interceptors.is_empty() {
914        let payload = Payload::new(body, format).with_decode_options(ctx.decode_options().clone());
915        return dispatcher.call_unary(path, ctx, payload, format).await;
916    }
917    let terminal = DispatchTerminal {
918        dispatcher,
919        path,
920        format,
921    };
922    let req = UnaryRequest::new(ctx, body, format);
923    let resp = Next::new(interceptors, &terminal).run(req).await?;
924    resp.into_encoded()
925}
926
927/// `UnaryTerminal` that hands off to the dispatcher's `call_unary`.
928struct DispatchTerminal<'a, D> {
929    dispatcher: &'a D,
930    path: &'a str,
931    format: CodecFormat,
932}
933
934#[async_trait::async_trait]
935impl<D: crate::Dispatcher> UnaryTerminal for DispatchTerminal<'_, D> {
936    async fn call(&self, req: UnaryRequest) -> Result<UnaryResponse, ConnectError> {
937        let UnaryRequest { ctx, payload } = req;
938        // Hand the Payload — not raw bytes — to the dispatcher, so an
939        // owned-message handler can reuse a decode an interceptor cached.
940        let resp = self
941            .dispatcher
942            .call_unary(self.path, ctx, payload, self.format)
943            .await?;
944        Ok(UnaryResponse::from_encoded(resp, self.format))
945    }
946}
947
948#[cfg(test)]
949mod tests {
950    use super::*;
951    use crate::codec::encode_proto;
952    use buffa_types::google::protobuf::StringValue;
953    use std::sync::Mutex;
954
955    /// A terminal that records whether it ran and returns a fixed body.
956    struct RecordingTerminal {
957        ran: Mutex<bool>,
958        respond_with: &'static str,
959    }
960
961    #[async_trait::async_trait]
962    impl UnaryTerminal for RecordingTerminal {
963        async fn call(&self, req: UnaryRequest) -> Result<UnaryResponse, ConnectError> {
964            *self.ran.lock().unwrap() = true;
965            // Echo the (possibly replaced) request body length in a header
966            // so tests can verify mutation reached the terminal.
967            let in_len = req.payload.encoded()?.len().to_string();
968            let body = encode_proto(&StringValue {
969                value: self.respond_with.into(),
970                ..Default::default()
971            })?;
972            let mut resp = EncodedResponse::new(body.into());
973            resp.headers.insert("x-in-len", in_len.parse().unwrap());
974            Ok(UnaryResponse::from_encoded(resp, CodecFormat::Proto))
975        }
976    }
977
978    fn req() -> UnaryRequest {
979        let body = encode_proto(&StringValue {
980            value: "hi".into(),
981            ..Default::default()
982        })
983        .unwrap();
984        UnaryRequest::new(RequestContext::default(), body, CodecFormat::Proto)
985    }
986
987    /// Interceptor that pushes a label into the request extensions on the
988    /// way in and prepends it to a response header on the way out, so the
989    /// test can assert nesting order.
990    struct Tagger(&'static str);
991
992    #[derive(Clone, Default)]
993    struct Trace(Arc<Mutex<Vec<&'static str>>>);
994
995    #[async_trait::async_trait]
996    impl Interceptor for Tagger {
997        async fn intercept_unary(
998            &self,
999            mut req: UnaryRequest,
1000            next: Next<'_>,
1001        ) -> Result<UnaryResponse, ConnectError> {
1002            req.ctx
1003                .extensions
1004                .get_or_insert_default::<Trace>()
1005                .0
1006                .lock()
1007                .unwrap()
1008                .push(self.0);
1009            let resp = next.run(req).await?;
1010            Ok(resp.with_header("x-trace", format!("{}-out", self.0)))
1011        }
1012    }
1013
1014    #[tokio::test]
1015    async fn ordering_first_registered_is_outermost() {
1016        let trace = Trace::default();
1017        let chain: Vec<Arc<dyn Interceptor>> = vec![
1018            Arc::new(Tagger("a")),
1019            Arc::new(Tagger("b")),
1020            Arc::new(Tagger("c")),
1021        ];
1022        let terminal = RecordingTerminal {
1023            ran: Mutex::new(false),
1024            respond_with: "ok",
1025        };
1026        let mut request = req();
1027        request.ctx.extensions.insert(trace.clone());
1028        let resp = Next::new(&chain, &terminal).run(request).await.unwrap();
1029        assert!(*terminal.ran.lock().unwrap(), "terminal should have run");
1030        // Way in: outermost first.
1031        assert_eq!(*trace.0.lock().unwrap(), vec!["a", "b", "c"]);
1032        // Way out: innermost appends to headers first (HeaderMap::append
1033        // preserves insertion order), so "c-out" is first and "a-out" last.
1034        let outs: Vec<_> = resp
1035            .headers
1036            .get_all("x-trace")
1037            .iter()
1038            .map(|v| v.to_str().unwrap().to_owned())
1039            .collect();
1040        assert_eq!(outs, vec!["c-out", "b-out", "a-out"]);
1041    }
1042
1043    #[tokio::test]
1044    async fn short_circuit_skips_terminal() {
1045        struct Reject;
1046        #[async_trait::async_trait]
1047        impl Interceptor for Reject {
1048            async fn intercept_unary(
1049                &self,
1050                _req: UnaryRequest,
1051                _next: Next<'_>,
1052            ) -> Result<UnaryResponse, ConnectError> {
1053                // Auth interceptors attach diagnostic headers (e.g. an
1054                // operator-facing "which policy denied" hint) to the deny
1055                // error. Those must reach the wire response.
1056                let mut headers = http::HeaderMap::new();
1057                headers.insert("x-deny-policy", "p1".parse().unwrap());
1058                Err(ConnectError::permission_denied("nope").with_headers(headers))
1059            }
1060        }
1061        let chain: Vec<Arc<dyn Interceptor>> = vec![Arc::new(Reject), Arc::new(Tagger("never"))];
1062        let terminal = RecordingTerminal {
1063            ran: Mutex::new(false),
1064            respond_with: "ok",
1065        };
1066        let err = Next::new(&chain, &terminal).run(req()).await.unwrap_err();
1067        assert_eq!(err.code, crate::ErrorCode::PermissionDenied);
1068        assert!(!*terminal.ran.lock().unwrap(), "terminal must not run");
1069        // The chain must not strip response headers off a short-circuit
1070        // error: they reach the dispatch path and the protocol-aware error
1071        // renderers (`error_response`, `grpc_error_response`,
1072        // `ConnectError::into_http_response`) walk `response_headers()`
1073        // when building the wire response.
1074        assert_eq!(
1075            err.response_headers().get("x-deny-policy").unwrap(),
1076            "p1",
1077            "diagnostic headers on a short-circuit error must survive the chain"
1078        );
1079    }
1080
1081    /// `call_unary_intercepted` propagates a short-circuit error verbatim,
1082    /// including response headers, so the caller's error renderer can put
1083    /// them on the wire. Pinned because an auth interceptor relies on it.
1084    #[tokio::test]
1085    async fn call_unary_intercepted_propagates_error_headers() {
1086        struct Reject;
1087        #[async_trait::async_trait]
1088        impl Interceptor for Reject {
1089            async fn intercept_unary(
1090                &self,
1091                _req: UnaryRequest,
1092                _next: Next<'_>,
1093            ) -> Result<UnaryResponse, ConnectError> {
1094                let mut headers = http::HeaderMap::new();
1095                headers.insert("x-deny-policy", "p1".parse().unwrap());
1096                Err(ConnectError::permission_denied("nope").with_headers(headers))
1097            }
1098        }
1099        struct PanickyDispatcher;
1100        impl crate::Dispatcher for PanickyDispatcher {
1101            fn lookup(&self, _: &str) -> Option<crate::dispatcher::MethodDescriptor> {
1102                None
1103            }
1104            fn call_unary(
1105                &self,
1106                _: &str,
1107                _: RequestContext,
1108                _: Payload,
1109                _: CodecFormat,
1110            ) -> crate::dispatcher::UnaryResult {
1111                unreachable!("dispatcher must not be reached when an interceptor short-circuits")
1112            }
1113            fn call_server_streaming(
1114                &self,
1115                _: &str,
1116                _: RequestContext,
1117                _: Bytes,
1118                _: CodecFormat,
1119            ) -> crate::dispatcher::StreamingResult {
1120                unreachable!()
1121            }
1122            fn call_client_streaming(
1123                &self,
1124                _: &str,
1125                _: RequestContext,
1126                _: crate::dispatcher::RequestStream,
1127                _: CodecFormat,
1128            ) -> crate::dispatcher::UnaryResult {
1129                unreachable!()
1130            }
1131            fn call_bidi_streaming(
1132                &self,
1133                _: &str,
1134                _: RequestContext,
1135                _: crate::dispatcher::RequestStream,
1136                _: CodecFormat,
1137            ) -> crate::dispatcher::StreamingResult {
1138                unreachable!()
1139            }
1140        }
1141        let chain: Vec<Arc<dyn Interceptor>> = vec![Arc::new(Reject)];
1142        let err = call_unary_intercepted(
1143            &PanickyDispatcher,
1144            &chain,
1145            "p",
1146            RequestContext::default(),
1147            Bytes::new(),
1148            CodecFormat::Proto,
1149        )
1150        .await
1151        .unwrap_err();
1152        assert_eq!(err.code, crate::ErrorCode::PermissionDenied);
1153        assert_eq!(err.response_headers().get("x-deny-policy").unwrap(), "p1");
1154    }
1155
1156    #[tokio::test]
1157    async fn mutation_replaces_request_body() {
1158        struct Replace;
1159        #[async_trait::async_trait]
1160        impl Interceptor for Replace {
1161            async fn intercept_unary(
1162                &self,
1163                mut req: UnaryRequest,
1164                next: Next<'_>,
1165            ) -> Result<UnaryResponse, ConnectError> {
1166                req.payload.set_message(StringValue {
1167                    value: "rewritten by interceptor".into(),
1168                    ..Default::default()
1169                });
1170                next.run(req).await
1171            }
1172        }
1173        let chain: Vec<Arc<dyn Interceptor>> = vec![Arc::new(Replace)];
1174        let terminal = RecordingTerminal {
1175            ran: Mutex::new(false),
1176            respond_with: "ok",
1177        };
1178        let resp = Next::new(&chain, &terminal).run(req()).await.unwrap();
1179        // The terminal re-encoded the replaced message; its length differs
1180        // from the original ("hi" -> 4 bytes) and is recorded in the header.
1181        let in_len: usize = resp
1182            .headers
1183            .get("x-in-len")
1184            .unwrap()
1185            .to_str()
1186            .unwrap()
1187            .parse()
1188            .unwrap();
1189        let original_len = req().payload.encoded().unwrap().len();
1190        assert_ne!(in_len, original_len, "terminal should see the replacement");
1191    }
1192
1193    #[tokio::test]
1194    async fn closure_interceptor_works() {
1195        let i = unary_interceptor(|req, next| {
1196            Box::pin(async move {
1197                let resp = next.run(req).await?;
1198                Ok(resp.with_header("x-fn", "1"))
1199            })
1200        });
1201        let chain: Vec<Arc<dyn Interceptor>> = vec![Arc::new(i)];
1202        // Exercise the public test helper that downstream crates use.
1203        let resp = run_chain(&chain, req(), |_| async {
1204            Ok(UnaryResponse::from_encoded(
1205                EncodedResponse::new(Bytes::new().into()),
1206                CodecFormat::Proto,
1207            ))
1208        })
1209        .await
1210        .unwrap();
1211        assert_eq!(resp.headers.get("x-fn").unwrap(), "1");
1212    }
1213
1214    /// Trailers and the compression hint must round-trip through a
1215    /// passthrough chain — `into_encoded` preserves all `Response`
1216    /// metadata, not just the body.
1217    #[tokio::test]
1218    async fn passthrough_chain_preserves_response_metadata() {
1219        struct Passthrough;
1220        #[async_trait::async_trait]
1221        impl Interceptor for Passthrough {}
1222        let chain: Vec<Arc<dyn Interceptor>> = vec![Arc::new(Passthrough)];
1223        let resp = run_chain(&chain, req(), |_| async {
1224            let mut r = EncodedResponse::new(Bytes::from_static(b"x").into());
1225            r.headers.insert("x-h", "1".parse().unwrap());
1226            r.trailers.insert("x-t", "2".parse().unwrap());
1227            r.compress = Some(true);
1228            Ok(UnaryResponse::from_encoded(r, CodecFormat::Proto))
1229        })
1230        .await
1231        .unwrap();
1232        let encoded = resp.into_encoded().unwrap();
1233        assert_eq!(encoded.headers.get("x-h").unwrap(), "1");
1234        assert_eq!(encoded.trailers.get("x-t").unwrap(), "2");
1235        assert_eq!(encoded.compress, Some(true));
1236        assert_eq!(&*encoded.body.into_contiguous(), b"x");
1237    }
1238
1239    #[tokio::test]
1240    async fn empty_chain_is_no_op() {
1241        // `call_unary_intercepted` with an empty slice delegates straight
1242        // to the dispatcher. The response bytes must come straight from
1243        // the dispatcher (refcount-shared with the request that the echo
1244        // dispatcher returned). Note: a `Bytes` clone shares the backing
1245        // pointer, so this test alone doesn't *uniquely* prove the
1246        // `UnaryRequest`-free fast path — that property is guarded by the
1247        // conformance suite, which only ever runs the empty chain.
1248        struct Echo;
1249        impl crate::Dispatcher for Echo {
1250            fn lookup(&self, _: &str) -> Option<crate::dispatcher::MethodDescriptor> {
1251                None
1252            }
1253            fn call_unary(
1254                &self,
1255                _: &str,
1256                _: RequestContext,
1257                request: Payload,
1258                _: CodecFormat,
1259            ) -> crate::dispatcher::UnaryResult {
1260                Box::pin(async move { Ok(EncodedResponse::new(request.encoded()?.into())) })
1261            }
1262            fn call_server_streaming(
1263                &self,
1264                _: &str,
1265                _: RequestContext,
1266                _: Bytes,
1267                _: CodecFormat,
1268            ) -> crate::dispatcher::StreamingResult {
1269                unimplemented!()
1270            }
1271            fn call_client_streaming(
1272                &self,
1273                _: &str,
1274                _: RequestContext,
1275                _: crate::dispatcher::RequestStream,
1276                _: CodecFormat,
1277            ) -> crate::dispatcher::UnaryResult {
1278                unimplemented!()
1279            }
1280            fn call_bidi_streaming(
1281                &self,
1282                _: &str,
1283                _: RequestContext,
1284                _: crate::dispatcher::RequestStream,
1285                _: CodecFormat,
1286            ) -> crate::dispatcher::StreamingResult {
1287                unimplemented!()
1288            }
1289        }
1290        let body = Bytes::from_static(b"x");
1291        let resp = call_unary_intercepted(
1292            &Echo,
1293            &[],
1294            "p",
1295            RequestContext::default(),
1296            body.clone(),
1297            CodecFormat::Proto,
1298        )
1299        .await
1300        .unwrap();
1301        // Same backing storage — no copy through Payload.
1302        assert!(std::ptr::eq(
1303            resp.body.into_contiguous().as_ptr(),
1304            body.as_ptr()
1305        ));
1306    }
1307
1308    /// `DispatchTerminal` hands the `Payload` — not raw bytes — to the
1309    /// dispatcher, so an owned-message handler can `take_message()` and
1310    /// reuse the decode an interceptor cached.
1311    ///
1312    /// Pinned by handing the dispatcher a `Payload` whose wire bytes are
1313    /// *garbage* but whose cache an interceptor populated by replacement.
1314    /// If the terminal stripped the `Payload` to bytes (the pre-this-PR
1315    /// behavior), the dispatcher's `take_message` would error on the
1316    /// garbage; if it forwards the `Payload`, the dispatcher sees the
1317    /// replacement.
1318    #[tokio::test]
1319    async fn dispatch_terminal_forwards_payload_to_handler() {
1320        let captured = Arc::new(Mutex::new(None::<String>));
1321
1322        // A dispatcher that decodes via `take_message` — the path an
1323        // owned-message `Router::route` handler takes.
1324        struct Capture(Arc<Mutex<Option<String>>>);
1325        impl crate::Dispatcher for Capture {
1326            fn lookup(&self, _: &str) -> Option<crate::dispatcher::MethodDescriptor> {
1327                None
1328            }
1329            fn call_unary(
1330                &self,
1331                _: &str,
1332                _: RequestContext,
1333                request: Payload,
1334                _: CodecFormat,
1335            ) -> crate::dispatcher::UnaryResult {
1336                let captured = Arc::clone(&self.0);
1337                Box::pin(async move {
1338                    let m: StringValue = request.take_message()?;
1339                    *captured.lock().unwrap() = Some(m.value);
1340                    Ok(EncodedResponse::new(Bytes::new().into()))
1341                })
1342            }
1343            fn call_server_streaming(
1344                &self,
1345                _: &str,
1346                _: RequestContext,
1347                _: Bytes,
1348                _: CodecFormat,
1349            ) -> crate::dispatcher::StreamingResult {
1350                unreachable!()
1351            }
1352            fn call_client_streaming(
1353                &self,
1354                _: &str,
1355                _: RequestContext,
1356                _: crate::dispatcher::RequestStream,
1357                _: CodecFormat,
1358            ) -> crate::dispatcher::UnaryResult {
1359                unreachable!()
1360            }
1361            fn call_bidi_streaming(
1362                &self,
1363                _: &str,
1364                _: RequestContext,
1365                _: crate::dispatcher::RequestStream,
1366                _: CodecFormat,
1367            ) -> crate::dispatcher::StreamingResult {
1368                unreachable!()
1369            }
1370        }
1371
1372        // Interceptor that replaces the request body. The original wire
1373        // bytes are garbage — only the replacement is valid.
1374        struct Replace;
1375        #[async_trait::async_trait]
1376        impl Interceptor for Replace {
1377            async fn intercept_unary(
1378                &self,
1379                mut req: UnaryRequest,
1380                next: Next<'_>,
1381            ) -> Result<UnaryResponse, ConnectError> {
1382                req.payload.set_message(StringValue {
1383                    value: "from interceptor".into(),
1384                    ..Default::default()
1385                });
1386                next.run(req).await
1387            }
1388        }
1389
1390        let chain: Vec<Arc<dyn Interceptor>> = vec![Arc::new(Replace)];
1391        call_unary_intercepted(
1392            &Capture(Arc::clone(&captured)),
1393            &chain,
1394            "p",
1395            RequestContext::default(),
1396            // Garbage wire bytes: would error on a fresh decode.
1397            Bytes::from_static(&[0xff, 0xff, 0xff]),
1398            CodecFormat::Proto,
1399        )
1400        .await
1401        .unwrap();
1402
1403        assert_eq!(
1404            captured.lock().unwrap().as_deref(),
1405            Some("from interceptor"),
1406            "the dispatcher must see the interceptor's replacement, not re-decode the wire bytes"
1407        );
1408    }
1409
1410    // ========================================================================
1411    // Streaming interceptor tests
1412    // ========================================================================
1413
1414    /// Build a `PayloadStream` from string values.
1415    fn payload_stream(values: &[&'static str]) -> PayloadStream {
1416        let items: Vec<Result<Payload, ConnectError>> = values
1417            .iter()
1418            .map(|v| {
1419                let bytes = encode_proto(&StringValue {
1420                    value: (*v).into(),
1421                    ..Default::default()
1422                })
1423                .unwrap();
1424                Ok(Payload::new(bytes, CodecFormat::Proto))
1425            })
1426            .collect();
1427        Box::pin(futures::stream::iter(items))
1428    }
1429
1430    /// Drain a `PayloadStream` to decoded `StringValue`s.
1431    async fn collect_strings(stream: PayloadStream) -> Vec<String> {
1432        stream
1433            .map(|item| {
1434                item.unwrap()
1435                    .message::<StringValue>()
1436                    .unwrap()
1437                    .value
1438                    .clone()
1439            })
1440            .collect()
1441            .await
1442    }
1443
1444    /// Streaming counterpart of `Tagger`: pushes a label into request
1445    /// extensions inbound, appends to a header outbound.
1446    struct StreamTagger(&'static str);
1447
1448    #[async_trait::async_trait]
1449    impl Interceptor for StreamTagger {
1450        async fn intercept_streaming(
1451            &self,
1452            mut req: StreamRequest,
1453            inbound: PayloadStream,
1454            next: NextStream<'_>,
1455        ) -> Result<StreamResponse, ConnectError> {
1456            req.ctx
1457                .extensions
1458                .get_or_insert_default::<Trace>()
1459                .0
1460                .lock()
1461                .unwrap()
1462                .push(self.0);
1463            let resp = next.run(req, inbound).await?;
1464            Ok(resp.with_header("x-trace", format!("{}-out", self.0)))
1465        }
1466    }
1467
1468    /// A streaming terminal that records whether it ran, drains the
1469    /// inbound stream into a header, and produces a fixed outbound stream.
1470    struct RecordingStreamTerminal {
1471        ran: Mutex<bool>,
1472        respond_with: Vec<&'static str>,
1473    }
1474
1475    #[async_trait::async_trait]
1476    impl StreamTerminal for RecordingStreamTerminal {
1477        async fn call(
1478            &self,
1479            _req: StreamRequest,
1480            inbound: PayloadStream,
1481        ) -> Result<StreamResponse, ConnectError> {
1482            *self.ran.lock().unwrap() = true;
1483            let inbound_values = collect_strings(inbound).await;
1484            let body: PayloadStream = payload_stream(&self.respond_with);
1485            let resp = Response {
1486                body,
1487                headers: http::HeaderMap::new(),
1488                trailers: http::HeaderMap::new(),
1489                compress: None,
1490            };
1491            Ok(resp.with_header("x-inbound", inbound_values.join(",")))
1492        }
1493    }
1494
1495    fn stream_req() -> StreamRequest {
1496        StreamRequest::new(RequestContext::default())
1497    }
1498
1499    #[tokio::test]
1500    async fn streaming_ordering_first_registered_is_outermost() {
1501        let trace = Trace::default();
1502        let chain: Vec<Arc<dyn Interceptor>> = vec![
1503            Arc::new(StreamTagger("a")),
1504            Arc::new(StreamTagger("b")),
1505            Arc::new(StreamTagger("c")),
1506        ];
1507        let terminal = RecordingStreamTerminal {
1508            ran: Mutex::new(false),
1509            respond_with: vec!["ok"],
1510        };
1511        let mut request = stream_req();
1512        request.ctx.extensions.insert(trace.clone());
1513        let resp = NextStream::new(&chain, &terminal)
1514            .run(request, payload_stream(&["x"]))
1515            .await
1516            .unwrap();
1517        assert!(*terminal.ran.lock().unwrap(), "terminal should have run");
1518        // Way in: outermost first.
1519        assert_eq!(*trace.0.lock().unwrap(), vec!["a", "b", "c"]);
1520        // Way out: innermost appends first.
1521        let outs: Vec<_> = resp
1522            .headers
1523            .get_all("x-trace")
1524            .iter()
1525            .map(|v| v.to_str().unwrap().to_owned())
1526            .collect();
1527        assert_eq!(outs, vec!["c-out", "b-out", "a-out"]);
1528    }
1529
1530    #[tokio::test]
1531    async fn streaming_short_circuit_skips_terminal() {
1532        struct Reject;
1533        #[async_trait::async_trait]
1534        impl Interceptor for Reject {
1535            async fn intercept_streaming(
1536                &self,
1537                _req: StreamRequest,
1538                _inbound: PayloadStream,
1539                _next: NextStream<'_>,
1540            ) -> Result<StreamResponse, ConnectError> {
1541                let mut headers = http::HeaderMap::new();
1542                headers.insert("x-deny-policy", "p1".parse().unwrap());
1543                Err(ConnectError::permission_denied("nope").with_headers(headers))
1544            }
1545        }
1546        let chain: Vec<Arc<dyn Interceptor>> =
1547            vec![Arc::new(Reject), Arc::new(StreamTagger("never"))];
1548        let terminal = RecordingStreamTerminal {
1549            ran: Mutex::new(false),
1550            respond_with: vec!["ok"],
1551        };
1552        let err = match NextStream::new(&chain, &terminal)
1553            .run(stream_req(), payload_stream(&["x"]))
1554            .await
1555        {
1556            Ok(_) => panic!("expected error"),
1557            Err(e) => e,
1558        };
1559        assert_eq!(err.code, crate::ErrorCode::PermissionDenied);
1560        assert!(!*terminal.ran.lock().unwrap(), "terminal must not run");
1561        // Diagnostic headers on a short-circuit error must survive the
1562        // chain so the dispatch path's streaming error renderer can put
1563        // them on the wire.
1564        assert_eq!(
1565            err.response_headers().get("x-deny-policy").unwrap(),
1566            "p1",
1567            "diagnostic headers must survive a streaming short-circuit"
1568        );
1569    }
1570
1571    #[tokio::test]
1572    async fn streaming_passthrough_preserves_items_and_metadata() {
1573        struct Passthrough;
1574        #[async_trait::async_trait]
1575        impl Interceptor for Passthrough {}
1576        let chain: Vec<Arc<dyn Interceptor>> = vec![Arc::new(Passthrough)];
1577        let resp = run_chain_streaming(
1578            &chain,
1579            stream_req(),
1580            payload_stream(&["a", "b"]),
1581            |_req, inbound| async move {
1582                let inbound_values = collect_strings(inbound).await;
1583                let body: PayloadStream = payload_stream(&["x", "y", "z"]);
1584                let mut r = Response {
1585                    body,
1586                    headers: http::HeaderMap::new(),
1587                    trailers: http::HeaderMap::new(),
1588                    compress: Some(true),
1589                };
1590                r.headers.insert("x-h", "1".parse().unwrap());
1591                r.trailers.insert("x-t", "2".parse().unwrap());
1592                r.headers
1593                    .insert("x-inbound", inbound_values.join(",").parse().unwrap());
1594                Ok(r)
1595            },
1596        )
1597        .await
1598        .unwrap();
1599        assert_eq!(resp.headers.get("x-h").unwrap(), "1");
1600        assert_eq!(resp.trailers.get("x-t").unwrap(), "2");
1601        assert_eq!(resp.compress, Some(true));
1602        assert_eq!(resp.headers.get("x-inbound").unwrap(), "a,b");
1603        let out = collect_strings(resp.body).await;
1604        assert_eq!(out, vec!["x", "y", "z"]);
1605    }
1606
1607    #[tokio::test]
1608    async fn streaming_interceptor_wraps_inbound() {
1609        /// Replaces every inbound message with `"redacted"`.
1610        struct RedactInbound;
1611        #[async_trait::async_trait]
1612        impl Interceptor for RedactInbound {
1613            async fn intercept_streaming(
1614                &self,
1615                req: StreamRequest,
1616                inbound: PayloadStream,
1617                next: NextStream<'_>,
1618            ) -> Result<StreamResponse, ConnectError> {
1619                let wrapped: PayloadStream = Box::pin(inbound.map(|item| {
1620                    item.map(|mut payload| {
1621                        payload.set_message(StringValue {
1622                            value: "redacted".into(),
1623                            ..Default::default()
1624                        });
1625                        payload
1626                    })
1627                }));
1628                next.run(req, wrapped).await
1629            }
1630        }
1631        let chain: Vec<Arc<dyn Interceptor>> = vec![Arc::new(RedactInbound)];
1632        let resp = run_chain_streaming(
1633            &chain,
1634            stream_req(),
1635            payload_stream(&["secret-a", "secret-b"]),
1636            |_req, inbound| async move {
1637                let inbound_values = collect_strings(inbound).await;
1638                let body: PayloadStream = payload_stream(&[]);
1639                let resp = Response {
1640                    body,
1641                    headers: http::HeaderMap::new(),
1642                    trailers: http::HeaderMap::new(),
1643                    compress: None,
1644                };
1645                Ok(resp.with_header("x-inbound", inbound_values.join(",")))
1646            },
1647        )
1648        .await
1649        .unwrap();
1650        // The terminal saw the wrapped inbound stream.
1651        assert_eq!(resp.headers.get("x-inbound").unwrap(), "redacted,redacted");
1652    }
1653
1654    #[tokio::test]
1655    async fn streaming_interceptor_wraps_outbound() {
1656        /// Replaces every outbound message with `"redacted"`.
1657        struct RedactOutbound;
1658        #[async_trait::async_trait]
1659        impl Interceptor for RedactOutbound {
1660            async fn intercept_streaming(
1661                &self,
1662                req: StreamRequest,
1663                inbound: PayloadStream,
1664                next: NextStream<'_>,
1665            ) -> Result<StreamResponse, ConnectError> {
1666                let resp = next.run(req, inbound).await?;
1667                Ok(resp.map_body(|stream| -> PayloadStream {
1668                    Box::pin(stream.map(|item| {
1669                        item.map(|mut payload| {
1670                            payload.set_message(StringValue {
1671                                value: "redacted".into(),
1672                                ..Default::default()
1673                            });
1674                            payload
1675                        })
1676                    }))
1677                }))
1678            }
1679        }
1680        let chain: Vec<Arc<dyn Interceptor>> = vec![Arc::new(RedactOutbound)];
1681        let terminal = RecordingStreamTerminal {
1682            ran: Mutex::new(false),
1683            respond_with: vec!["secret-1", "secret-2"],
1684        };
1685        let resp = NextStream::new(&chain, &terminal)
1686            .run(stream_req(), payload_stream(&["x"]))
1687            .await
1688            .unwrap();
1689        let out = collect_strings(resp.body).await;
1690        assert_eq!(out, vec!["redacted", "redacted"]);
1691    }
1692
1693    #[tokio::test]
1694    async fn streaming_closure_interceptor_works() {
1695        let i = streaming_interceptor(|req, inbound, next| {
1696            Box::pin(async move {
1697                let resp = next.run(req, inbound).await?;
1698                Ok(resp.with_header("x-fn", "1"))
1699            })
1700        });
1701        let chain: Vec<Arc<dyn Interceptor>> = vec![Arc::new(i)];
1702        let resp = run_chain_streaming(
1703            &chain,
1704            stream_req(),
1705            payload_stream(&[]),
1706            |_req, _in| async {
1707                let body: PayloadStream = payload_stream(&[]);
1708                Ok(Response {
1709                    body,
1710                    headers: http::HeaderMap::new(),
1711                    trailers: http::HeaderMap::new(),
1712                    compress: None,
1713                })
1714            },
1715        )
1716        .await
1717        .unwrap();
1718        assert_eq!(resp.headers.get("x-fn").unwrap(), "1");
1719    }
1720
1721    /// A `Dispatcher` mock for testing the streaming dispatch glue.
1722    /// Echoes the inbound items as the outbound stream.
1723    struct StreamEcho;
1724    impl crate::Dispatcher for StreamEcho {
1725        fn lookup(&self, _: &str) -> Option<crate::dispatcher::MethodDescriptor> {
1726            None
1727        }
1728        fn call_unary(
1729            &self,
1730            _: &str,
1731            _: RequestContext,
1732            _: Payload,
1733            _: CodecFormat,
1734        ) -> crate::dispatcher::UnaryResult {
1735            unimplemented!()
1736        }
1737        fn call_server_streaming(
1738            &self,
1739            _: &str,
1740            _: RequestContext,
1741            request: Bytes,
1742            _: CodecFormat,
1743        ) -> crate::dispatcher::StreamingResult {
1744            Box::pin(async move {
1745                let body: BoxStream<Result<Bytes, ConnectError>> =
1746                    Box::pin(futures::stream::once(async move { Ok(request) }));
1747                Ok(Response {
1748                    body,
1749                    headers: http::HeaderMap::new(),
1750                    trailers: http::HeaderMap::new(),
1751                    compress: None,
1752                })
1753            })
1754        }
1755        fn call_client_streaming(
1756            &self,
1757            _: &str,
1758            _: RequestContext,
1759            requests: crate::dispatcher::RequestStream,
1760            _: CodecFormat,
1761        ) -> crate::dispatcher::UnaryResult {
1762            Box::pin(async move {
1763                let mut total = 0usize;
1764                let mut requests = requests;
1765                while let Some(item) = requests.next().await {
1766                    total += item?.len();
1767                }
1768                Ok(EncodedResponse::new(Bytes::from(total.to_string()).into()))
1769            })
1770        }
1771        fn call_bidi_streaming(
1772            &self,
1773            _: &str,
1774            _: RequestContext,
1775            requests: crate::dispatcher::RequestStream,
1776            _: CodecFormat,
1777        ) -> crate::dispatcher::StreamingResult {
1778            Box::pin(async move {
1779                Ok(Response {
1780                    body: requests,
1781                    headers: http::HeaderMap::new(),
1782                    trailers: http::HeaderMap::new(),
1783                    compress: None,
1784                })
1785            })
1786        }
1787    }
1788
1789    /// All three `call_*_streaming_intercepted` empty-chain fast paths
1790    /// delegate straight to the dispatcher with no `PayloadStream` /
1791    /// `NextStream` overhead. Verified by pointer-equality on the
1792    /// echoed body bytes.
1793    #[tokio::test]
1794    async fn streaming_empty_chain_is_no_op() {
1795        // Server-streaming.
1796        let body = Bytes::from_static(b"x");
1797        let resp = call_server_streaming_intercepted(
1798            &StreamEcho,
1799            &[],
1800            "p",
1801            RequestContext::default(),
1802            body.clone(),
1803            CodecFormat::Proto,
1804        )
1805        .await
1806        .unwrap();
1807        let out: Vec<_> = resp.body.collect().await;
1808        assert_eq!(out.len(), 1);
1809        assert!(std::ptr::eq(
1810            out[0].as_ref().unwrap().as_ptr(),
1811            body.as_ptr()
1812        ));
1813
1814        // Client-streaming.
1815        let inbound: RequestStream = Box::pin(futures::stream::iter(vec![
1816            Ok(Bytes::from_static(b"ab")),
1817            Ok(Bytes::from_static(b"cd")),
1818        ]));
1819        let resp = call_client_streaming_intercepted(
1820            &StreamEcho,
1821            &[],
1822            "p",
1823            RequestContext::default(),
1824            inbound,
1825            CodecFormat::Proto,
1826        )
1827        .await
1828        .unwrap();
1829        assert_eq!(&*resp.body.into_contiguous(), b"4");
1830
1831        // Bidi-streaming.
1832        let body = Bytes::from_static(b"z");
1833        let inbound: RequestStream = Box::pin(futures::stream::once({
1834            let body = body.clone();
1835            async move { Ok(body) }
1836        }));
1837        let resp = call_bidi_streaming_intercepted(
1838            &StreamEcho,
1839            &[],
1840            "p",
1841            RequestContext::default(),
1842            inbound,
1843            CodecFormat::Proto,
1844        )
1845        .await
1846        .unwrap();
1847        let out: Vec<_> = resp.body.collect().await;
1848        assert_eq!(out.len(), 1);
1849        assert!(std::ptr::eq(
1850            out[0].as_ref().unwrap().as_ptr(),
1851            body.as_ptr()
1852        ));
1853    }
1854
1855    /// `call_*_streaming_intercepted` propagates a short-circuit error
1856    /// verbatim, including response headers, without invoking the
1857    /// dispatcher. Pinned because an auth interceptor relies on it.
1858    #[tokio::test]
1859    async fn call_streaming_intercepted_propagates_error_headers() {
1860        struct Reject;
1861        #[async_trait::async_trait]
1862        impl Interceptor for Reject {
1863            async fn intercept_streaming(
1864                &self,
1865                _req: StreamRequest,
1866                _inbound: PayloadStream,
1867                _next: NextStream<'_>,
1868            ) -> Result<StreamResponse, ConnectError> {
1869                let mut headers = http::HeaderMap::new();
1870                headers.insert("x-deny-policy", "p1".parse().unwrap());
1871                Err(ConnectError::permission_denied("nope").with_headers(headers))
1872            }
1873        }
1874        struct PanickyDispatcher;
1875        impl crate::Dispatcher for PanickyDispatcher {
1876            fn lookup(&self, _: &str) -> Option<crate::dispatcher::MethodDescriptor> {
1877                None
1878            }
1879            fn call_unary(
1880                &self,
1881                _: &str,
1882                _: RequestContext,
1883                _: Payload,
1884                _: CodecFormat,
1885            ) -> crate::dispatcher::UnaryResult {
1886                unreachable!()
1887            }
1888            fn call_server_streaming(
1889                &self,
1890                _: &str,
1891                _: RequestContext,
1892                _: Bytes,
1893                _: CodecFormat,
1894            ) -> crate::dispatcher::StreamingResult {
1895                unreachable!("dispatcher must not run when an interceptor short-circuits")
1896            }
1897            fn call_client_streaming(
1898                &self,
1899                _: &str,
1900                _: RequestContext,
1901                _: crate::dispatcher::RequestStream,
1902                _: CodecFormat,
1903            ) -> crate::dispatcher::UnaryResult {
1904                unreachable!("dispatcher must not run when an interceptor short-circuits")
1905            }
1906            fn call_bidi_streaming(
1907                &self,
1908                _: &str,
1909                _: RequestContext,
1910                _: crate::dispatcher::RequestStream,
1911                _: CodecFormat,
1912            ) -> crate::dispatcher::StreamingResult {
1913                unreachable!("dispatcher must not run when an interceptor short-circuits")
1914            }
1915        }
1916        let chain: Vec<Arc<dyn Interceptor>> = vec![Arc::new(Reject)];
1917
1918        let err = match call_server_streaming_intercepted(
1919            &PanickyDispatcher,
1920            &chain,
1921            "p",
1922            RequestContext::default(),
1923            Bytes::new(),
1924            CodecFormat::Proto,
1925        )
1926        .await
1927        {
1928            Ok(_) => panic!("expected error"),
1929            Err(e) => e,
1930        };
1931        assert_eq!(err.code, crate::ErrorCode::PermissionDenied);
1932        assert_eq!(err.response_headers().get("x-deny-policy").unwrap(), "p1");
1933
1934        let err = call_client_streaming_intercepted(
1935            &PanickyDispatcher,
1936            &chain,
1937            "p",
1938            RequestContext::default(),
1939            Box::pin(futures::stream::empty()),
1940            CodecFormat::Proto,
1941        )
1942        .await
1943        .unwrap_err();
1944        assert_eq!(err.code, crate::ErrorCode::PermissionDenied);
1945
1946        let err = match call_bidi_streaming_intercepted(
1947            &PanickyDispatcher,
1948            &chain,
1949            "p",
1950            RequestContext::default(),
1951            Box::pin(futures::stream::empty()),
1952            CodecFormat::Proto,
1953        )
1954        .await
1955        {
1956            Ok(_) => panic!("expected error"),
1957            Err(e) => e,
1958        };
1959        assert_eq!(err.code, crate::ErrorCode::PermissionDenied);
1960    }
1961
1962    /// The three `call_*_streaming_intercepted` un-unify correctly —
1963    /// server-streaming pulls a 1-item inbound stream, client-streaming
1964    /// collapses a 1-item outbound stream — through a passthrough chain.
1965    #[tokio::test]
1966    async fn streaming_intercepted_un_unifies_through_passthrough_chain() {
1967        struct Passthrough;
1968        #[async_trait::async_trait]
1969        impl Interceptor for Passthrough {}
1970        let chain: Vec<Arc<dyn Interceptor>> = vec![Arc::new(Passthrough)];
1971
1972        // Server-streaming: single body in → 1-item inbound stream → terminal
1973        // pulls it → echo dispatcher returns a 1-item outbound stream.
1974        let body = Bytes::from_static(b"ss");
1975        let resp = call_server_streaming_intercepted(
1976            &StreamEcho,
1977            &chain,
1978            "p",
1979            RequestContext::default(),
1980            body.clone(),
1981            CodecFormat::Proto,
1982        )
1983        .await
1984        .unwrap();
1985        let out: Vec<_> = resp.body.collect().await;
1986        assert_eq!(out.len(), 1);
1987        assert_eq!(out[0].as_ref().unwrap(), &body);
1988
1989        // Client-streaming: 2-item inbound stream → terminal hands stream
1990        // to dispatcher → dispatcher's single response collapses to body.
1991        let inbound: RequestStream = Box::pin(futures::stream::iter(vec![
1992            Ok(Bytes::from_static(b"abc")),
1993            Ok(Bytes::from_static(b"de")),
1994        ]));
1995        let resp = call_client_streaming_intercepted(
1996            &StreamEcho,
1997            &chain,
1998            "p",
1999            RequestContext::default(),
2000            inbound,
2001            CodecFormat::Proto,
2002        )
2003        .await
2004        .unwrap();
2005        assert_eq!(&*resp.body.into_contiguous(), b"5");
2006
2007        // Bidi: 2-item inbound → echo dispatcher returns it as outbound.
2008        let inbound: RequestStream = Box::pin(futures::stream::iter(vec![
2009            Ok(Bytes::from_static(b"1")),
2010            Ok(Bytes::from_static(b"2")),
2011        ]));
2012        let resp = call_bidi_streaming_intercepted(
2013            &StreamEcho,
2014            &chain,
2015            "p",
2016            RequestContext::default(),
2017            inbound,
2018            CodecFormat::Proto,
2019        )
2020        .await
2021        .unwrap();
2022        let out: Vec<_> = resp.body.collect().await;
2023        assert_eq!(out.len(), 2);
2024        assert_eq!(out[0].as_ref().unwrap(), &Bytes::from_static(b"1"));
2025        assert_eq!(out[1].as_ref().unwrap(), &Bytes::from_static(b"2"));
2026    }
2027}