Когда вы пишете 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 —
undefinedvsnull, структура ответа. - Query-параметры — NestJS — Query-DTO и массивы.