Interceptors: Transforming Responses

Creating interceptors to transform responses before they are sent to the client, e.g., adding metadata or formatting the data.


Advanced Interceptor Techniques in NestJS

NestJS interceptors are powerful tools that allow you to intercept and modify request and response streams. Beyond basic logging or transforming data, interceptors can be used for more sophisticated tasks, significantly improving application performance, security, and maintainability. This document explores some advanced interceptor techniques: caching responses, validating request data, and implementing authentication/authorization logic.

1. Caching Responses

Caching responses using interceptors can dramatically reduce server load and improve response times. Instead of repeatedly processing requests for the same data, you can store the response in a cache (e.g., Redis, Memcached, in-memory cache) and serve it directly from the cache on subsequent requests.

Implementation Considerations:

  • Cache Key Generation: Create a unique key for each cacheable request, often based on the request URL, method, and relevant query parameters.
  • Cache Invalidation: Implement a mechanism to invalidate the cache when the underlying data changes. This can be achieved through event-driven approaches or scheduled cache refreshes.
  • Cache Storage: Choose a suitable cache storage mechanism based on your application's requirements (e.g., in-memory, Redis, Memcached).
  • TTL (Time-to-Live): Define a TTL for cached responses to ensure data freshness.

Example (Conceptual):

 import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Inject } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';

export const CacheKey = (key: string) => SetMetadata('cacheKey', key);
export const CacheTTL = (seconds: number) => SetMetadata('cacheTTL', seconds);

@Injectable()
export class HttpCacheInterceptor implements NestInterceptor {
    constructor(
        private reflector: Reflector,
        @Inject(CACHE_MANAGER) private cacheManager: Cache,
    ) {}

    async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
        const cacheKeyMeta = this.reflector.get('cacheKey', context.getHandler());
        const cacheTTLMeta = this.reflector.get('cacheTTL', context.getHandler()) || 60; // Default TTL

        if (!cacheKeyMeta) {
            return next.handle(); // Skip caching if no cacheKey is defined
        }

        const request = context.switchToHttp().getRequest();
        const cacheKey = `${cacheKeyMeta}:${request.url}`; // Consider more complex key generation

        const cachedData = await this.cacheManager.get(cacheKey);

        if (cachedData) {
            console.log('Serving from cache');
            return of(cachedData);
        }

        return next.handle().pipe(
            tap(async (data) => {
                console.log('Caching data');
                await this.cacheManager.set(cacheKey, data, cacheTTLMeta);
            }),
        );
    }
}

// Example usage in controller:
// @CacheKey('users')
// @Get('users')
// async getUsers(): Promise<User[]> { ... } 

2. Validating Request Data

Interceptors can be used to validate incoming request data before it reaches the controller. This provides a centralized and reusable way to enforce data integrity and prevent invalid data from polluting your application logic.

Implementation Considerations:

  • Validation Library: Use a validation library like `class-validator` and `class-transformer` for defining and applying validation rules.
  • Error Handling: Return appropriate error responses (e.g., 400 Bad Request) with detailed validation error messages.
  • Targeting Specific Routes: Apply the validation interceptor only to routes that require data validation.

Example:

 import { Injectable, NestInterceptor, ExecutionContext, CallHandler, BadRequestException } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validate, ValidationError } from 'class-validator';
import { Observable } from 'rxjs';

// Example DTO for validation
class CreateUserDto {
    @IsString()
    @IsNotEmpty()
    name: string;

    @IsEmail()
    email: string;
}


@Injectable()
export class ValidationInterceptor implements NestInterceptor {
  constructor(private readonly dto: any) {}

  async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
    const req = context.switchToHttp().getRequest();
    const body = req.body;

    const dtoInstance = plainToInstance(this.dto, body);
    const errors: ValidationError[] = await validate(dtoInstance);

    if (errors.length > 0) {
        const messages = errors.map(err => Object.values(err.constraints)).reduce((acc, cur) => acc.concat(cur), []);
        throw new BadRequestException(messages);
    }

    return next.handle();
  }
}

// Example usage in controller:
// @UseInterceptors(new ValidationInterceptor(CreateUserDto))
// @Post('users')
// async createUser(@Body() createUserDto: CreateUserDto): Promise<User> { ... } 

3. Implementing Authentication/Authorization Logic

Interceptors can be used to implement authentication and authorization logic, providing a central point for enforcing security policies across your application. This approach allows you to keep your controllers clean and focused on business logic.

Implementation Considerations:

  • Authentication: Verify the user's identity (e.g., using JWTs, sessions).
  • Authorization: Determine if the user has the necessary permissions to access the requested resource.
  • Role-Based Access Control (RBAC): Implement RBAC to define roles and associate permissions with those roles.
  • Error Handling: Return appropriate error responses (e.g., 401 Unauthorized, 403 Forbidden) when authentication or authorization fails.
  • Metadata: Use metadata to define authorization requirements for specific routes.

Example (Conceptual):

 import { Injectable, NestInterceptor, ExecutionContext, CallHandler, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';

// Metadata key for required roles
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

@Injectable()
export class RolesInterceptor implements NestInterceptor {
  constructor(private reflector: Reflector) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());

    if (!requiredRoles) {
      return next.handle(); // No role requirements, allow access
    }

    const request = context.switchToHttp().getRequest();
    const user = request.user; // Assuming user information is attached to the request (e.g., by a JWT middleware)

    if (!user) {
      throw new UnauthorizedException('Authentication required');
    }

    const hasRole = () => user.roles.some((role) => requiredRoles.includes(role)); // Assuming user.roles is an array of roles

    if (!hasRole()) {
      throw new ForbiddenException('Insufficient permissions');
    }

    return next.handle();
  }
}

// Example usage in controller:
// @UseGuards(JwtAuthGuard) // JwtAuthGuard would typically handle authentication and populate request.user
// @UseInterceptors(RolesInterceptor)
// @Roles('admin')
// @Post('admin/resource')
// async createAdminResource(): Promise<any> { ... }