Галоўная / Артыкулы / Адзін 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 ўартуюць увагі.

Ключовыя вынікі

  • Робіце кантролеры простымі і дазвольце механізму жыцёвага циклу кераваць ўсімі процесамі.
  • Універсальны інтэрцептор за дапамою RxJS map адпраўляе успехі; декоратар метадаў настраюе паведамленні для кожнага маршруту.
  • Інтерцэптары ніколі не бачаюць выкарыстоўваных памылак, таму іх трэба супараджваць з глобальным фільтрам асобых ситуацый, каб обе дарожкі застаўаліся у аднаковым формате.
  • Ніколі не павінны выкліквацца неапранутыя внутрашніе паведамлення пра памылкі.