Skip to content

Interceptors

Wrap handler execution — install ambient state, observe, transform the response, in a single intercept(req, next) call.

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 continuation builds on poem’s endpoint chain. One method, one transport:

nest_rs::interceptors::Interceptor
#[async_trait]
pub trait Interceptor: Layer {
/// HTTP entry. Per-route, runs once per request.
async fn intercept(&self, req: Request, next: Next<'_>) -> Result<Response>;
}

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.

Terminal window
cargo add nest-rs --features interceptors

The http feature turns this on, and http is a default — name it explicitly only in a headless app built with default-features = false, where no transport implies it.

async_trait comes from nest-rs-interceptors (and nest-rs-filters / nest-rs-exception-filters for their traits). That re-export is load-bearing, not ergonomic. poem::async_trait does not exist in poem 3, so there is no alternative spelling to fall back on. The unresolved import then cascades into a confusing E0195: lifetime parameters or bounds on method 'intercept' do not match the trait declaration — the un-expanded async fn no longer matches the #[async_trait]-declared trait. A crate mid-upgrade can also depend on async-trait = "0.1" directly and use async_trait::async_trait; — both spellings must resolve to the same 0.1 macro.

apps/api/src/interceptor.rs
use nest_rs::core::{Layer, injectable};
use nest_rs::interceptors::{Interceptor, Next, async_trait};
use nest_rs::http::poem::{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 main, with use nest_rs::interceptors::AppBuilderInterceptorsExt; in scopeA transport-level HttpEndpointWrap 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
crates/features/src/api/http/controller.rs
#[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 HttpEndpointWrap 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/http/interceptor.rs (abridged)
#[interceptor(priority = -10)]
pub struct DbContext {
#[inject]
db: Arc<DatabaseConnection>,
}
impl Layer for DbContext {}
#[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 SeaOrmDatabaseModule, 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, authorization ability). The trace and the actor_id are not on that list — the transport installs those before any interceptor runs, see Correlation.
  • Transform the response — add headers (Server-Timing, X-Cache-Status), compress, paginate.
  • Time the call — measure latency by route, emit a metric.
  • Mask the body — nest-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.