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 are pooled by TypeId and run once per request: a global interceptor folds at the transport edge (band 90, before auth — it sees 404s and denials); a scoped one wraps the handler inside the guards. Infra #[interceptor]s (DbContext, tracing) auto-mount off the pool at a fixed band. The full ordering across all five families is on the request lifecycle.

  • 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.

Built by YV17labs