Strona główna / Artykuły

Jeden kopertka JSON na każdą trasę NestJS: interceptorzy plus filtr globalny

Otocz każdą odpowiedź NestJS typowanym „kopertem” za pomocą interceptora ogólnego, dekoratora wiadomości oraz filtru wyjątków, zachowując przy tym kontrolery wolne od kodu szablonowego.

Wydawca: REACTAPP.TOP6 min czytania1288 słów

Źródła: Standardizing NestJS API Responses with Custom Interceptors and Generics

Gdy każdy punkt końcowy zwraca nieco inny format, klienci API muszą pisać kod w sposób obronny. Spójna struktura opakowania pozwala użytkownikom napisać jeden obsługujący odpowiedzi mechanizm zamiast wielu. NestJS umożliwia to bez konieczności modyfikowania każdego kontrolera: interceptor przekształca udane wyniki, dekorator dostarcza komunikaty specyficzne dla danej trasy, a filtr wyjątków nadaje błędom ten sam format. Ten przewodnik opisuje implementację wszystkich trzech elementów.

Dlaczego opakowywanie odpowiedzi w kontrolerach nie jest skalowalne

Naiwny podejście polega na ręcznym tworzeniu tej struktury w każdym mechanizmie obsługi:

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

Powtarzanie się tego w dziesiątkach tras powoduje z biegiem czasu coraz większą duplikację kodu. Kontrolery powinny przyjmować dane wejściowe i zwracać informacje z domeny; formatowanie wysyłanych danych to kwestia wspólna dla wielu elementów, która powinna być obsługiwana w ramach cyklu życia żądania. (Należy również zauważyć, że w tym fragmencie użyto await w metodzie, która nie jest oznaczona jako async, co uniemożliwia skompilowanie kodu; to kolejny powód, by usunąć takie operacje z kontrolerów.)

Definiowanie kontraktu odpowiedzi

Należy zacząć od typów. IResponseEntity<T> jest generyczny w odniesieniu do treści odpowiedzi, a opcjonalny obiekt meta zawiera informacje o paginacji:

// 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;
}

Wiadomości specyficzne dla danej trasy z dekoratorem metadanych

Koniecpunkt tworzenia i koniecpunkt listy wymagają różnych komunikatów o sukcesie. SetMetadata przypisuje wartość do obsługi trasy, a niewielki wrapper nadaje jej czytelne nazwę:

// 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);

Interceptor transformacji

Interceptor implementuje NestInterceptor>, co wskazuje, że przekształca wynik obsługi o typie T w kopertę. Przed wywołaniem obsługi odczytuje aktualny kod stanu z odpowiedzi i pobiera wiadomość dostosowaną za pomocą Reflector, przyjmując wartość domyślną 'Success'. Następnie przekazuje obserwowalny obiekt obsługi przez funkcję map w RxJS. Jeśli wynik to obiekt zawierający zarówno data, jak i meta, traktuje się go jako wynik paginowany i dzieli na odpowiednie pola koperty; wszystko inne staje się bezpośrednio wartością 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,
        };
      }),
    );
  }
}

Kilka szczegółów, które warto znać:

  • reflector.get() odczytuje wyłącznie metadane z obsługiwanego elementu. Jeśli chcesz również domyślną wiadomość na poziomie klasy, getAllAndOverride() w połączeniu z context.getHandler() i context.getClass() rozwiązuje ten problem.
  • Stan jest odczytywany przed uruchomieniem obsługiwanego elementu. Dzięki temu pobierane są wartości domyślne oraz informacje z @HttpCode(), ale jeśli obsługa zmienia stan dynamicznie, bezpieczniej jest odczytać response.statusCode wewnątrz funkcji map.
  • Sprawdzenie paginacji opiera się na zasadzie duck typing. Obiekt domenowy, który przypadkowo posiada właściwości data i meta, mógłby zostać błędnie rozpakowany; dedykowana klasa wraz z sprawdzeniem typu za pomocą instanceof jest bardziej niezawodna.

Rегистrowanie interfektora dla całego aplikacji

Rегистrowanie na poziomie globalnym w main.ts stosuje interceptor do każdego kontrolera bez konieczności używania @UseInterceptors() w każdej klasie. Ponieważ jest on tworzony poza systemem modułów, Reflector musi być pobrany z aplikacji i przekazany ręcznie:

// 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();

Alternatywą jest zarejestrowanie go jako dostawcy pod tokenem APP_INTERCEPTOR w module korzeniowym. Wtedy Nest konstruuje go przy użyciu pełnej iniekcji zależności.

Kontrolery po refaktoryzacji

Kontrolery zwracają teraz proste entity lub obiekt { data, meta }, a ich funkcje są deklarowane za pomocą dekoratora:

// 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();
  }
}

Zapytanie do endpointu listy generuje następujący obiekt:

{
  "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
  }
}

Dawanie błędom tej samej struktury

map interceptora jest wykonywany tylko dla wartości, które z powodzeniem zostały wysłane przez obsługę. Przypadek rzucenia instancji HttpException, błędy walidacji oraz nieoczekiwane błędy omijają ten interceptor i docierają do klientów w domyślnym formacie błędów Nesta.

Filtr wyjątków zamienia tę lukę. Ten poniżej używa prostego @Catch(), więc obsługuje każdy wyjątek. Status błędu pobiera z instancji HttpException, w przeciwnym razie używa wartości 500. Wyodrębnia komunikat, łączy tablice (takie jak lista wygenerowana przez ValidationPipe) w jedną ciąg znaków, a następnie odpowiada z wartościami status: false i 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,
    });
  }
}

Zarejestruj go globalnie, na przykład za pomocą app.useGlobalFilters() lub tokena APP_FILTER. Jedna uwaga: w przypadku błędów innych niż HTTP ten filtr zwraca do klienta surowy tekst exception.message, co może ujawnić wewnętrzne szczegóły. W środowisku produkcyjnym należy zapisywać oryginalny błąd i wysyłać ogólny komunikat w odpowiedziach typu 500.

Jeśli wolisz zastosować standardową strukturę błędów zamiast własnej, warto zapoznać się z szczegółami problemu RFC 9457.

Główne wnioski

  • Zachowuj kontrolery proste i pozwalaj mechanizmom cyklu życia zajmować się obsługą odpowiedzi.
  • Ogólny interceptor z użyciem funkcji map z RxJS przekształca pomyślne operacje; dekorator metadanych dostosowuje komunikaty do poszczególnych tras.
  • Interceptory nigdy nie widzą rzucanych błędów, dlatego należy je połączyć z globalnym filtrem wyjątków, aby obie ścieżki były w tym samym formacie.
  • Nigdy nie ujawniaj surowych, wewnętrznych komunikatów o błędach.
  • Literatura pokrewna