Опирается на правила:
R-BATCH-1..5,R-ASYNC-1..4,R-LOC-1..3и X-коды из REST API Style Guide → раздел Batch, async, локализация.
Важно знать
- Batch обрабатывается поэлементно (partial success). Атомарность явно декларируется в документации.
- Endpoint:
POST /resources/batchилиPOST /resources/batch/<action>.- Request:
{ items: [...] }. Response:200 OK+results+summary.@HttpCode(200)обязателен на batch-endpoint — NestJS по умолчанию отдаёт201для@Post.- Async через polling:
202 Accepted+Location+taskId+statusUrl.- Статусы задачи:
PENDING/PROCESSING/COMPLETED/FAILED.- При
COMPLETED— полеresultUrlобязательно. ПриFAILED— полеerrorобязательно.- Локализация:
Accept-Language→detailиviolations[].message. Enum-коды,title,type, имена JSON-полей — не локализуются.
Три сценария, не покрытых стандартным CRUD: массовые операции, длительные задачи, многоязычность. Каждый имеет стандартизованное решение — клиент знает, чего ожидать.
Batch-операции
R-BATCH-1..5: partial success, фиксированный формат запроса и ответа.
Endpoint и DTO
POST /api/v1/orders/batch ← batch для коллекции
POST /api/v1/notifications/batch/send ← batch для action
export class BatchCreateOrderItemDto {
@ApiProperty()
@IsUUID()
productId: string;
@ApiProperty()
@IsInt()
@Min(1)
quantity: number;
}
export class BatchCreateOrdersRequestDto {
@ApiProperty({ type: [BatchCreateOrderItemDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => BatchCreateOrderItemDto)
items: BatchCreateOrderItemDto[];
}
export class BatchItemResultDto {
@ApiProperty()
index: number;
@ApiProperty({ enum: ['SUCCESS', 'ERROR'] })
status: 'SUCCESS' | 'ERROR';
orderId?: string;
error?: { code: string; detail: string };
}
export class BatchSummaryDto {
@ApiProperty() total: number;
@ApiProperty() succeeded: number;
@ApiProperty() failed: number;
}
export class BatchCreateOrdersResponseDto {
@ApiProperty({ type: [BatchItemResultDto] })
results: BatchItemResultDto[];
@ApiProperty()
summary: BatchSummaryDto;
}
Контроллер
@ApiTags('Orders')
@Controller('orders')
export class OrdersController {
@Post('batch')
@HttpCode(200)
@ApiOperation({ operationId: 'batchCreateOrders', summary: 'Создание заказов batch' })
@ApiResponse({ status: 200, type: BatchCreateOrdersResponseDto })
@ApiResponse({ status: 400, description: 'BATCH_SIZE_EXCEEDED или VALIDATION_ERROR' })
async batchCreate(
@Body() dto: BatchCreateOrdersRequestDto,
): Promise<BatchCreateOrdersResponseDto> {
return this.ordersService.batchCreate(dto.items);
}
}
@HttpCode(200) обязателен — без него NestJS отдаёт 201 для @Post.
Request / Response
POST /api/v1/orders/batch
Content-Type: application/json
{
"items": [
{ "productId": "c9f3...", "quantity": 2 },
{ "productId": "d1a8...", "quantity": 0 },
{ "productId": "e7b2...", "quantity": 1 }
]
}
HTTP/1.1 200 OK
{
"results": [
{ "index": 0, "status": "SUCCESS", "orderId": "550e..." },
{ "index": 1, "status": "ERROR", "error": { "code": "INVALID_QUANTITY", "detail": "Количество должно быть больше нуля" } },
{ "index": 2, "status": "SUCCESS", "orderId": "6ba7..." }
],
"summary": {
"total": 3,
"succeeded": 2,
"failed": 1
}
}
200 OK возвращается даже при частичной ошибке — это partial success, не HTTP-failure. index — позиция в исходном массиве (0-based). В error на уровне элемента — упрощённый объект { code, detail }, не полный ProblemDetails (полный ProblemDetails — для HTTP-уровня, не для элемента batch).
Атомарность (all-or-nothing) — не дефолт. Если нужна — явно декларируется в OpenAPI и документации:
@ApiOperation({
summary: 'Атомарный batch резервирования на складе',
description: 'Все позиции обрабатываются в одной транзакции. Ошибка любой позиции — откат всех.',
})
Размер batch и BATCH_SIZE_EXCEEDED
R-BATCH-4..5: максимальный размер фиксируется в документации.
const MAX_BATCH_SIZE = 100;
async batchCreate(items: BatchCreateOrderItemDto[]): Promise<BatchCreateOrdersResponseDto> {
if (items.length > MAX_BATCH_SIZE) {
throw new BadRequestException({
type: 'urn:problem:order-service:batch-size-exceeded',
status: 400,
title: 'Bad Request',
detail: `Размер batch превышает максимум (${MAX_BATCH_SIZE} элементов)`,
code: 'BATCH_SIZE_EXCEEDED',
});
}
// ...
}
Исключение попадёт в ProblemDetailsExceptionFilter — он выставит Content-Type: application/problem+json.
Async-операции
R-ASYNC-1..4: 202 Accepted + polling.
Для операций, которые не завершаются за время HTTP-запроса: генерация выписки Сбер за год, пересчёт остатков по продуктовому каталогу, массовый импорт клиентской базы.
Submit — DTO и контроллер
export class GenerateReportRequestDto {
@ApiProperty({ example: '2026-01-01' })
@IsDateString()
dateFrom: string;
@ApiProperty({ example: '2026-12-31' })
@IsDateString()
dateTo: string;
}
export class AsyncTaskResponseDto {
@ApiProperty() taskId: string;
@ApiProperty({ enum: ['PENDING', 'PROCESSING', 'COMPLETED', 'FAILED'] })
status: string;
@ApiProperty() createdAt: string;
@ApiProperty() statusUrl: string;
}
@Post('generate')
@HttpCode(202)
@ApiOperation({ operationId: 'generateReport', summary: 'Запуск генерации отчёта' })
@ApiResponse({ status: 202, type: AsyncTaskResponseDto, headers: { Location: { description: 'URL задачи' } } })
async generate(
@Body() dto: GenerateReportRequestDto,
@Res({ passthrough: true }) res: Response,
): Promise<AsyncTaskResponseDto> {
const task = await this.reportsService.startGeneration(dto);
const statusUrl = `/api/v1/tasks/${task.taskId}`;
res.location(statusUrl);
return {
taskId: task.taskId,
status: 'PENDING',
createdAt: task.createdAt,
statusUrl,
};
}
@Res({ passthrough: true }) позволяет вернуть значение через return и при этом установить заголовок Location. Без passthrough: true NestJS передаёт управление ответом полностью в Express — return перестаёт работать.
Submit — request / response
POST /api/v1/reports/generate
Content-Type: application/json
{ "dateFrom": "2026-01-01", "dateTo": "2026-12-31" }
HTTP/1.1 202 Accepted
Location: /api/v1/tasks/550e8400-e29b-41d4-a716-446655440000
{
"taskId": "550e8400-e29b-41d4-a716-446655440000",
"status": "PENDING",
"createdAt": "2026-06-19T10:30:00Z",
"statusUrl": "/api/v1/tasks/550e8400-e29b-41d4-a716-446655440000"
}
Polling — GET /tasks/{id}
@ApiTags('Tasks')
@Controller('tasks')
export class TasksController {
@Get(':taskId')
@ApiOperation({ operationId: 'getTask', summary: 'Статус задачи' })
@ApiResponse({ status: 200, type: TaskStatusResponseDto })
getTask(@Param('taskId', ParseUUIDPipe) taskId: string): Promise<TaskStatusResponseDto> {
return this.tasksService.getTask(taskId);
}
}
В процессе:
{
"taskId": "550e8400-...",
"status": "PROCESSING",
"progress": 45,
"createdAt": "2026-06-19T10:30:00Z"
}
Завершено:
{
"taskId": "550e8400-...",
"status": "COMPLETED",
"progress": 100,
"createdAt": "2026-06-19T10:30:00Z",
"completedAt": "2026-06-19T10:35:00Z",
"resultUrl": "/api/v1/reports/550e8400-..."
}
Ошибка:
{
"taskId": "550e8400-...",
"status": "FAILED",
"createdAt": "2026-06-19T10:30:00Z",
"completedAt": "2026-06-19T10:32:00Z",
"error": {
"code": "REPORT_GENERATION_FAILED",
"detail": "Данные за указанный период отсутствуют"
}
}
Статусы задачи
R-ASYNC-3..4:
| Статус | Значение |
|---|---|
PENDING | создана, ожидает обработки |
PROCESSING | выполняется |
COMPLETED | завершена; resultUrl обязателен |
FAILED | завершена с ошибкой; error обязателен |
Период опроса клиент выбирает сам. Сервер может вернуть Retry-After в ответе на polling с рекомендацией.
Локализация
R-LOC-1..3: через Accept-Language, реализация через nestjs-i18n.
Подключение nestjs-i18n
import { I18nModule, AcceptLanguageResolver } from 'nestjs-i18n';
@Module({
imports: [
I18nModule.forRoot({
fallbackLanguage: 'ru',
loaderOptions: { path: join(__dirname, '/i18n/'), watch: true },
resolvers: [AcceptLanguageResolver],
}),
],
})
export class AppModule {}
fallbackLanguage: 'ru' — если Accept-Language отсутствует или неизвестен, используется ru.
Локализация detail в ошибках
@Catch(OrderNotFoundException)
export class OrderNotFoundFilter implements ExceptionFilter {
constructor(private readonly i18n: I18nService) {}
catch(exception: OrderNotFoundException, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const lang = request.headers['accept-language'] ?? 'ru';
const detail = this.i18n.t('errors.ORDER_NOT_FOUND', { lang });
response.status(404).contentType('application/problem+json').json({
type: 'urn:problem:order-service:order-not-found',
status: 404,
title: 'Not Found',
detail,
code: 'ORDER_NOT_FOUND',
});
}
}
Файлы переводов:
// i18n/ru/errors.json
{ "ORDER_NOT_FOUND": "Заказ не найден" }
// i18n/en/errors.json
{ "ORDER_NOT_FOUND": "Order not found" }
Локализация violations при валидации
app.useGlobalPipes(
new ValidationPipe({
transform: true,
exceptionFactory: (errors) => {
const violations = errors.map((e) => ({
field: e.property,
code: Object.keys(e.constraints ?? {})[0]?.toUpperCase() ?? 'INVALID',
message: Object.values(e.constraints ?? {})[0] ?? '',
}));
throw new UnprocessableEntityException({ violations });
},
}),
);
Для локализованных сообщений class-validator поддерживает кастомные message-функции; nestjs-i18n предоставляет декоратор @i18nValidate*. message в violations локализуется, code — нет.
Что не локализуется
R-LOC-X1: машиночитаемые идентификаторы всегда на английском.
// Нарушение R-LOC-X1
{ "code": "ЗАКАЗ_НЕ_НАЙДЕН" }
// Правильно
{ "code": "ORDER_NOT_FOUND", "detail": "Заказ не найден" }
// Нарушение R-LOC-X1
{ "type": "urn:problem:order-service:заказ-не-найден" }
// Правильно
{ "type": "urn:problem:order-service:order-not-found" }
Поля code, title, type используются в клиентском коде (switch (error.code)) — они не зависят от языка пользователя. Локализуется только то, что показывается пользователю: detail и violations[].message.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Batch без @HttpCode(200) на @Post | R-BATCH-3 | явный @HttpCode(200) |
| Batch atomicity по умолчанию без документирования | R-BATCH-3 | partial success + явная декларация атомарности |
4xx на partial failure batch | R-BATCH-3 | 200 + per-item status: ERROR |
Batch без summary | R-BATCH-3 | { total, succeeded, failed } |
| Batch без ограничения максимального размера | R-BATCH-4 | BATCH_SIZE_EXCEEDED при превышении |
202 без заголовка Location | R-ASYNC-1 | res.location(statusUrl) обязателен |
COMPLETED без resultUrl | R-ASYNC-4 | обязательное поле при финальном статусе |
FAILED без error | R-ASYNC-4 | обязательное поле при финальном статусе |
Accept-Language игнорируется | R-LOC-1 | nestjs-i18n с AcceptLanguageResolver |
fallbackLanguage не задан | R-LOC-2 | fallbackLanguage: 'ru' в I18nModule |
code: "ЗАКАЗ_НЕ_НАЙДЕН" | R-LOC-X1 | только UPPER_SNAKE_CASE на английском |
Локализованный title в ProblemDetails | R-LOC-X1 | стандартное название HTTP-статуса на английском |
| Локализованные имена JSON-полей | R-LOC-X1 | camelCase на английском |
Куда дальше
- node/errors.md — ProblemDetails через Exception Filters,
exceptionFactoryдля ValidationPipe. - node/headers.md —
Idempotency-Keyдля batch,Locationиtraceparent. - node/json-and-responses.md —
content+ метаданные пагинации,undefinedвместоnull. - node/rate-limiting-files-deprecation.md —
@nestjs/throttler,StreamableFile,Sunset. - node/url-and-resources.md — kebab-case,
setGlobalPrefix, URI-версионирование. - node/alias-and-actions.md — action-эндпоинты
@Post('orders/:id/confirm'). - Resilience → async polling — task-queue реализация на Node.