← назад к разделу

Когда вы пишете REST API, другие разработчики должны понять, как им пользоваться. OpenAPI — это стандарт описания REST API: структура эндпоинтов, форматы запросов и ответов, возможные коды ошибок. NestJS умеет генерировать OpenAPI-документ автоматически — прямо из кода вашего контроллера.

Разберём, как это настроить правильно, и какие ошибки встречаются чаще всего.

Что такое SwaggerModule и зачем он нужен

Раньше документацию к API писали вручную: отдельный YAML или JSON-файл, который приходилось обновлять после каждого изменения. Файл быстро расходился с кодом — и разработчики переставали ему доверять.

SwaggerModule в NestJS решает эту проблему: документация генерируется из аннотаций на ваших контроллерах при старте приложения. Изменили контроллер — изменилась документация.

// main.ts
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';

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

  app.setGlobalPrefix('api');
  app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' });

  if (process.env.NODE_ENV !== 'production') {
    const config = new DocumentBuilder()
      .setTitle('Orders API')
      .setVersion('1.0')
      .addBearerAuth()
      .build();
    const document = SwaggerModule.createDocument(app, config);
    SwaggerModule.setup('docs', app, document);
  }

  await app.listen(3000);
}

Несколько важных моментов:

  • setGlobalPrefix('api') добавляет /api ко всем маршрутам — документация и код используют одинаковые пути.
  • URI-версионирование даёт /api/v1/....
  • Swagger UI показывается только вне production — не стоит открывать внутренние детали API всем в интернете.

operationId — имя операции

Каждый маршрут в OpenAPI-спецификации имеет operationId — уникальное имя. Это имя становится именем метода в автоматически сгенерированных клиентских библиотеках.

Если не задать operationId явно, NestJS генерирует его сам: получается что-то вроде OrdersController_findAll — бесполезное имя для потребителя вашего API.

@Controller('orders')
@ApiTags('Orders')
export class OrdersController {

  @Get()
  @ApiOperation({ operationId: 'getOrders', summary: 'Список заказов' })
  findAll(@Query() query: OrdersQueryDto): Promise<OrdersPageDto> { ... }

  @Get(':orderId')
  @ApiOperation({ operationId: 'getOrder', summary: 'Получить заказ' })
  findOne(@Param('orderId') orderId: string): Promise<OrderDto> { ... }

  @Post()
  @ApiOperation({ operationId: 'createOrder', summary: 'Создать заказ' })
  create(@Body() body: CreateOrderDto): Promise<OrderDto> { ... }

  @Put(':orderId')
  @ApiOperation({ operationId: 'updateOrder', summary: 'Заменить заказ' })
  replace(@Param('orderId') orderId: string, @Body() body: UpdateOrderDto): Promise<OrderDto> { ... }

  @Patch(':orderId')
  @ApiOperation({ operationId: 'patchOrder', summary: 'Частично обновить заказ' })
  patch(@Param('orderId') orderId: string, @Body() body: PatchOrderDto): Promise<OrderDto> { ... }

  @Delete(':orderId')
  @HttpCode(204)
  @ApiOperation({ operationId: 'deleteOrder', summary: 'Удалить заказ' })
  remove(@Param('orderId') orderId: string): Promise<void> { ... }

  @Post(':orderId/confirm')
  @HttpCode(200)
  @ApiOperation({ operationId: 'confirmOrder', summary: 'Подтвердить заказ' })
  confirm(@Param('orderId') orderId: string): Promise<OrderDto> { ... }
}

Соглашение по именованию operationId:

ДействиеПример
Получить один ресурсgetOrder
Получить списокgetOrders
СоздатьcreateOrder
Заменить целикомupdateOrder
Частично обновитьpatchOrder
УдалитьdeleteOrder
Выполнить действиеconfirmOrder
ПоискsearchOrders

Формат: camelCase, действие + ресурс.

@ApiTags — группировка эндпоинтов

В Swagger UI эндпоинты можно группировать по тегам. Без тегов все маршруты свалятся в одну кучу, и в ней сложно ориентироваться.

Правило простое: один тег на ресурс, множественное число с заглавной буквы.

@Controller('orders')
@ApiTags('Orders')
export class OrdersController { ... }

@Controller('customers')
@ApiTags('Customers')
export class CustomersController { ... }

Если у ресурса есть вложенные эндпоинты — они используют тег родительского ресурса:

@Controller('orders/:orderId/items')
@ApiTags('Orders')   // не создаём отдельный тег OrderItems
export class OrderItemsController {

  @Get()
  @ApiOperation({ operationId: 'getOrderItems', summary: 'Позиции заказа' })
  findAll(@Param('orderId') orderId: string): Promise<OrderItemsPageDto> { ... }

  @Post()
  @ApiOperation({ operationId: 'addOrderItem', summary: 'Добавить позицию' })
  add(@Param('orderId') orderId: string, @Body() body: AddOrderItemDto): Promise<OrderItemDto> { ... }
}

Так в Swagger UI все эндпоинты заказов оказываются в одной группе Orders.

Параметры пути

Когда у маршрута несколько параметров пути, важно дать каждому уникальное имя:

// Правильно: уникальные имена
@Get(':orderId/items/:itemId')
@ApiParam({ name: 'orderId', schema: { type: 'string', format: 'uuid' } })
@ApiParam({ name: 'itemId', schema: { type: 'string', format: 'uuid' } })
findItem(
  @Param('orderId') orderId: string,
  @Param('itemId') itemId: string,
): Promise<OrderItemDto> { ... }
// Неправильно: одинаковые :id — Swagger/Redoc не работают корректно
@Get(':id/items/:id')
findItem(@Param('id') ...) { ... }

Swagger и Redoc идентифицируют параметры по имени. Два параметра с одним именем id — это конфликт, который инструменты обрабатывают непредсказуемо.

summary и description

summary — короткое описание маршрута (до 80 символов). Отображается прямо рядом с маршрутом в Swagger UI. Должен быть на каждом эндпоинте.

description — расширенное описание в формате Markdown. Добавляйте только когда поведение неочевидно:

@Post(':orderId/confirm')
@HttpCode(200)
@ApiOperation({
  operationId: 'confirmOrder',
  summary: 'Подтвердить заказ',
  description: `Переводит заказ из статуса CREATED в CONFIRMED.
Заказ должен содержать хотя бы одну позицию.
После подтверждения изменение состава заказа невозможно.`,
})
confirm(@Param('orderId') orderId: string): Promise<OrderDto> { ... }

Пустая description: '' хуже, чем её отсутствие — не добавляйте ради самого факта.

Схемы из DTO-классов

В NestJS OpenAPI-схемы генерируются из ваших DTO-классов. Есть два подхода.

С CLI-плагином (рекомендуется — меньше кода):

// nest-cli.json
{
  "compilerOptions": {
    "plugins": [{ "name": "@nestjs/swagger" }]
  }
}
// Плагин выводит типы из TypeScript — @ApiProperty не нужен
export class CreateOrderDto {
  customerId: string;
  items: CreateOrderItemDto[];
  note?: string;
}

Без плагина — явные декораторы:

export class OrderDto {
  @ApiProperty({ format: 'uuid' })
  orderId: string;

  @ApiProperty({ enum: OrderStatus, enumName: 'OrderStatus' })
  status: OrderStatus;

  @ApiProperty({ type: [OrderItemDto] })
  items: OrderItemDto[];

  @ApiPropertyOptional()
  note?: string;
}

Несколько деталей, которые важно знать:

  • @ApiPropertyOptional() исключает поле из списка required в спецификации.
  • Поле note?: string при значении undefined вообще не попадает в JSON-ответ — это правильно, не подставляйте null.
  • enumName: 'OrderStatus' в @ApiProperty обязателен. Без него Swagger создаёт анонимный enum в каждой схеме вместо одного переиспользуемого компонента.

Значения enum — в формате UPPER_SNAKE_CASE на английском:

export enum OrderStatus {
  CREATED = 'CREATED',
  CONFIRMED = 'CONFIRMED',
  SHIPPED = 'SHIPPED',
  DELIVERED = 'DELIVERED',
  CANCELLED = 'CANCELLED',
}

Ошибки в OpenAPI

Схема ошибок должна быть описана в спецификации — иначе потребитель не знает, что ждать при 400 или 404.

export class ProblemDetailsDto {
  @ApiProperty()
  type: string;

  @ApiProperty()
  title: string;

  @ApiProperty()
  status: number;

  @ApiProperty()
  detail: string;

  @ApiProperty()
  instance: string;

  @ApiProperty({ enum: ErrorCode, enumName: 'ErrorCode' })
  code: ErrorCode;

  @ApiPropertyOptional({ type: [ViolationDto] })
  violations?: ViolationDto[];
}
@Post()
@ApiOperation({ operationId: 'createOrder', summary: 'Создать заказ' })
@ApiResponse({ status: 201, type: OrderDto })
@ApiResponse({
  status: 400,
  description: 'Ошибка валидации',
  schema: {
    example: {
      type: 'urn:problem:orders:validation-error',
      title: 'Validation Error',
      status: 400,
      detail: 'Request validation failed',
      instance: '/api/v1/orders',
      code: 'VALIDATION_ERROR',
      violations: [
        { field: 'customerId', message: 'customerId is required' },
      ],
    },
  },
})
@ApiResponse({ status: 404, description: 'Клиент не найден' })
create(@Body() body: CreateOrderDto): Promise<OrderDto> { ... }

Content-Type: application/problem+json для ошибок выставляется в Exception Filter — не здесь. Но пример в спецификации помогает клиенту понять реальный формат без чтения исходного кода.

Частые ошибки при проектировании API

Глаголы в URL и неправильная структура путей

Частая ошибка — добавлять глаголы в URL:

// Неправильно
@Get('get-orders')
@Get('orderItems')   // camelCase в пути
@Get('orders/')      // trailing slash
// Правильно
@Get()                // GET /orders
@Get('order-items')   // kebab-case
@Get('orders')        // без слеша

URL описывает ресурс, а не действие. Если нужно выполнить действие — используйте POST с глаголом в последнем сегменте: POST /orders/:id/confirm.

Не передавайте ID ресурса в теле при PUT/PATCH — только в параметре пути:

// Неправильно: orderId в теле
@Put(':orderId')
update(@Body() body: { orderId: string, ... }) { ... }

// Правильно: orderId в @Param
@Put(':orderId')
update(@Param('orderId') orderId: string, @Body() body: UpdateOrderDto) { ... }

Избегайте более двух уровней вложенности. GET /orders/:orderId/items — нормально. GET /orders/:orderId/items/:itemId/subitems — признак плохой структуры ресурсов.

Версионирование

  • Версия в query-параметре (?api-version=1) — неудобно кешировать и ненаглядно.
  • Минорная версия в пути (/v1.2/) — бесполезно, клиенты ориентируются на мажорную.
  • Отсутствие глобального префикса (/orders вместо /api/v1/orders) — смешивает API с фронтендом.
  • Новая мажорная версия только ради добавления необязательного поля — достаточно @ApiPropertyOptional().

JSON и формат ответов

Распространённые ошибки в формате ответов:

  • null в ответе там, где поле просто отсутствует — используйте undefined, оно не сериализуется.
  • Пустая строка "" вместо отсутствия поля.
  • Обёртка вроде { success: true, data: {...} } — отдавайте ресурс напрямую.

Заголовки и ошибки

  • Нестандартные заголовки лучше называть с доменным префиксом (Shop-Request-Id), а не с устаревшим X- (X-Request-Id).
  • Для ошибок используйте Content-Type: application/problem+json, а не application/json.
  • В type ошибки пишите конкретный URN: urn:problem:orders:not-found, не about:blank.
  • Stack trace в теле 500-ошибки — не отправляйте пользователям никогда.

Устаревание эндпоинтов

Когда помечаете маршрут как устаревший через @ApiOperation({ deprecated: true }), добавляйте заголовок Sunset с датой отключения. Иначе потребители не знают, сколько у них времени на миграцию.

Action-эндпоинты

Для действий над ресурсом используйте POST с глаголом в URL:

// Правильно
@Post(':orderId/confirm')   // POST /orders/:orderId/confirm

// Неправильно: PUT/PATCH для действия
@Put(':orderId/confirm')

// Неправильно: существительное вместо глагола
@Post(':orderId/confirmation')

Коротко

  • SwaggerModule генерирует документацию из аннотаций в коде — подключайте только вне production.
  • operationId задавайте явно через @ApiOperation — без него NestJS генерирует нечитаемые имена вроде OrdersController_findAll.
  • @ApiTags — один тег на ресурс, множественное число с заглавной; вложенные контроллеры наследуют тег родителя.
  • Параметры пути именуйте уникально: :orderId, :itemId, а не два :id.
  • summary обязателен на каждом маршруте; description — только когда логика неочевидна.
  • Схемы — из DTO-классов; используйте CLI-плагин чтобы писать меньше декораторов.
  • @ApiPropertyOptional() исключает поле из required; undefined не сериализуется в JSON.
  • Глаголы в URL — признак проблемы в дизайне; действия — через POST с глаголом в пути.
  • null в ответе вместо отсутствия поля, обёртки { success, data }, stack trace в ошибках — частые ошибки, которых стоит избегать с самого начала.

Что почитать дальше

  • URL и ресурсы — NestJS — как строить пути и именовать параметры.
  • Версионирование — NestJS — enableVersioning и управление изменениями.
  • Ошибки RFC 9457 — NestJS — Exception Filters и формат ProblemDetails.
  • JSON и формат ответов — NestJS — undefined vs null, структура ответа.
  • Query-параметры — NestJS — Query-DTO и массивы.