Тема 9. Валідація даних та конвеєр обробки запитів у NestJS

Практичні приклади Interceptors

LoggingInterceptor, TransformInterceptor, TimeoutInterceptor, CacheInterceptor

Практичні приклади Interceptors

🎯 Мета лекції

  • Опанувати створення LoggingInterceptor для моніторингу часу виконання запитів
  • Навчитися реалізовувати TransformInterceptor для уніфікації структури відповідей API
  • Вивчити CacheInterceptor з Redis для кешування результатів обробників
  • Засвоїти TimeoutInterceptor для автоматичного скасування довгих запитів
  • Практикувати ErrorsInterceptor для централізованої обробки виключень
  • Розуміти SerializeInterceptor для виключення чутливих полів з відповідей
  • Навчитися комбінувати кілька interceptors для створення комплексної обробки

🔑 Ключові терміни

  • LoggingInterceptor (перехоплювач логування): interceptor для запису часу виконання та деталей запиту
  • TransformInterceptor (перехоплювач трансформації): уніфікація структури відповідей у формат { data, meta }
  • CacheInterceptor (перехоплювач кешування): збереження результатів у Redis для швидкого доступу
  • TimeoutInterceptor (перехоплювач таймауту): автоматичне скасування запитів через RxJS timeout()
  • ErrorsInterceptor (перехоплювач помилок): перехоплення та форматування виключень
  • SerializeInterceptor (перехоплювач серіалізації): виключення полів (паролів, токенів) з відповідей
  • Response Caching (кешування відповідей): збереження результатів для зменшення навантаження на БД

TransformInterceptor: уніфікація структури відповідей

Один з найпоширеніших interceptors — обгортання всіх відповідей API у єдиний формат для консистентності клієнтського коду.

Базова імплементація

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export interface Response<T> {
  data: T;
  timestamp: string;
  path: string;
}

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
    const request = context.switchToHttp().getRequest();

    return next.handle().pipe(
      map(data => ({
        data,
        timestamp: new Date().toISOString(),
        path: request.url,
      }))
    );
  }
}

Без interceptor (оригінальна відповідь):

[
  { "id": 1, "name": "John Doe" },
  { "id": 2, "name": "Jane Smith" }
]

З interceptor (трансформована відповідь):

{
  "data": [
    { "id": 1, "name": "John Doe" },
    { "id": 2, "name": "Jane Smith" }
  ],
  "timestamp": "2026-09-05T14:30:00.123Z",
  "path": "/api/users"
}

Розширена версія з метаданими пагінації

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

interface PaginatedResponse<T> {
  data: T;
  meta: {
    timestamp: string;
    path: string;
    method: string;
    pagination?: {
      page: number;
      limit: number;
      total: number;
      totalPages: number;
    };
  };
}

@Injectable()
export class EnhancedTransformInterceptor<T> implements NestInterceptor<T, PaginatedResponse<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<PaginatedResponse<T>> {
    const request = context.switchToHttp().getRequest();
    const { method, url, query } = request;

    return next.handle().pipe(
      map(data => {
        const response: PaginatedResponse<T> = {
          data: Array.isArray(data) ? data : data,
          meta: {
            timestamp: new Date().toISOString(),
            path: url,
            method,
          },
        };

        // Додавання метаданих пагінації, якщо присутні query параметри
        if (query.page && query.limit) {
          const page = parseInt(query.page, 10);
          const limit = parseInt(query.limit, 10);
          const total = data.total || (Array.isArray(data) ? data.length : 0);

          response.meta.pagination = {
            page,
            limit,
            total,
            totalPages: Math.ceil(total / limit),
          };

          // Якщо обробник повернув об'єкт з items та total
          if (data.items && data.total !== undefined) {
            response.data = data.items as T;
          }
        }

        return response;
      })
    );
  }
}

Відповідь з пагінацією:

{
  "data": [
    { "id": 1, "name": "User 1" },
    { "id": 2, "name": "User 2" }
  ],
  "meta": {
    "timestamp": "2026-09-05T14:30:00.123Z",
    "path": "/api/users?page=1&limit=10",
    "method": "GET",
    "pagination": {
      "page": 1,
      "limit": 10,
      "total": 42,
      "totalPages": 5
    }
  }
}
Для великих API з багатьма ендпоінтами застосуйте TransformInterceptorглобально через APP_INTERCEPTOR. Це гарантує консистентний формат відповідей без додавання @UseInterceptors() до кожного контролера.

CacheInterceptor: кешування відповідей у Redis

Кешування результатів обробників дозволяє зменшити навантаження на базу даних та прискорити відповіді API.

Імплементація з Redis

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Redis } from 'ioredis';

@Injectable()
export class CacheInterceptor implements NestInterceptor {
  constructor(private readonly redis: Redis) {}

  async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
    const request = context.switchToHttp().getRequest();
    const cacheKey = this.generateCacheKey(request);

    // Перевірка наявності в кеші
    const cachedData = await this.redis.get(cacheKey);

    if (cachedData) {
      console.log(`Cache HIT: ${cacheKey}`);
      return of(JSON.parse(cachedData));
    }

    console.log(`Cache MISS: ${cacheKey}`);

    // Виконання обробника та збереження результату
    return next.handle().pipe(
      tap(async (data) => {
        await this.redis.set(
          cacheKey,
          JSON.stringify(data),
          'EX',
          300 // TTL: 5 хвилин
        );
      })
    );
  }

  private generateCacheKey(request: any): string {
    const { method, url, query, params, user } = request;
    
    // Ключ включає: метод, URL, query параметри та userId
    const queryString = new URLSearchParams(query).toString();
    const userId = user?.id || 'anonymous';
    
    return `cache:${method}:${url}:${queryString}:user:${userId}`;
  }
}

Встановлення Redis:

npm install ioredis
npm install -D @types/ioredis

Реєстрація у модулі:

import { Module } from '@nestjs/common';
import { Redis } from 'ioredis';
import { CacheInterceptor } from './interceptors/cache.interceptor';

@Module({
  providers: [
    {
      provide: 'REDIS_CLIENT',
      useFactory: () => new Redis({
        host: process.env.REDIS_HOST || 'localhost',
        port: parseInt(process.env.REDIS_PORT || '6379'),
      }),
    },
    CacheInterceptor,
  ],
  exports: [CacheInterceptor],
})
export class CacheModule {}

Використання:

@Controller('products')
export class ProductsController {
  @Get()
  @UseInterceptors(CacheInterceptor)
  async findAll() {
    // Ця операція буде закешована на 5 хвилин
    return this.productsService.findAll();
  }
}

Селективне кешування через декоратор

Для кращого контролю створіть декоратор з налаштуванням TTL:

import { SetMetadata } from '@nestjs/common';

export const CACHE_KEY = 'cache';
export const CacheTTL = (ttl: number) => SetMetadata(CACHE_KEY, ttl);

Модифікований interceptor:

@Injectable()
export class ConfigurableCacheInterceptor implements NestInterceptor {
  constructor(
    private readonly redis: Redis,
    private readonly reflector: Reflector
  ) {}

  async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
    const ttl = this.reflector.get<number>(CACHE_KEY, context.getHandler());

    if (!ttl) {
      return next.handle(); // Немає кешування
    }

    const request = context.switchToHttp().getRequest();
    const cacheKey = this.generateCacheKey(request);

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

    if (cachedData) {
      return of(JSON.parse(cachedData));
    }

    return next.handle().pipe(
      tap(async (data) => {
        await this.redis.set(cacheKey, JSON.stringify(data), 'EX', ttl);
      })
    );
  }

  private generateCacheKey(request: any): string {
    // ... та сама логіка
  }
}

Використання з кастомним TTL:

@Get('trending')
@CacheTTL(60) // Кешувати на 1 хвилину
getTrending() {
  return this.productsService.getTrending();
}

@Get('popular')
@CacheTTL(3600) // Кешувати на 1 годину
getPopular() {
  return this.productsService.getPopular();
}
Інвалідація кешу є критичною для консистентності даних:
// При оновленні/видаленні продукту
@Patch(':id')
async update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
  const result = await this.productsService.update(id, dto);
  
  // Видалити відповідні ключі кешу
  await this.redis.del(`cache:GET:/api/products/*`);
  
  return result;
}
Для складніших сценаріїв використовуйте паттерн Cache-Aside або інтегруйте з Event Emitter для автоматичної інвалідації.

TimeoutInterceptor: автоматичне скасування довгих запитів

Для захисту від зависання запитів використовується RxJS оператор timeout():

import { Injectable, NestInterceptor, ExecutionContext, CallHandler, RequestTimeoutException } from '@nestjs/common';
import { Observable, throwError, TimeoutError } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      timeout(5000), // 5 секунд
      catchError(err => {
        if (err instanceof TimeoutError) {
          return throwError(() => new RequestTimeoutException('Request timeout'));
        }
        return throwError(() => err);
      })
    );
  }
}

Використання з налаштовуваним timeout:

export const TIMEOUT_KEY = 'timeout';
export const SetTimeout = (ms: number) => SetMetadata(TIMEOUT_KEY, ms);

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

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const timeoutValue = this.reflector.get<number>(TIMEOUT_KEY, context.getHandler()) || 5000;

    return next.handle().pipe(
      timeout(timeoutValue),
      catchError(err => {
        if (err instanceof TimeoutError) {
          return throwError(() => new RequestTimeoutException(
            `Request exceeded ${timeoutValue}ms timeout`
          ));
        }
        return throwError(() => err);
      })
    );
  }
}

У контролері:

@Get('fast')
@SetTimeout(1000) // 1 секунда для швидких операцій
getFast() {
  return this.service.quickOperation();
}

@Get('slow')
@SetTimeout(30000) // 30 секунд для звітів
generateReport() {
  return this.service.heavyReport();
}

SerializeInterceptor: виключення чутливих полів

Для видалення паролів, токенів та інших чутливих даних з відповідей:

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { plainToClass } from 'class-transformer';

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

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      map((data: any) => {
        // Трансформація через class-transformer
        return plainToClass(this.dto, data, {
          excludeExtraneousValues: true,
        });
      })
    );
  }
}

DTO з виключенням полів:

import { Expose, Exclude } from 'class-transformer';

export class UserDto {
  @Expose()
  id: number;

  @Expose()
  email: string;

  @Expose()
  name: string;

  @Exclude() // Не включати у відповідь
  password: string;

  @Exclude()
  resetToken: string;
}

Використання:

@Get(':id')
@UseInterceptors(new SerializeInterceptor(UserDto))
findOne(@Param('id') id: string) {
  // Повертає User з усіма полями, включно з password
  return this.usersService.findById(id);
}

Клієнт отримує (без password):

{
  "id": 1,
  "email": "john@example.com",
  "name": "John Doe"
}

ErrorsInterceptor: централізована обробка помилок

import { Injectable, NestInterceptor, ExecutionContext, CallHandler, BadGatewayException } from '@nestjs/common';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';

@Injectable()
export class ErrorsInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      catchError(err => {
        // Логування помилки
        console.error('Error caught by interceptor:', err);

        // Трансформація специфічних помилок
        if (err.name === 'QueryFailedError') {
          return throwError(() => new BadGatewayException('Database error'));
        }

        if (err.code === 'ECONNREFUSED') {
          return throwError(() => new BadGatewayException('External service unavailable'));
        }

        // Передача інших помилок далі
        return throwError(() => err);
      })
    );
  }
}

Комбінування Interceptors: повний приклад

@Controller('api/products')
@UseInterceptors(
  LoggingInterceptor,           // 1. Логування
  TimeoutInterceptor,            // 2. Timeout
  CacheInterceptor,              // 3. Кешування
  TransformInterceptor,          // 4. Трансформація
  ErrorsInterceptor              // 5. Обробка помилок
)
export class ProductsController {
  @Get()
  async findAll() {
    return this.productsService.findAll();
  }
}

Порядок виконання (стек):

LoggingInterceptor (before) →
  TimeoutInterceptor (before) →
    CacheInterceptor (before) →
      TransformInterceptor (before) →
        ErrorsInterceptor (before) →
          [Handler Execution] →
        ErrorsInterceptor (after) →
      TransformInterceptor (after) →
    CacheInterceptor (after) →
  TimeoutInterceptor (after) →
LoggingInterceptor (after)

Підсумок

TransformInterceptor

Уніфікація структури відповідей API у формат { data, meta, timestamp }.

CacheInterceptor

Кешування результатів у Redis для зменшення навантаження на БД.

TimeoutInterceptor

Автоматичне скасування запитів через RxJS timeout().

SerializeInterceptor

Виключення чутливих полів (паролів, токенів) через class-transformer.

У наступній лекції ми розглянемо Exception Filters — компоненти для обробки виключень та формування структурованих відповідей про помилки.

Copyright © 2026