Skip to content

Interceptors

An interceptor wraps the handler. It sees the request before the handler runs, holds the response after, and in between calls next.run(req) to delegate to whatever sits inside it (another interceptor, eventually the handler). One call, both sides of the boundary.

Interceptor is a Layer sub-trait shipped by nest-rs-interceptors. The HTTP continuation builds on poem’s endpoint chain; the GraphQL one on async-graphql’s resolver context. One impl, three transports — override the method(s) the interceptor targets, the rest inherit a pass-through default.

#[async_trait]
pub trait Interceptor: Layer {
/// HTTP entry — required. Per-route, runs once per request.
async fn intercept(&self, req: Request, next: Next<'_>) -> Result<Response>;
/// GraphQL per-resolver-call entry. Default just awaits `next`.
async fn wrap_graphql<'a>(&self, ctx: &GraphqlContext<'a>, next: GraphqlNext<'a>)
-> ServerResult<GraphqlValue> { next.await }
/// WS per-message entry. Default just awaits `next`.
async fn wrap_ws<'a>(&self, client: &WsClient, event: &str, data: &Value, next: WsNext<'a>)
-> Result<Option<Value>, String> { next.await }
}

Same vocabulary as a guard, different shape:

  • A guard runs before the handler and returns yes/no.
  • An interceptor runs around the handler — it can install ambient state for the handler’s duration, add response headers, time the call, retry, transform the body.

It is the seam where the database transaction lives, where response masking lives, where tracing spans wrap a request.

use nest_rs_core::{Layer, injectable};
use nest_rs_interceptors::{Interceptor, Next};
use poem::{async_trait, Request, Response, Result};
#[injectable]
#[derive(Default)]
pub struct Tracer;
impl Layer for Tracer {}
#[async_trait]
impl Interceptor for Tracer {
async fn intercept(&self, req: Request, next: Next<'_>) -> Result<Response> {
let mut resp = next.run(req).await?;
resp.headers_mut()
.insert("x-trace", "hit".parse().unwrap());
Ok(resp)
}
}

A plain #[injectable] provider that implements Interceptor. Listed in a module’s providers = [...], then bound globally, per controller, or per handler.

ScopeBindingResolved by
GlobalApp::builder().use_interceptors_global([interceptor::<Tracer>()]) in mainA transport-level HttpInterceptorMeta wrap + the per-route shaper
Controller / Resolver / Gateway#[use_interceptors(Tracer)] on the structThe container, at mount
Per-handler#[use_interceptors(Tracer)] beside a verb / #[query] / #[subscribe_message]The container, at mount
#[controller(path = "/api")]
#[use_interceptors(Tracer)] // controller scope
pub struct ApiController {
#[inject] svc: Arc<ApiService>,
}
#[routes]
impl ApiController {
#[get("/cache")]
#[use_interceptors(CacheControl)] // handler scope
async fn cached(&self) -> Json<Snapshot> { /* ... */ }
}

Multiple interceptors in one attribute run outermost-first: the first listed sees the request before the second, and its response wraps the second’s.

Like every other Layer, an Interceptor declared globally and redeclared on the controller or method runs exactly once per request — the transport-level HttpInterceptorMeta wrap carries the single execution, the inner wraps are skipped at mount time. The full rationale and the cross-transport wiring details live on the guards page; the same rules apply here.

See Guards / Declare on the provider for the worked example and the defense-in-depth argument.

For infrastructure that must wrap everything, write #[interceptor] instead of #[injectable] + impl Interceptor. The framework discovers it, builds it, and folds it around the assembled route tree in registration order — no #[use_interceptors] needed anywhere.

crates/nest-rs-seaorm/src/interceptor.rs
#[interceptor]
pub(crate) struct DbContext {
#[inject]
db: Arc<DatabaseConnection>,
}
#[async_trait]
impl Interceptor for DbContext {
async fn intercept(&self, req: Request, next: Next<'_>) -> Result<Response> {
if is_safe(req.method()) {
return with_request_executor(Executor::Pool(self.db.clone()), next.run(req)).await;
}
let txn = Arc::new(self.db.begin().await?);
let result = with_request_executor(Executor::Txn(txn.clone()), next.run(req)).await;
commit_or_rollback(txn, &result).await?;
result
}
}

That’s the entire transactional boundary. Import DatabaseModule, the interceptor is auto-mounted, and every handler runs inside a transaction on a mutating verb, on the pool on a safe verb. No #[use_interceptors] on any controller.

Interceptors participate at two levels. The full picture — per-route nesting, global priority bands, RouteShaper, and where #[use_filters] vs #[use_exception_filters] sit — is on Guards / Ordering inside a route.

Global interceptors (including auto-mounted DbContext) wrap outside global guards. That’s how the executor is visible when guards run:

  1. DbContext runs — opens a transaction for mutating verbs, installs the executor in a task_local!, then calls next.run(req).
  2. Guards run inside itAuthGuard attaches Claims, AuthzGuard builds Ability. Both see the same executor.
  3. Handler runsRepo::scoped(...) reads both ambients.
  4. Response returnsDbContext commits on 2xx/3xx, rolls back otherwise.

Per-route interceptors (#[use_interceptors] on the controller or beside a verb) nest inside the RouteShaper guard chain — closer to the handler than the guards. A guard denial returns before those interceptors’ pre-handler work runs.

Per-route, inner → outer (from the #[routes] macro):

handler
→ ability shaper
→ per-route interceptors
→ per-route filters (error path only)
→ RouteShaper (guards + pipes)
→ `#[meta]` / `#[public]` (route data)
  • Install ambient state for the handler’s duration (transactions, tracing spans, request id, authorization ability).
  • Transform the response — add headers (Server-Timing, X-Request-Id), compress, paginate.
  • Time the call — measure latency by route, emit a metric.
  • Mask the bodynest-rs-authz’s Authorize shaper is technically a “shaper” (inside the interceptors), but the pattern is the same: see the response, decide what to send.

The pattern: capture what you need from req, await next.run(req) to get the response, decide what to do with it.