Опирается на правила: R-OAS-1..4, R-PRIN-X1, R-URL-X1..4, R-MTH-X1, R-NEST-X1..3, R-VER-X1..4, R-QRY-X1..5, R-RSP-X1..4, R-HDR-X1, R-ERR-X1..4, R-RATE-X1, R-DEP-X1, R-ALIAS-X1..2, R-ACT-X1..2, R-LOC-X1раздел OpenAPI-метаданные и антипаттерны.

Важно знать

  • Code-first: в NestJS DTO-класс — источник контракта; SwaggerModule генерирует спеку из декораторов, дублировать правила нельзя.
  • operationId задаётся явно через @ApiOperation({ operationId: '...' }); без него NestJS генерирует OrdersController_createOrder вместо createOrder.
  • @ApiTags на контроллере — один тег на ресурс, множественное число с заглавной (Orders, Customers); action-эндпоинты наследуют тег родительского контроллера.
  • Path-параметры в OpenAPI именуются уникально (:orderId, :itemId) — требование Swagger/Redoc; в дизайне URL — {id}, контекст устраняет неоднозначность.
  • summary обязателен на каждом маршруте; description — только если логика неочевидна.
  • SwaggerModule подключается вне production (NODE_ENV !== 'production'), не проксируется через public-балансировщик.
  • Сводка антипаттернов — единая таблица из всех разделов гайда; используется как checklist на ревью.

SwaggerModule в NestJS строит OpenAPI-спецификацию из декораторов во время старта приложения. Хорошо оформленный контракт генерирует client SDK, Postman Collections и служит документацией для потребителей. Плохо оформленный — даёт postOrdersOrderIdConfirm вместо confirmOrder и flat-список из 100 эндпоинтов без группировки.

Подключение SwaggerModule

// 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') + URI-versioning дают /api/v1/... во всех маршрутах (R-VER-1..3). Спека доступна только вне production — не экспонировать через public-балансировщик.

operationId

R-OAS-1: уникальный, camelCase, действие + ресурс.

@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> { ... }

  @Post('search')
  @HttpCode(200)
  @ApiOperation({ operationId: 'searchOrders', summary: 'Поиск заказов' })
  search(@Body() body: OrderSearchDto): Promise<OrdersPageDto> { ... }
}

Конвенция operationId:

ПаттернПример
get{Resource}getOrder — единичный ресурс
get{Resources}getOrders — список
create{Resource}createOrder — POST создание
update{Resource}updateOrder — PUT замена
patch{Resource}patchOrder — PATCH
delete{Resource}deleteOrder — DELETE
{verb}{Resource}confirmOrder — action
search{Resources}searchOrders — POST /search

Почему это критично: openapi-generator использует operationId как имя метода в client SDK. Без явного задания NestJS генерирует OrdersController_findAll — бесполезное имя для потребителя.

@ApiTags — группировка

R-OAS-2: один тег на ресурс, множественное число с заглавной.

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

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

@Controller('products')
@ApiTags('Products')
export class ProductsController { ... }

Action-эндпоинты POST /orders/:orderId/confirm, POST /orders/:orderId/cancel объявляются внутри OrdersController — они автоматически наследуют тег Orders. Отдельный тег OrderActions не создаётся.

Вложенный контроллер для позиций заказа:

@Controller('orders/:orderId/items')
@ApiTags('Orders')
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> { ... }
}

OrderItemsController использует тот же тег Orders — в Swagger UI все эндпоинты заказов в одной группе.

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

R-OAS-3: уникальные имена в маршруте, контекстное именование.

// PREFER: уникальные параметры
@Get(':orderId/items/:itemId')
@ApiOperation({ operationId: 'getOrderItem', summary: 'Позиция заказа' })
@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> { ... }
// AVOID: одинаковые :id — Swagger/Redoc не работают корректно
@Get(':id/items/:id')
findItem(@Param('id') ...) { ... }

В дизайне URL (PR-описание, спека) пишется {id} — контекст сегмента orders/{id}/items/{id} устраняет неоднозначность. В NestJS-маршруте и OpenAPI — :orderId/:itemId (R-NEST-4 vs R-OAS-3): это разрыв намеренный, требование инструмента.

summary и description

R-OAS-4: summary обязателен, description — если логика неочевидна.

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

summary до 80 символов — отображается в Swagger UI рядом с маршрутом. description — Markdown, опциональный; пустая строка хуже отсутствия.

Схемы из DTO

В NestJS схемы OpenAPI генерируются из DTO-классов. Два способа:

Через CLI-плагин (рекомендовано — меньше декораторов):

// nest-cli.json
{
  "compilerOptions": {
    "plugins": [{ "name": "@nestjs/swagger" }]
  }
}
// С плагином: @ApiProperty генерируется автоматически из типов TS
export class CreateOrderDto {
  customerId: string;
  items: CreateOrderItemDto[];
  note?: string;
}

Явные @ApiProperty (без плагина):

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 в спеке (R-RSP-8). note?: string в TS выпадает из JSON при undefined — нет null в ответе (R-RSP-X1).

Enum-значения — UPPER_SNAKE_CASE (R-FLD-3):

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

enumName: 'OrderStatus' в @ApiProperty важен — без него Swagger генерирует анонимный enum в каждой схеме вместо переиспользуемого компонента.

Ошибки в OpenAPI

R-ERR-7..8: ProblemDetails схема и examples для каждого маршрута.

// problem-details.dto.ts
export class ViolationDto {
  @ApiProperty()
  field: string;

  @ApiProperty()
  code: string;

  @ApiProperty()
  message: string;
}

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

  @ApiProperty()
  title: string;

  @ApiProperty()
  status: number;

  @ApiProperty()
  detail: string;

  @ApiProperty()
  instance: string;

  @ApiPropertyOptional()
  code?: string;

  @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', code: 'REQUIRED', message: 'customerId is required' },
      ],
    },
  },
})
@ApiResponse({ status: 404, description: 'Клиент не найден' })
create(@Body() body: CreateOrderDto): Promise<OrderDto> { ... }

Content-Type: application/problem+json выставляется в Exception Filter (R-ERR-3), не здесь. Примеры нужны в спеке — клиент видит реальный формат без чтения кода.

Что запрещено

URL

АнтипаттернПравилоЧто взамен
Глагол в URL: @Get('get-orders')R-URL-X4@Get() на ресурсе
CamelCase в пути: @Get('orderItems')R-URL-X1@Get('order-items')
Trailing slash: @Get('orders/')R-URL-X2@Get('orders')
ID в теле: body.orderId при PUTR-NEST-X2@Param('orderId')
Три уровня вложенностиR-NEST-X1верхний уровень + фильтр
GET с побочным эффектомR-MTH-X1@Post action-эндпоинт

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

АнтипаттернПравилоЧто взамен
Версия в query: ?api-version=1R-VER-X2URI-versioning v1
Минорная версия в пути: /v1.2/R-VER-X1только /v1/
Маршрут без /api: @Controller('orders') без prefixR-VER-X3setGlobalPrefix('api')
Новая версия для optional поляR-VER-X4@ApiPropertyOptional() в текущей
Нет operationId: NestJS генерирует OrdersController_createR-OAS-1@ApiOperation({ operationId: 'createOrder' })
Нет @ApiTags на контроллереR-OAS-2@ApiTags('Orders')
Нет summary на маршрутеR-OAS-4summary: 'Создать заказ'

Query

АнтипаттернПравилоЧто взамен
Comma-separated массив: ?status=A,BR-QRY-X3?status=A&status=B + @IsArray()
snake_case в параметре: ?order_id=R-QRY-X1camelCase ?orderId=
0-based page: ?page=0R-QRY-X2page=1 1-based
Бизнес-логика в query: ?action=cancelR-QRY-X4POST /orders/:id/cancel

JSON и ответы

АнтипаттернПравилоЧто взамен
null в ответе: note: nullR-RSP-X1note?: stringundefined выпадает
Пустая строка: note: ""R-RSP-X2отсутствие поля
nullable: true в @ApiPropertyR-RSP-X3@ApiPropertyOptional()
Envelope: { success: true, data: {...} }R-RSP-X4плоский ресурс
HATEOAS-ссылки в телеR-PRIN-X1OpenAPI описывает навигацию

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

АнтипаттернПравилоЧто взамен
Префикс X- в заголовке: X-Request-IdR-HDR-X1доменный префикс Shop-Request-Id
Content-Type: application/json для ошибокR-ERR-X1application/problem+json
type: "about:blank" в ProblemDetailsR-ERR-X2urn:problem:orders:not-found
Stack trace в теле 500R-ERR-X4code + общий detail
429 без Retry-AfterR-RATE-X1Retry-After + RateLimit-*

Deprecation и прочее

АнтипаттернПравилоЧто взамен
@ApiOperation({ deprecated: true }) без SunsetR-DEP-X1interceptor добавляет Sunset: 2026-12-01
me вместо контекста из токенаR-ALIAS-X1/profile, /settings (singleton)
Существительное в action: /confirmationR-ACT-X1/confirm — глагол
@Put / @Patch для actionR-ACT-X2@Post action-эндпоинт
Локализация enum-кода: ПОДТВЕРЖДЁНR-LOC-X1CONFIRMED — английский

Куда дальше

  • node/url-and-resources.md — R-NEST-4 {id} vs :orderId в NestJS.
  • node/alias-and-actions.md — operationId для action-эндпоинтов.
  • node/versioning.md — enableVersioning и breaking change в OpenAPI.
  • node/errors.md — Exception Filters и ProblemDetails-схема.
  • node/json-and-responses.md — undefined vs null, envelope.
  • node/query-params.md — Query-DTO, массивы повтором.
  • node/headers.md — кастомные заголовки без X-.
  • node/rate-limiting-files-deprecation.md — Sunset и @nestjs/throttler.
  • node/batch-async-localization.md — 202 Accepted и taskId в OpenAPI.
  • use-case-pattern/ — UseCase соответствует operationId в контракте.