Опирается на правила:
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 при PUT | R-NEST-X2 | @Param('orderId') |
| Три уровня вложенности | R-NEST-X1 | верхний уровень + фильтр |
| GET с побочным эффектом | R-MTH-X1 | @Post action-эндпоинт |
Версионирование и OpenAPI
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Версия в query: ?api-version=1 | R-VER-X2 | URI-versioning v1 |
Минорная версия в пути: /v1.2/ | R-VER-X1 | только /v1/ |
Маршрут без /api: @Controller('orders') без prefix | R-VER-X3 | setGlobalPrefix('api') |
| Новая версия для optional поля | R-VER-X4 | @ApiPropertyOptional() в текущей |
Нет operationId: NestJS генерирует OrdersController_create | R-OAS-1 | @ApiOperation({ operationId: 'createOrder' }) |
Нет @ApiTags на контроллере | R-OAS-2 | @ApiTags('Orders') |
Нет summary на маршруте | R-OAS-4 | summary: 'Создать заказ' |
Query
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Comma-separated массив: ?status=A,B | R-QRY-X3 | ?status=A&status=B + @IsArray() |
snake_case в параметре: ?order_id= | R-QRY-X1 | camelCase ?orderId= |
0-based page: ?page=0 | R-QRY-X2 | page=1 1-based |
Бизнес-логика в query: ?action=cancel | R-QRY-X4 | POST /orders/:id/cancel |
JSON и ответы
| Антипаттерн | Правило | Что взамен |
|---|---|---|
null в ответе: note: null | R-RSP-X1 | note?: string → undefined выпадает |
Пустая строка: note: "" | R-RSP-X2 | отсутствие поля |
nullable: true в @ApiProperty | R-RSP-X3 | @ApiPropertyOptional() |
Envelope: { success: true, data: {...} } | R-RSP-X4 | плоский ресурс |
| HATEOAS-ссылки в теле | R-PRIN-X1 | OpenAPI описывает навигацию |
Заголовки и ошибки
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Префикс X- в заголовке: X-Request-Id | R-HDR-X1 | доменный префикс Shop-Request-Id |
Content-Type: application/json для ошибок | R-ERR-X1 | application/problem+json |
type: "about:blank" в ProblemDetails | R-ERR-X2 | urn:problem:orders:not-found |
| Stack trace в теле 500 | R-ERR-X4 | code + общий detail |
429 без Retry-After | R-RATE-X1 | Retry-After + RateLimit-* |
Deprecation и прочее
| Антипаттерн | Правило | Что взамен |
|---|---|---|
@ApiOperation({ deprecated: true }) без Sunset | R-DEP-X1 | interceptor добавляет Sunset: 2026-12-01 |
me вместо контекста из токена | R-ALIAS-X1 | /profile, /settings (singleton) |
Существительное в action: /confirmation | R-ACT-X1 | /confirm — глагол |
@Put / @Patch для action | R-ACT-X2 | @Post action-эндпоинт |
Локализация enum-кода: ПОДТВЕРЖДЁН | R-LOC-X1 | CONFIRMED — английский |
Куда дальше
- 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 —
undefinedvsnull, 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в контракте.