Перейти к содержимому

REST-дизайн в деталях: ресурсы, пагинация, идемпотентность, OpenAPI

Код на NestJS ты уже умеешь писать: модули, guards, pipes. Теперь поднимаемся на уровень выше — к дизайну самого контракта. API — это не «набор эндпоинтов, которые работают», а обещание, данное клиентам: вебу, мобильным приложениям, интеграторам и другим твоим же сервисам. Нарушенное обещание стоит дорого: мобильное приложение, которое не обновил пользователь, будет ходить в старый контракт месяцами.

Эта глава — практический справочник по проектированию REST API, который живёт годами: как моделировать ресурсы, где граница вложенности, чем offset-пагинация отличается от cursor на глубине миллиона записей, как сделать повторный платёж безопасным и как превратить код в живую документацию через OpenAPI.

REST строится вокруг ресурсов — существительных, а не глаголов. URL называет сущность, HTTP-метод — операцию:

Метод /users (коллекция) /users/42 (экземпляр)
GET список пользователей один пользователь
POST создать пользователя — (антипаттерн)
PATCH — (редко) частично обновить
PUT заменить коллекцию (почти никогда) полная замена
DELETE — (опасно) удалить

Плохие URL — это глаголы в путях: POST /users/createUser, GET /getUserOrders. Это RPC, замаскированный под REST: метод уже говорит об операции, дублировать её в URL нет смысла. Правильный тест: если у тебя в пути глагол — почти наверняка ресурс спроектирован неверно, и createUser — это POST /users, а getUserOrdersGET /users/42/orders.

Вложенность выражает принадлежность: заказы пользователя — /users/42/orders, позиции заказа — /orders/7/items. Правила:

  • 1–2 уровня вложенности — норма. /users/42/orders/7/items — ещё читается.
  • 3+ уровня — топор. /users/42/orders/7/items/3/reviews — клиент мучается, сервер дублирует валидацию цепочки. Вынеси «хвост» в корень: /items/3/reviews.
  • Дублируй шорткаты. Полный путь /users/42/orders/7 и корневой /orders/7 могут существовать одновременно: второй удобнее, когда user уже не нужен (например, вебхук платёжки знает только id заказа).

Глубокая вложенность — признак, что ты спроектировал иерархию вместо графа. Реальные данные связаны сетью, а не деревом; URI должен отражать типичный путь доступа, а не всю модель.

Идемпотентность — свойство «повторить операцию N раз = сделать один раз». Это не абстракция, а то, как клиенты реально ведут себя при таймаутах: ретрай неизбежен, и вопрос только — убьёт ли он данные.

Метод Идемпотентен? Что происходит при ретрае
GET / HEAD / OPTIONS Да (safe) Ничего, можно дёргать сколько угодно
PUT Да Ресурс приводится к одному и тому же состоянию
DELETE Да Первый вызов удаляет, остальные — 404
PATCH Зависит от семантики {"name": "x"} идемпотентен, {"op": "increment"} — нет
POST Нет Каждый ретрай создаёт новую сущность

Отсюда главное правило: POST-подобные операции (платежи, заказы, регистрации) обязаны защищаться идемпотентными ключами. Об этом ниже.

Статус-код — часть контракта, а не украшение. Клиент ветвит логику именно по нему (справочник — MDN: HTTP Status).

Код Когда Пример
200 OK Успех с телом GET, PATCH
201 Created Ресурс создан POST — и в Location header отдавай URL нового ресурса
204 No Content Успех без тела DELETE, PUT
400 Bad Request Ошибка формы входа Ошибки class-validator, невалидный JSON
401 Unauthorized Нет/просрочена аутентификация Нет токена, JWT истёк
403 Forbidden Аутентифицирован, но нельзя Роль не та, доступ к чужому ресурсу
404 Not Found Ресурса нет Неверный id, эндпоинт не существует
409 Conflict Конфликт с текущим состоянием Email занят, версия записи устарела (optimistic locking)
422 Unprocessable Entity Форма ок, смысл нет Валидный формат, но дата в прошлом
429 Too Many Requests Rate limit Retry-After обязателен
500 Internal Server Error Баг сервера Непойманное исключение

Разница 401 vs 403 — вечный вопрос собеседований: 401 — «кто ты?» (нет/плохие креды), 403 — «знаю кто ты, но нельзя». А 404 vs 403: если клиент не должен знать о существовании чужого ресурса — отвечай 404, а не 403 (иначе раскрываешь факт существования id).

Breaking change — удаление поля, смена типа, новый обязательный параметр, другой статус-код. Как только клиентов больше одного (а мобильное приложение ты не контролируешь), breaking-изменения выпускаются новой версией.

Варианты:

  • Префикс в URI/api/v1/users, /api/v2/users. Самый практичный: видно в логах, просто в nginx, кэшируется нормально. Минус — в URL «грязь», но это честная цена.
  • Header Accept-Version: 2 — чище URL, но логи молчат, curl отладка бесит, кэши сложнее.
  • Header/content negotiation (Accept: application/vnd.app.v2+json) — путь вендорских API, для внутреннего сервиса overkill.

В NestJS версионирование встроено:

main.ts
app.enableVersioning({
type: VersioningType.URI, // /v1/... /v2/...
prefix: 'v',
defaultVersion: '1',
});
@Controller({ path: 'users', version: '2' })
export class UsersV2Controller { /* новый контракт */ }

Правила жизни версий: старую версию не чинят (только security), новую поддерживают параллельно ограниченное время (обычно 3–6 месяцев), в ответе старой версии — Sunset header с датой выключения. Лучшая версия — та, которую не пришлось выпускать: добавляй поля, не удаляй; расширяй enum’ы; используй опциональность.

Коллекции без пагинации — это уязвимость: GET /users с миллионом записей положит и память, и БД.

GET /api/v1/users?offset=0&limit=20
GET /api/v1/users?offset=100000&limit=20

БД пропускает 100 000 строк, чтобы отдать 20. На глубине это деградирует до полного скана, плюс записи «плывут», пока листаешь (между страницами вставили строку — получил дубликат/пропуск). Offset-пагинация нормальна для админок и таблиц «на глазок».

Cursor: быстро на любой глубине, стандарт для лент

Заголовок раздела «Cursor: быстро на любой глубине, стандарт для лент»

Идея: вместо «пропусти N» — «дай записи после этой». Курсор — непрозрачная строка (обычно base64 от последнего id + значения сортировки):

GET /api/v1/feed?limit=20
# ответ: { "data": [...], "meta": { "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI0LTA1LTAxIiwiaWQiOjQyN30", "hasMore": true } }
GET /api/v1/feed?limit=20&cursor=eyJjcmVhdGVkQXQiOiIyMDI0LTA1LTAxIiwiaWQiOjQyN30

Запрос в БД — WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 20 — стабильное время на любой глубине, запись не «плывёт». Платишь невозможностью перепрыгнуть на «страницу 47» — но ленты и API так не работают.

// posts.service.ts — cursor-пагинация по (createdAt, id)
async getFeed(cursor: string | undefined, limit: number) {
const where = cursor
? lt(Posts.createdAt, decodeCursor(cursor)) // decodeCursor -> { createdAt, id }
: undefined;
const rows = await this.db.query.posts.findMany({
where,
orderBy: [desc(Posts.createdAt), desc(Posts.id)],
limit: limit + 1, // +1, чтобы узнать hasMore
});
const hasMore = rows.length > limit;
const data = hasMore ? rows.slice(0, limit) : rows;
const last = data[data.length - 1];
return {
data,
meta: {
nextCursor: hasMore ? encodeCursor({ createdAt: last.createdAt, id: last.id }) : null,
hasMore,
},
};
}

Всегда оборачивай ответ в конверт { data, meta } — у коллекции есть метаданные, и клиенту нужно место для nextCursor.

Соглашения, которые не надо объяснять:

GET /api/v1/orders?status=paid&status=shipped # повторение = OR
GET /api/v1/orders?minTotal=1000&maxTotal=50000 # диапазоны через min/max или total[gte]=1000
GET /api/v1/orders?sort=-created_at,total # "-" = DESC, через запятую

Ограничивай жёстко: whitelist полей для фильтрации и сортировки (иначе sort=password_hash или фильтр по неиндексированному полю = DoS на БД), максимальный limit (например, 100), белый список операторов. Всё, что пришло от клиента, — данные, а не инструкции.

POST не идемпотентен, а ретраи случаются: таймаут, двойной клик, повтор вебхука. Решение (эталон — Stripe Idempotent Requests) — клиент генерирует ключ и шлёт его заголовком; сервер запоминает и при повторе отдаёт тот же результат, а не создаёт дубликат:

POST /api/v1/payments
Idempotency-Key: 8f7d2c1a-9b3e-4f5a-a1b2-3c4d5e6f7a8b
Content-Type: application/json
{ "orderId": 7, "amount": 1500 }
@Post('payments')
async createPayment(
@Body() dto: CreatePaymentDto,
@Headers('idempotency-key') idemKey: string | undefined,
) {
if (idemKey) {
const existing = await this.db.paymentByIdempotencyKey(idemKey);
if (existing) return existing; // повтор → тот же ответ, 200/201 по вкусу
}
// транзакция: создать платёж + записать ключ; UNIQUE constraint на ключ
return this.payments.create(dto, idemKey);
}

Ключ хранится с TTL (24–72 часа) и UNIQUE-ограничением в БД — два одновременных ретрая в разные инстансы сервиса не создадут дубль. Этот паттерн — фундамент надёжности платёжных API и вебхуков: вебхук придёт дважды, ты ответишь 200 дважды, а заплатит клиент один раз.

Идеальный REST: ответ содержит ссылки на доступные действия, клиент вообще не хардкодит URL:

{
"id": 7,
"status": "paid",
"_links": {
"self": "/api/v1/orders/7",
"cancel": "/api/v1/orders/7/cancellation",
"invoice": "/api/v1/invoices/12"
}
}

Красиво, но в проде почти никто не использует полноценно: клиенты (особенно мобильные, обновляемые годами) всё равно жёстко знают URL, а гипермедиа добавляет головняка с версионированием ссылок. Забирай идею частично: отдавай Location при 201, давай ссылки на смежные ресурсы в ответах коллекций — но не строй архитектуру вокруг гипермедиа.

OpenAPI (спецификация v3.1, бывший Swagger) — YAML/JSON-описание всех эндпоинтов, схем, параметров и ответов. Из него генерируется интерактивная документация, клиентские SDK и моки. Писать его руками — мазохизм; в NestJS схема собирается из декораторов через @nestjs/swagger:

Окно терминала
npm i @nestjs/swagger
main.ts
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
const config = new DocumentBuilder()
.setTitle('Pet API')
.setDescription('API pet-проекта: задачник')
.setVersion('1.0')
.addBearerAuth() // JWT в документации
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document); // UI на /docs
// dto автоматически попадает в схему из class-validator
export class CreateTaskDto {
@ApiProperty({ example: 'Выучить cursor-пагинацию', description: 'Заголовок задачи' })
@IsString()
@Length(3, 120)
title!: string;
@ApiPropertyOptional({ enum: TaskStatus, default: TaskStatus.Open })
@IsOptional()
@IsEnum(TaskStatus)
status?: TaskStatus;
}
@ApiTags('tasks')
@Controller({ path: 'tasks', version: '1' })
export class TasksController {
@Get()
@ApiOperation({ summary: 'Список задач (cursor-пагинация)' })
@ApiResponse({ status: 200, type: TaskPageDto })
list(@Query() query: ListTasksDto) { /* ... */ }
@Post()
@ApiBearerAuth()
@ApiResponse({ status: 201, type: TaskDto })
@ApiResponse({ status: 400, description: 'Ошибки валидации' })
create(@Body() dto: CreateTaskDto) { /* ... */ }
}

Ключевое: DTO — единый источник правды. ValidationPipe валидирует по нему, OpenAPI документирует по нему, клиентский SDK генерируется по нему. Дрифт «код vs документация» исчезает, потому что документация — это код.

Scalar: документация, которую приятно открывать

Заголовок раздела «Scalar: документация, которую приятно открывать»

Swagger UI — стандарт де-факто, но выглядит и работает на 2015 год. Scalar — современный рендерер того же OpenAPI-документа: тёмная тема, примеры на curl/Node.js/Python на каждый эндпоинт, нормальный поиск:

// main.ts — Scalar поверх того же document
import { apiReference } from '@scalar/nestjs-api-reference';
app.use(
'/reference',
apiReference({ spec: { content: () => SwaggerModule.createDocument(app, config) } }),
);

Итог: /docs — Swagger UI для привычных, /reference — Scalar для красоты. Оба бесплатны, оба из одного document.

Мы договорились о едином формате ошибок в exception filter. Стандартизированная версия этого формата — RFC 7807 (application/problem+json): клиенты и библиотеки умеют его парсить без твоей документации:

{
"type": "https://api.petproject.dev/problems/insufficient-funds",
"title": "Недостаточно средств",
"status": 409,
"detail": "На балансе 300, требуется 1500",
"instance": "/api/v1/payments",
"balance": 300
}

type — машиночитаемый URI проблемы (расширения клади туда же, как balance), title — человекочитаемо, status — дублирует HTTP-код, instance — путь запроса. Не обязателен, но хороший ориентир: если проектируешь публичный API, возьми RFC 7807 за основу вместо самодельного конверта.

Пагинация снимает нагрузку с БД, но повторные запросы одних и тех же ресурсов всё равно долетают до сервиса. HTTP-кэширование (MDN: HTTP Caching) решает это на уровне протокола:

GET /api/v1/users/42
# ответ:
HTTP/1.1 200 OK
ETag: "user-42-v7"
Cache-Control: private, max-age=60
# повторный запрос от клиента:
GET /api/v1/users/42
If-None-Match: "user-42-v7"
# если версия та же — сервер отвечает без тела:
HTTP/1.1 304 Not Modified

ETag — «версия» ресурса; клиент присылает её обратно через If-None-Match, сервер отвечает 304 Not Modified — тела нет, но клиент понимает: кэш валиден. Cache-Control: max-age говорит, что повторный запрос вообще не нужен в течение N секунд. Для публичных GET-ресурсов (/products, /posts) добавь Cache-Control: public + CDN — и популярные страницы вообще не доходят до твоего сервиса. Это бесплатное масштабирование, которое часто забывают в погоне за cursor-пагинацией.

  • Глаголы в путях. POST /users/createUser дублирует семантику метода. Путь — существительное, операцию несёт HTTP-метод.
  • Deep nesting. Три уровня вложенности — сигнал вынести хвост в корень: /users/1/orders/2/items/3/reviews/items/3/reviews.
  • Offset-пагинация для лент. На 50 000-й странице БД пропускает полмиллиона строк; пользователь листает — получает дубликаты. Ленты и потоки — только cursor.
  • Ретрай POST без идемпотентного ключа. Двойной клик «Оплатить» → два списания. Ключ + UNIQUE в БД стоит час, разбор двойных платежей — неделя.
  • Whitelisting фильтров забыт. ?sort=password_hash или фильтр по неиндексированному полю на большой таблице = тормоза и утечка. Только разрешённые поля, только индексированные.
  • Документация руками в Confluence. Дрифт за неделю. Только генерация из кода; ручные правки OpenAPI — красный флаг при review.
  • 200 вместо 201/204. Клиенты (и кэши) различают «создано» и «прочитано». Location на 201 — обязателен для REST-пуриста и полезен всем.
  1. Чем отличаются offset и cursor пагинация? Offset пропускает N строк — деградирует на глубине и даёт дрейф записей. Cursor — «после ключа» со стабильным временем на любой глубине, но без произвольного перехода на страницу.
  2. Какие HTTP-методы идемпотентны? GET, HEAD, OPTIONS, PUT, DELETE — идемпотентны; PATCH — по семантике; POST — нет. Отсюда идемпотентные ключи для POST.
  3. Разница 401 и 403? 401 — не аутентифицирован (нет/плохой токен), 403 — аутентифицирован, но доступ запрещён (роль/владение). Для чужих ресурсов часто 404 вместо 403.
  4. Как безопасно выпустить breaking change? Новая версия (/v2/), старая в параллель с датой заката (Sunset header). Внутри версии — только расширения: новые опциональные поля, новые значения enum’ов.
  5. Зачем идемпотентный ключ, если есть транзакции? Транзакция делает атомарным один вызов. Ключ делает безопасным повторный вызов: ретрай вернёт первый результат, а не создаст дубликат. Нужен и тот, и другой.
  6. Что даёт OpenAPI, кроме «красивой странички»? Контракт для генерации клиентских SDK, моков, контрактных тестов; single source of truth из кода. Дрифт документации исключён.
  7. HATEOAS — использовать? Идеологически чистый REST, практически почти никогда полностью. Частично: Location при 201, ссылки на смежные ресурсы. Клиенты всё равно хардкодят URL.
  1. Спроектируй REST API pet-проекта: 4–5 ресурсов, вложенность не глубже двух уровней, таблица методов/статус-кодов. Критерий: ни одного глагола в путях, 201 + Location на создании.
  2. Реализуй cursor-пагинацию на списке задач/заказов: сортировка по двум полям, nextCursor в base64, hasMore, проверка стабильности (вставь запись между запросами — дубликатов быть не должно).
  3. Добавь идемпотентный ключ на создание заказа: Idempotency-Key header, UNIQUE-ограничение, повторный запрос с тем же ключом → тот же ответ. Проверь двумя параллельными curl.
  4. Подключи @nestjs/swagger и Scalar; добейся, что все DTO и статус-коды видны, авторизация через Bearer работает из UI.
  5. Напиши nginx-правило для версий: /v1/* → старый инстанс, /v2/* → новый, чтобы мигрировать без даунтайма.