Практичні приклади Interceptors
Практичні приклади 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
}
}
}
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;
}
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
{ data, meta, timestamp }.CacheInterceptor
TimeoutInterceptor
timeout().SerializeInterceptor
Так, через перевірку методу у before phase:
intercept(context: ExecutionContext, next: CallHandler) {
const request = context.switchToHttp().getRequest();
if (request.method !== 'GET') {
return next.handle(); // Пропустити для не-GET
}
// Кешувати лише GET-запити
return this.cacheLogic(context, next);
}
Використовуйте паттерн з очищенням кешу:
@Post()
async create(@Body() dto: CreateDto) {
const result = await this.service.create(dto);
// Видалити всі ключі, що починаються з cache:GET:
const keys = await this.redis.keys('cache:GET:/api/products*');
if (keys.length > 0) {
await this.redis.del(...keys);
}
return result;
}
Або інтегруйте з Event Emitter для автоматичної інвалідації.
У наступній лекції ми розглянемо Exception Filters — компоненти для обробки виключень та формування структурованих відповідей про помилки.