Skip to content

Feature: Interceptors (@UseInterceptors, NestInterceptor, CallHandler) #117

@ItayTheDar

Description

@ItayTheDar

Overview

PyNest has no mechanism to wrap route handler execution with cross-cutting logic. There is no way to transparently add logging, timing, response caching, response mapping, or request transformation without modifying handler code.

This feature request proposes NestJS-compatible Interceptors — a composable layer that wraps handler execution before AND after it runs.


Motivation

Interceptors are the most powerful tool in the NestJS pipeline because they can:

  1. Execute code before and after a route handler
  2. Transform the result returned by a handler
  3. Transform exceptions thrown by a handler
  4. Extend basic handler behavior (caching, logging, timing)
  5. Completely override the handler (serve from cache, short-circuit)

Without interceptors, developers must either:

  • Add logging/timing boilerplate inside every handler
  • Write FastAPI middleware that operates on raw ASGI scope (no DI access)
  • Duplicate response transformation logic across services

Proposed API

NestInterceptor — base interface

```python
from abc import ABC, abstractmethod
from nest.common.interceptors import NestInterceptor, ExecutionContext, CallHandler
from typing import Observable # we use AsyncGenerator as Python's equivalent

class NestInterceptor(ABC):
@AbstractMethod
async def intercept(self, context: ExecutionContext, next: CallHandler) -> any:
...

class CallHandler:
async def handle(self) -> any:
"""Call the next handler in the chain (ultimately the route handler)."""
...
```

@UseInterceptors decorator

```python
from nest.common.decorators import UseInterceptors

@controller('/users')
@UseInterceptors(LoggingInterceptor) # controller-level
class UserController:

@Get('/:id')
@UseInterceptors(CacheInterceptor)  # route-level
def get_user(self, id: int):
    ...

```


Built-in Interceptors

LoggingInterceptor

```python
from nest.common.interceptors import NestInterceptor, ExecutionContext, CallHandler
import time, logging

class LoggingInterceptor(NestInterceptor):
async def intercept(self, context: ExecutionContext, next: CallHandler) -> any:
req = context.switch_to_http().get_request()
logger.info(f"→ {req.method} {req.url}")
start = time.perf_counter()

    result = await next.handle()

    elapsed = (time.perf_counter() - start) * 1000
    logger.info(f"← {req.method} {req.url} [{elapsed:.1f}ms]")
    return result

```

TimeoutInterceptor

```python
import asyncio

class TimeoutInterceptor(NestInterceptor):
def init(self, timeout_ms: int = 5000):
self.timeout = timeout_ms / 1000

async def intercept(self, context: ExecutionContext, next: CallHandler) -> any:
    try:
        return await asyncio.wait_for(next.handle(), timeout=self.timeout)
    except asyncio.TimeoutError:
        raise RequestTimeoutException()

```

CacheInterceptor

```python
class CacheInterceptor(NestInterceptor):
def init(self, cache_service: CacheService):
self.cache = cache_service

async def intercept(self, context: ExecutionContext, next: CallHandler) -> any:
    key = self._get_cache_key(context)
    cached = await self.cache.get(key)
    if cached:
        return cached

    result = await next.handle()
    await self.cache.set(key, result)
    return result

```

TransformInterceptor — wrap every response in {data: ...}

```python
class TransformInterceptor(NestInterceptor):
async def intercept(self, context: ExecutionContext, next: CallHandler) -> any:
result = await next.handle()
return {"data": result, "statusCode": 200}
```

ExcludeNullInterceptor — strip None values from responses

```python
class ExcludeNullInterceptor(NestInterceptor):
async def intercept(self, context: ExecutionContext, next: CallHandler) -> any:
result = await next.handle()
return self._strip_nulls(result)
```


ExecutionContext — rich context object

```python
class ExecutionContext:
def switch_to_http(self) -> HttpArgumentsHost: ...
def get_class(self) -> type: ... # the controller class
def get_handler(self) -> callable: ... # the route method
def get_type(self) -> str: ... # 'http' | 'ws'
```


app.use_global_interceptors()

```python
app = PyNestFactory.create(AppModule)
app.use_global_interceptors(LoggingInterceptor(), TransformInterceptor())
```


Interceptor Execution Order

For a route with both controller-level and route-level interceptors:

→ Global interceptors (outermost)
  → Controller interceptors
    → Route interceptors (innermost)
      → Route handler
    ← Route interceptors
  ← Controller interceptors
← Global interceptors

Each interceptor wraps the next, forming a true middleware onion.


DI Support in Interceptors

Interceptors registered with @UseInterceptors(MyInterceptor) should be instantiated via the DI container, allowing them to declare dependencies:

```python
@Injectable
class AuditInterceptor(NestInterceptor):
def init(self, audit_service: AuditService): # injected!
self.audit = audit_service
```


Acceptance Criteria

  • NestInterceptor abstract base class in nest/common/interceptors.py
  • CallHandler class with async handle() method
  • ExecutionContext with switch_to_http(), get_class(), get_handler(), get_type()
  • @UseInterceptors(*interceptors) decorator for controller and route scope
  • app.use_global_interceptors(*interceptors) API
  • Interceptors instantiated via DI container (support for injected dependencies)
  • Built-in: LoggingInterceptor, TimeoutInterceptor, TransformInterceptor, ExcludeNullInterceptor
  • Correct onion-order execution (global → controller → route)
  • Async intercept() methods supported
  • Unit tests covering all scopes and execution order
  • Documentation page

Dependencies

  • Feature update readme #1 (Exception Filters) — interceptors may throw exceptions that filters catch
  • Feature Add official docs #2 (Lifecycle Hooks) — ExecutionContext shares infrastructure with hooks
  • Shares ArgumentsHost / HttpArgumentsHost with Exception Filters

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions