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, а getUserOrders — GET /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 версионирование встроено:
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’ы; используй опциональность.
Пагинация: offset vs cursor
Заголовок раздела «Пагинация: offset vs cursor»Коллекции без пагинации — это уязвимость: GET /users с миллионом записей положит и память, и БД.
Offset: просто, но дорого на глубине
Заголовок раздела «Offset: просто, но дорого на глубине»GET /api/v1/users?offset=0&limit=20GET /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 # повторение = ORGET /api/v1/orders?minTotal=1000&maxTotal=50000 # диапазоны через min/max или total[gte]=1000GET /api/v1/orders?sort=-created_at,total # "-" = DESC, через запятуюОграничивай жёстко: whitelist полей для фильтрации и сортировки (иначе sort=password_hash или фильтр по неиндексированному полю = DoS на БД), максимальный limit (например, 100), белый список операторов. Всё, что пришло от клиента, — данные, а не инструкции.
Идемпотентные ключи
Заголовок раздела «Идемпотентные ключи»POST не идемпотентен, а ретраи случаются: таймаут, двойной клик, повтор вебхука. Решение (эталон — Stripe Idempotent Requests) — клиент генерирует ключ и шлёт его заголовком; сервер запоминает и при повторе отдаёт тот же результат, а не создаёт дубликат:
POST /api/v1/paymentsIdempotency-Key: 8f7d2c1a-9b3e-4f5a-a1b2-3c4d5e6f7a8bContent-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 дважды, а заплатит клиент один раз.
HATEOAS кратко
Заголовок раздела «HATEOAS кратко»Идеальный 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: документация из кода
Заголовок раздела «OpenAPI: документация из кода»OpenAPI (спецификация v3.1, бывший Swagger) — YAML/JSON-описание всех эндпоинтов, схем, параметров и ответов. Из него генерируется интерактивная документация, клиентские SDK и моки. Писать его руками — мазохизм; в NestJS схема собирается из декораторов через @nestjs/swagger:
npm i @nestjs/swaggerimport { 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-validatorexport 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 поверх того же documentimport { apiReference } from '@scalar/nestjs-api-reference';
app.use( '/reference', apiReference({ spec: { content: () => SwaggerModule.createDocument(app, config) } }),);Итог: /docs — Swagger UI для привычных, /reference — Scalar для красоты. Оба бесплатны, оба из одного document.
Формат ошибок: RFC 7807 Problem Details
Заголовок раздела «Формат ошибок: RFC 7807 Problem Details»Мы договорились о едином формате ошибок в 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-кэширование: ETag и Cache-Control
Заголовок раздела «HTTP-кэширование: ETag и Cache-Control»Пагинация снимает нагрузку с БД, но повторные запросы одних и тех же ресурсов всё равно долетают до сервиса. HTTP-кэширование (MDN: HTTP Caching) решает это на уровне протокола:
GET /api/v1/users/42# ответ:HTTP/1.1 200 OKETag: "user-42-v7"Cache-Control: private, max-age=60
# повторный запрос от клиента:GET /api/v1/users/42If-None-Match: "user-42-v7"# если версия та же — сервер отвечает без тела:HTTP/1.1 304 Not ModifiedETag — «версия» ресурса; клиент присылает её обратно через 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-пуриста и полезен всем.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Чем отличаются offset и cursor пагинация? Offset пропускает N строк — деградирует на глубине и даёт дрейф записей. Cursor — «после ключа» со стабильным временем на любой глубине, но без произвольного перехода на страницу.
- Какие HTTP-методы идемпотентны? GET, HEAD, OPTIONS, PUT, DELETE — идемпотентны; PATCH — по семантике; POST — нет. Отсюда идемпотентные ключи для POST.
- Разница 401 и 403? 401 — не аутентифицирован (нет/плохой токен), 403 — аутентифицирован, но доступ запрещён (роль/владение). Для чужих ресурсов часто 404 вместо 403.
- Как безопасно выпустить breaking change? Новая версия (
/v2/), старая в параллель с датой заката (Sunsetheader). Внутри версии — только расширения: новые опциональные поля, новые значения enum’ов. - Зачем идемпотентный ключ, если есть транзакции? Транзакция делает атомарным один вызов. Ключ делает безопасным повторный вызов: ретрай вернёт первый результат, а не создаст дубликат. Нужен и тот, и другой.
- Что даёт OpenAPI, кроме «красивой странички»? Контракт для генерации клиентских SDK, моков, контрактных тестов; single source of truth из кода. Дрифт документации исключён.
- HATEOAS — использовать? Идеологически чистый REST, практически почти никогда полностью. Частично:
Locationпри 201, ссылки на смежные ресурсы. Клиенты всё равно хардкодят URL.
Практика
Заголовок раздела «Практика»- Спроектируй REST API pet-проекта: 4–5 ресурсов, вложенность не глубже двух уровней, таблица методов/статус-кодов. Критерий: ни одного глагола в путях, 201 +
Locationна создании. - Реализуй cursor-пагинацию на списке задач/заказов: сортировка по двум полям,
nextCursorв base64,hasMore, проверка стабильности (вставь запись между запросами — дубликатов быть не должно). - Добавь идемпотентный ключ на создание заказа:
Idempotency-Keyheader, UNIQUE-ограничение, повторный запрос с тем же ключом → тот же ответ. Проверь двумя параллельными curl. - Подключи
@nestjs/swaggerи Scalar; добейся, что все DTO и статус-коды видны, авторизация через Bearer работает из UI. - Напиши nginx-правило для версий:
/v1/*→ старый инстанс,/v2/*→ новый, чтобы мигрировать без даунтайма.
Что почитать
Заголовок раздела «Что почитать»- OpenAPI Specification v3.1 — первоисточник формата.
- NestJS: OpenAPI — декораторы и генерация документации.
- Stripe: Idempotent requests — эталонная реализация идемпотентных ключей.
- RFC 9110 (HTTP Semantics) — статус-коды и идемпотентность по спецификации.
- APIs You Won’t Hate — практичный блог о дизайне API.
- Scalar — современный рендерер OpenAPI.