Головна / Статті / Одна JSON-конвертка на кожен маршрут NestJS: інтерцептори та глобальний фільтр

Одна JSON-конвертка на кожен маршрут NestJS: інтерцептори та глобальний фільтр

Обгорніть кожну відповідь NestJS у типізований „ конверт “ за допомогою генеричного інтерцептора, декоратора повідомлень та фільтра винятків, залишаючи при цьому контролери без зайвого коду.

1288 слів

Коли кожен кінцевий пункт повертає трохи іншу структуру даних, клієнти API змушені писати код із захистом від таких ситуацій. Єдина структура даних дозволяє користувачам писати лише один обробник відповідей замість багатьох. NestJS уможливлює це без змін кожного контролера: інтерцептор трансформує успішні результати, декоратор надає повідомлення для кожного маршруту, а фільтр винятків надає помилкам однакову структуру. У цьому посібнику описано створення всіх трьох елементів.

Чому обгортання відповідей у контролерах не є масштабованим

Примітивний підхід передбачає ручне формування цієї структури в кожному обробнику:

@Get()
findAll() {
  const users = await this.userService.findAll();
  return {
    code: 200,
    status: true,
    message: 'Success retrieve users',
    data: users
  };
}

Це повторення, яке зустрічається у десятках маршрутів, з часом лише посилюється. Контролери повинні приймати вхідні дані та повертати інформацію про домен; форматування вихідних даних є проблемою, яка стосується всіх маршрутів та має бути реалізована під час життєвого циклу запиту. (Також зверніть увагу, що у цьому фрагменті використовується await у методі, який не позначений як async, що унеможливлює компіляцію; це ще одна причина вивести цю логіку з контролерів.)

Визначення контракту відповіді

Почніть з типів. IResponseEntity<T> є генеричним для вмісту даних, а необов’язковий об’єкт meta містить деталі сторінкування:

// src/common/interfaces/response.interface.ts

export interface ImetaPagination {
  page: number;
  limit: number;
  totalItems: number;
  totalPages: number;
  hasNextPage: boolean;
  hasPrevPage: boolean;
}

export interface IResponseEntity<T> {
  code: number;
  status: boolean;
  message: string;
  data?: T;
  meta?: ImetaPagination;
}

Повідомлення для кожного маршруту з декоратором метаданих

Кінцева точка створення та кінцева точка отримання списку потребують різних повідомлень про успіх. SetMetadata додає значення до обробника маршруту, а невеликий обгортковий клас надає йому зрозумілої назви:

// src/common/decorators/response-message.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const RESPONSE_MESSAGE_METADATA = 'response_message';
export const ResponseMessage = (message: string) =>
  SetMetadata(RESPONSE_MESSAGE_METADATA, message);

Інтерцептор трансформації

Інтерцептор реалізує NestInterceptor<T, IResponseEntity<T>>, що свідчить про те, що він перетворює результат обробника типу T на конверт. Перед викликом обробника він читає поточний код статусу з базової відповіді та отримує користувацьке повідомлення через Reflector, за замовчуванням використовуючи значення 'Success'. Потім він передає об’єкт, який повертає обробник, через функцію map у RxJS. Якщо результат — це об’єкт, що містить як data, так і meta, його розглядають як результат з пагінацією та розділяють на відповідні поля конверта; все інше стає значенням data безпосередньо.

// src/common/interceptors/transform-response.interceptor.ts
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { IResponseEntity } from '../interfaces/response.interface';
import { RESPONSE_MESSAGE_METADATA } from '../decorators/response-message.decorator';

@Injectable()
export class TransformResponseInterceptor<T>
  implements NestInterceptor<T, IResponseEntity<T>>
{
  constructor(private readonly reflector: Reflector) {}

  intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Observable<IResponseEntity<T>> {
    const http = context.switchToHttp();
    const response = http.getResponse();
    const statusCode = response.statusCode;

    // Extract custom message if set; default to 'Success'
    const customMessage =
      this.reflector.get<string>(
        RESPONSE_MESSAGE_METADATA,
        context.getHandler(),
      ) || 'Success';

    return next.handle().pipe(
      map((res) => {
        // Handle cases where service returns { data, meta } for pagination
        const hasMeta = res && typeof res === 'object' && 'meta' in res && 'data' in res;

        return {
          code: statusCode,
          status: true,
          message: customMessage,
          data: hasMeta ? res.data : res,
          meta: hasMeta ? res.meta : undefined,
        };
      }),
    );
  }
}

Деякі деталі, які варто знати:

  • reflector.get() читає метадані лише з обробника. Якщо ви також хочете отримати значення за замовчуванням на рівні класу, getAllAndOverride() у поєднанні з context.getHandler() та context.getClass() вирішує цю проблему.
  • Статус читається перед виконанням обробника. Це дозволяє отримати значення за замовчуванням та @HttpCode(), але якщо обробник динамічно змінює статус, безпечніше буде читати response.statusCode всередині map.
  • Перевірка пагінації ґрунтується на принципі duck typing. Об’єкт домену, який випадково має властивості data та meta, може бути помилково розпакований; використання спеціалізованого класу та перевірки instanceof є більш надійним.

Реєстрація інтерцептора для всього додатку

Реєстрація на глобальному рівні в main.ts застосовує інтерцептор до кожного контролеру без необхідності використання @UseInterceptors() у кожному класі. Оскільки Reflector створюється поза системою модулів, його потрібно отримати з додатку та передати вручну:

// src/main.ts
import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { TransformResponseInterceptor } from './common/interceptors/transform-response.interceptor';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const reflector = app.get(Reflector);
  app.useGlobalInterceptors(new TransformResponseInterceptor(reflector));

  await app.listen(3000);
}
bootstrap();

Альтернативою є реєстрація його як постачальника під токеном APP_INTERCEPTOR у кореневому модулі. Тоді Nest будує його з повною ін’єкцією залежностей.

Контролери після рефакторингу

Тепер контролери повертають прості ентитети або об’єкт { data, meta } та оголошують свої повідомлення за допомогою декоратора:

// src/users/users.controller.ts
import { Controller, Get, Post, Body } from '@nestjs/common';
import { UsersService } from './users.service';
import { ResponseMessage } from '../common/decorators/response-message.decorator';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  @ResponseMessage('User successfully created')
  create(@Body() dto: CreateUserDto) {
    return this.usersService.create(dto);
  }

  @Get()
  @ResponseMessage('Users retrieved successfully')
  findAll() {
    // Returns { data: [...], meta: { page: 1, limit: 10, ... } }
    return this.usersService.findAllPaginated();
  }
}

Запит до кінцевої точки списку створює такий конверт:

{
  "code": 200,
  "status": true,
  "message": "Users retrieved successfully",
  "data": [
    {
      "id": "usr_99",
      "name": "Alex Mercer",
      "email": "alex@example.com"
    }
  ],
  "meta": {
    "page": 1,
    "limit": 10,
    "totalItems": 1,
    "totalPages": 1,
    "hasNextPage": false,
    "hasPrevPage": false
  }
}

Надання помилкам однакової структури

Функція map інтерсептора виконується лише для значень, які успішно надсилає обробник. Екземпляри HttpException, помилки валідації та несподівані помилки обходять її та надсилаються клієнтам у стандартному форматі помилок Nest.

Фільтр винятків закриває цю прогалину. Наведений нижче фільтр використовує простий @Catch(), тому обробляє кожен виняток. Він отримує статус з екземплярів HttpException, а в іншому випадку використовує 500. Він видобуває повідомлення, об’єднує масиви (наприклад, список, створений ValidationPipe) у один рядок та відповідає з status: false та data: null:

// src/common/filters/http-exception.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();

    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    const exceptionResponse =
      exception instanceof HttpException ? exception.getResponse() : null;

    const message =
      typeof exceptionResponse === 'object' && exceptionResponse !== null
        ? (exceptionResponse as any).message || exception.toString()
        : exception instanceof Error
        ? exception.message
        : 'Internal server error';

    response.status(status).json({
      code: status,
      status: false,
      message: Array.isArray(message) ? message.join(', ') : message,
      data: null,
    });
  }
}

Зареєструйте його глобально, наприклад за допомогою app.useGlobalFilters() або токена APP_FILTER. Одна застереження: для помилок, що не є HTTP-помилками, цей фільтр повертає клієнту необроблене значення exception.message, що може призвести до витоку внутрішніх деталей. У продакшені необхідно записувати оригінальну помилку та надсилати загальне повідомлення для відповідей 500.

Якщо ви волієте використовувати стандартну форму повідомлення про помилку замість власної, варто ознайомитися з деталями проблеми RFC 9457.

Ключові висновки

  • Зробіть контролери простими та дозвольте механізму життєвого циклу керувати обгорткою даних.
  • Загальний інтерцептор з використанням map у RxJS обробляє успішні випадки; декоратор метаданих налаштовує повідомлення для кожного маршруту.
  • Інтерцептори ніколи не бачать кинутих помилок, тому їх слід поєднувати з глобальним фільтром винятків, щоб обидва шляхи залишалися у однаковому форматі.
  • Ніколи не відображайте сирі внутрішні повідомлення про помилки.