Как делать11 октября 2026 г.15 мин чтения
Spring BootШаг 5 из 7

Проектирование API: ресурсы, версии, ошибки, пагинация

Коротко

Удобный API строится вокруг ресурсов-существительных, использует методы HTTP в том смысле, который задаёт RFC 9110, отвечает точными кодами и ошибками в формате Problem Details (RFC 9457), листает большие списки курсором и развивается, не ломая клиентов. Эти соглашения фиксируют в спецификации OpenAPI 3.1 до написания кода, проверяют линтером, а реализацию — тестами на соответствие контракту.

РуководствоУровень: СреднийПроектирование APIПрактика: ~1–2 ч

После материала вы сможете

  • спроектировать URL ресурсов и выбрать метод и код ответа для каждой операции по RFC 9110
  • описать единый формат ошибок application/problem+json (RFC 9457) с полем-расширением errors
  • реализовать курсорную пагинацию, защиту от двойного POST заголовком Idempotency-Key и от потерянного обновления через ETag и If-Match
  • зафиксировать контракт в OpenAPI 3.1, проверить его redocly lint и контрактными тестами заглушки

Проверено на

OpenAPI 3.1.2Node.js 24 LTS (24.18)Express 5.3.0Ajv 8.20 (JSON Schema 2020-12)@redocly/cli 2.62.1supertest 7.3.1node:test

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Ресурсы и URL: существительные, коллекции, вложенность
  2. 02Методы, идемпотентность и коды ответов
  3. 03Формат ошибок: Problem Details (RFC 9457)
  4. 04Пагинация, фильтры и сортировка
  5. 05Повтор POST без дублей: заголовок Idempotency-Key
  6. 06Версионирование и обратная совместимость
  7. 07Контракт в OpenAPI 3.1 и проверка линтером
  8. 08Заглушка по контракту, тесты и чек-лист ревью

Ресурсы и URL: существительные, коллекции, вложенность

Сквозной пример — API записи клиентов в салон: записи (bookings) к мастерам. Мы спроектируем его по шагам, в конце зафиксируем контракт в OpenAPI, проверим линтером и напишем заглушку с тестами. Материал для тех, кто уже собирал REST API (например, по уроку про Express) и теперь хочет, чтобы API было удобно использовать и развивать без поломок.

Ресурс — сущность предметной области со своим адресом: запись, мастер, услуга. REST API (стиль, в котором данные представлены ресурсами с адресами, а действия — стандартными методами HTTP) строится вокруг ресурсов, а не действий. Руководство Microsoft по проектированию веб-API формулирует правила так: адреса основывают на существительных, коллекции называют во множественном числе, а глагол уже содержится в методе HTTP.

POST /createBooking

Лучше
POST /bookings
Почему
Действие выражает метод, адрес обозначает только ресурс

GET /getBooking?id=42

Лучше
GET /bookings/{bookingId}
Почему
У каждой записи свой постоянный адрес

POST /bookings/42/cancel

Лучше
PATCH /bookings/42 с телом {"status":"cancelled"}
Почему
Отмена — изменение состояния ресурса. Путь-действие оправдан, только если операция не сводится к изменению полей

GET /salons/1/masters/7/bookings/42

Лучше
GET /bookings/42, GET /bookings?master_id=7
Почему
Microsoft советует не делать адреса сложнее «коллекция/элемент/коллекция»

/Bookings, /booking_list

Лучше
/bookings
Почему
Один стиль: нижний регистр, множественное число
  • Идентификатор в адресе — непрозрачный: UUID не раскрывает количество записей и не позволяет перебирать соседние, в отличие от 1, 2, 3
  • Один стиль имён полей на весь API: у нас snake_case (master_id, starts_at), смешивать его с camelCase нельзя
  • Даты — строки RFC 3339 с часовым поясом (2026-11-02T10:00:00+05:00); сервер хранит и отдаёт UTC
  • Мажорная версия — в базовом адресе (/v1 в servers), а не в отдельных ресурсах

Методы, идемпотентность и коды ответов

RFC 9110 описывает у методов два свойства. Безопасный (safe) метод по смыслу только читает: клиент не просит менять состояние сервера. Идемпотентный — такой, при котором несколько одинаковых запросов дают тот же задуманный эффект, что и один. Из методов RFC 9110 безопасны GET, HEAD, OPTIONS и TRACE, идемпотентны они же плюс PUT и DELETE. POST не обладает ни одним из свойств, PATCH по RFC 5789 тоже не безопасен и не идемпотентен.

GET

Безопасный
да
Идемпотентный
да
В нашем API
Список записей и одна запись

POST

Безопасный
нет
Идемпотентный
нет
В нашем API
Создать запись

PUT

Безопасный
нет
Идемпотентный
да
В нашем API
Заменить ресурс целиком — у нас не используется

PATCH

Безопасный
нет
Идемпотентный
нет
В нашем API
Частичное изменение: перенос, отмена, заметка

DELETE

Безопасный
нет
Идемпотентный
да
В нашем API
Удаление — у нас отмена идёт через PATCH, чтобы сохранить историю

Свойства важны для повторов. RFC 9110 разрешает автоматически повторить идемпотентный запрос, если соединение оборвалось до ответа, и говорит, что клиенту не следует (SHOULD NOT) автоматически повторять неидемпотентный, если у него нет способа узнать, что повтор безопасен. Для POST такой способ — ключ идемпотентности, о нём ниже.

Коды ответа выбирают по смыслу из RFC 9110, а не по принципу «всё 200, ошибка в теле». Таблица — договорённость нашего API; ту же договорённость мы запишем в OpenAPI.

200 OK

Когда
Чтение и успешный PATCH
Что в ответе
Ресурс и ETag

201 Created

Когда
Запись создана
Что в ответе
Ресурс, Location с адресом новой записи, ETag

204 No Content

Когда
Успех без тела, например DELETE
Что в ответе
Пусто

400 Bad Request

Когда
Битый JSON, неверные поля или параметры, нет Idempotency-Key
Что в ответе
Problem Details и errors

401 Unauthorized

Когда
Нет токена или он неверный
Что в ответе
Problem Details и WWW-Authenticate

403 Forbidden

Когда
Пользователь известен, но прав нет
Что в ответе
Problem Details

404 Not Found

Когда
Нет такой записи или маршрута
Что в ответе
Problem Details

409 Conflict

Когда
Конфликт с текущим состоянием: слот уже занят
Что в ответе
Problem Details со своим type

412 Precondition Failed

Когда
If-Match не совпал: запись изменили после чтения
Что в ответе
Problem Details

415 Unsupported Media Type

Когда
PATCH пришёл не как application/merge-patch+json
Что в ответе
Problem Details

422 Unprocessable Content

Когда
Ключ идемпотентности уже использован с другим телом
Что в ответе
Problem Details

428 Precondition Required

Когда
PATCH без If-Match (RFC 6585)
Что в ответе
Problem Details

429 Too Many Requests

Когда
Превышен лимит запросов (RFC 6585)
Что в ответе
Problem Details и Retry-After

Коды 403 и 429 в заглушке не реализованы: права доступа и лимиты разбираются в отдельных уроках. Ошибки валидации одни API отдают кодом 400, другие — 422: RFC 9110 описывает 422 как «синтаксис верен, но выполнить инструкции нельзя», и пример в RFC 9457 использует именно его. Мы отдаём 400, как в уроке про Express, а 422 оставляем для повторно использованного ключа идемпотентности, как предлагает черновик IETF. Оба варианта встречаются; важно выбрать один и записать его в контракт.

Формат ошибок: Problem Details (RFC 9457)

Клиенту нужен один предсказуемый формат ошибок: тогда фронтенд и интеграции разбирают их одной функцией. Стандарт — Problem Details for HTTP APIs, RFC 9457, заменивший RFC 7807. Ответ имеет тип application/problem+json и такие поля:

type

Смысл
URI типа проблемы — главный идентификатор для программ
Правило RFC 9457
Если поля нет, считается about:blank; рекомендуются абсолютные URI

title

Смысл
Короткое описание типа
Правило RFC 9457
Не меняется от случая к случаю; для about:blank совпадает с фразой кода, например «Not Found»

status

Смысл
Код ответа
Правило RFC 9457
Только справочно: сервер обязан (MUST) отдать тот же код в самом HTTP-ответе

detail

Смысл
Пояснение к конкретному случаю
Правило RFC 9457
Помогает исправить запрос; клиентам не следует разбирать его программно

instance

Смысл
URI конкретного случая
Правило RFC 9457
Например, идентификатор события в журнале

Расширения

Смысл
Собственные поля, например errors
Правило RFC 9457
Клиенты обязаны (MUST) игнорировать незнакомые расширения

Типовые ошибки — нет записи, нет токена — отдаём с type: about:blank: смысл полностью передаёт код. Для ошибок предметной области заводим свой type: …/problems/slot-taken, …/problems/idempotency-key-reused. По такому URI клиент ветвит логику, а разработчик находит описание в документации. Вот фактический ответ заглушки на запрос без поля service и с датой tomorrow (отформатирован для чтения):

JSON
{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "Request validation failed",
  "errors": [
    {
      "pointer": "#/service",
      "detail": "must have required property 'service'"
    },
    {
      "pointer": "#/starts_at",
      "detail": "must match format \"date-time\""
    }
  ]
}
Ответ 400 заглушки: errors — расширение, pointer — путь к полю в формате JSON Pointer, как в примере RFC 9457
  • В detail не попадают стек вызовов, текст SQL и сообщения базы данных: это подсказка атакующему. Подробности пишут в лог, клиенту отдают общую фразу
  • Код в status и в статусной строке всегда один и тот же: прокси, кэши и HTTP-библиотеки смотрят только на статусную строку
  • Текст для пользователя интерфейс берёт из своих переводов по type, а detail показывает разве что в режиме отладки

Пагинация, фильтры и сортировка

Список, который сегодня возвращает 20 записей, через год вернёт 20 тысяч. Пагинацию закладывают в первую версию: добавить её позже — ломающее изменение, ведь клиенты ждут весь список целиком.

Offset: `?limit=20&offset=40`

  • Просто реализовать: LIMIT … OFFSET … в SQL
  • Можно сразу перейти на седьмую страницу
  • Если между запросами запись добавили или удалили, элементы съезжают: появляются дубли и пропуски
  • Большой OFFSET заставляет базу прочитать и отбросить все предыдущие строки

Cursor: `?limit=20&cursor=…`

  • Курсор указывает на последний элемент страницы; следующая страница — всё, что после него
  • Вставки и удаления не дают дублей и пропусков
  • С индексом по ключу сортировки скорость не зависит от глубины
  • Нельзя перейти на произвольную страницу — только вперёд (назад — если отдавать второй курсор)

Microsoft советует задавать разумные значения по умолчанию (в примере limit=25 и offset=0) и ограничивать максимальный limit, чтобы один запрос не выгружал всю базу. У нас limit по умолчанию 20, максимум 100, больше — ответ 400.

  1. Сортировка с уникальным хвостом

    Записи сортируются по starts_at, а при равном времени — по id. Без второго ключа две записи на одно время могут поменяться местами между запросами, и одна из них потеряется.

  2. Курсор непрозрачен

    В курсор кодируются порядок сортировки, starts_at и id последнего элемента в base64url. Клиент не разбирает курсор, а передаёт next_cursor дальше — поэтому формат можно менять без новой версии API.

  3. Курсор привязан к сортировке

    Курсор от sort=-starts_at не принимается вместе с sort=starts_at: заглушка ответит 400 и укажет параметр cursor.

  4. Конец списка явный

    На последней странице next_cursor равен null. Поле обязательно в схеме, поэтому клиенту не нужно гадать, закончился ли список.

Фильтры — обычные параметры строки запроса по полям ресурса: ?status=confirmed&master_id=m-1. Сортировку удобно задавать одним параметром со знаком: sort=starts_at — по возрастанию, sort=-starts_at — по убыванию. Допустимые значения перечисляют в контракте через enum, чтобы клиент не мог сортировать по неиндексированному полю. Частичные ответы (?fields=id,starts_at) Microsoft описывает как способ уменьшить объём передаваемых данных; мы их не добавляли — для небольших ресурсов они редко окупаются.

Ссылку на следующую страницу можно отдавать и заголовком Link с rel="next" по RFC 8288. Поле next_cursor в теле проще читать из JavaScript, заголовок удобнее универсальным клиентам; ничто не мешает отдавать оба.

Повтор POST без дублей: заголовок Idempotency-Key

Клиент отправил POST /bookings, а ответ не пришёл: оборвалась сеть, истёк таймаут мобильного интернета. Запись создана или нет? Повторить опасно — может появиться вторая запись; не повторять тоже плохо — клиент останется без записи. Решение — ключ идемпотентности: клиент генерирует уникальный ключ на одну операцию и отправляет его с каждым повтором, а сервер по ключу узнаёт повтор и возвращает сохранённый результат.

Что предлагает черновик и как это сделано в заглушке:

Формат ключа

Черновик -07
Structured Field — строка в кавычках, рекомендуется UUID
Заглушка
Принимает только "…" из 16–64 латинских букв, цифр и дефисов, иначе 400

Ключа нет, а операция его требует

Черновик -07
400 со ссылкой на документацию
Заглушка
400, type …/idempotency-key-missing

Ключ уже использован с другим телом

Черновик -07
422
Заглушка
422, type …/idempotency-key-reused

Первый запрос с этим ключом ещё выполняется

Черновик -07
409
Заглушка
Не реализовано: обработчик синхронный, гонки нет

Повтор после завершения

Черновик -07
Вернуть прежний результат
Заглушка
Тот же 201, те же Location, ETag и тело

Срок хранения ключей

Черновик -07
Сервис публикует свою политику
Заглушка
Ключи живут в памяти до перезапуска

«То же тело» сервер определяет по отпечатку (fingerprint) — в заглушке это SHA-256 от JSON тела. Ключ хранится вместе с аккаунтом клиента: раздел безопасности черновика предупреждает, что иначе угаданный чужой ключ выдаст чужой результат. Вот как это выглядит снаружи — скрипт с curl против запущенной заглушки:

demo.sh
API=http://localhost:3917/v1
AUTH="Authorization: Bearer demo-token"
JSON="Content-Type: application/json"
KEY='Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"'
BODY='{"master_id":"m-1","service":"haircut","starts_at":"2026-11-02T10:00:00+05:00"}'

# 1. Create, then retry with the same key: the same booking comes back
curl -si -w '\n' -X POST $API/bookings -H "$AUTH" -H "$KEY" -H "$JSON" -d "$BODY"
curl -s -w '\n' -X POST $API/bookings -H "$AUTH" -H "$KEY" -H "$JSON" -d "$BODY"

# 2. Same key, different body: 422
curl -s -w '\n' -X POST $API/bookings -H "$AUTH" -H "$KEY" -H "$JSON" \
  -d '{"master_id":"m-1","service":"coloring","starts_at":"2026-11-02T10:00:00+05:00"}'

# 3. Invalid body: 400 with a pointer per field
curl -s -w '\n' -X POST $API/bookings -H "$AUTH" -H "$JSON" \
  -H 'Idempotency-Key: "0b5f1c2e-7d1a-4c11-9e55-3f0c2a9d7b10"' \
  -d '{"master_id":"m-1","starts_at":"tomorrow"}'
terminal
$ bash demo.sh
HTTP/1.1 201 Created
Location: /v1/bookings/9ff0c277-6654-4fd6-8282-75f1156b6564
ETag: "v1"
Content-Type: application/json; charset=utf-8
Content-Length: 183

{"id":"9ff0c277-6654-4fd6-8282-75f1156b6564","master_id":"m-1","service":"haircut","starts_at":"2026-11-02T05:00:00.000Z","status":"confirmed","created_at":"2026-10-12T02:25:52.357Z"}
{"id":"9ff0c277-6654-4fd6-8282-75f1156b6564","master_id":"m-1","service":"haircut","starts_at":"2026-11-02T05:00:00.000Z","status":"confirmed","created_at":"2026-10-12T02:25:52.357Z"}
{"type":"https://api.salon.test/problems/idempotency-key-reused","title":"Idempotency-Key is already used","status":422,"detail":"This key was already used with a different request body"}
{"type":"about:blank","title":"Bad Request","status":400,"detail":"Request validation failed","errors":[{"pointer":"#/service","detail":"must have required property 'service'"},{"pointer":"#/starts_at","detail":"must match format \"date-time\""}]}
Фактический вывод; служебные заголовки Date, Connection, Keep-Alive убраны. Повтор вернул ту же запись, ключ с другим телом — 422, неверное тело — 400 с указателями
  • В продакшене ключи хранят в базе с уникальным ограничением на пару «аккаунт + ключ» и записывают результат в той же транзакции, что и саму запись — иначе при сбое между ними ключ и данные разойдутся
  • Ключ помечают «в работе» до начала обработки, чтобы параллельный повтор получил 409, а не создал дубль
  • Формат ключа и срок его хранения публикуют в документации API
  • Решают, сохранять ли ответы-ошибки. Заглушка запоминает только успешное создание, поэтому после 409 «слот занят» повтор с тем же ключом выполнится заново

Версионирование и обратная совместимость

Лучшая стратегия версий — выпускать новые как можно реже. Для этого заранее договариваются, какие изменения не ломают клиентов, и пишут клиентов так, чтобы они игнорировали незнакомые поля — так же, как RFC 9457 требует игнорировать незнакомые расширения Problem Details.

Не ломает клиентов

  • Новое необязательное поле в ответе
  • Новый ресурс или новая операция
  • Новый необязательный параметр с прежним поведением по умолчанию
  • Новый тип ошибки с кодом, который клиент уже обрабатывает

Ломает клиентов

  • Удаление или переименование поля, смена его типа или формата
  • Новое обязательное поле в запросе
  • Новое значение enum в ответе, если клиенты разбирают его строгим switch
  • Смена кода ответа, адреса или смысла поля — например, сумма в тенге вместо тиынов

В пути

Пример
/v1/bookings
Плюсы
Видно сразу, просто маршрутизировать, дружит с кэшами
Минусы
Много версий — много маршрутов; адрес ресурса меняется вместе с версией

Параметр запроса

Пример
/bookings?api-version=2
Плюсы
Адрес ресурса прежний, кэшируется
Минусы
Каждый обработчик разбирает параметр

Заголовок

Пример
Api-Version: 2
Плюсы
Чистые адреса
Минусы
Не видно в ссылке; серверу и кэшам нужна дополнительная логика

Тип содержимого

Пример
Accept: application/vnd.salon.v2+json
Плюсы
Версия у представления, а не у адреса
Минусы
Сложнее для клиентов и инструментов

Это сводка компромиссов из руководства Microsoft: версии в пути и в параметре проще всего кэшировать, версии в заголовках требуют от сервера больше логики. По нашему опыту, для API, которым пользуются мобильные приложения и интеграции партнёров, удобнее всего мажорная версия в пути (/v1) и только обратно совместимые изменения внутри неё. Номер ревизии контракта при этом живёт в info.version спецификации, а не в адресе.

Старую версию выключают не внезапно. RFC 9745 (Standards Track, март 2025) ввёл заголовок Deprecation с датой, когда ресурс устарел или устареет, и связь rel="deprecation" со ссылкой на объяснение. Дату выключения сообщает заголовок Sunset из RFC 8594. Заголовки из примеров RFC 9745:

HTTP
Deprecation: @1688169599
Link: <https://developer.example.com/deprecation>;
      rel="deprecation"; type="text/html"
Sunset: Sun, 30 Jun 2024 23:59:59 UTC
Значение Deprecation — дата в формате Structured Fields, секунды Unix-времени: @1688169599 — 30 июня 2023 года, 23:59:59 UTC

Контракт в OpenAPI 3.1 и проверка линтером

OpenAPI — машиночитаемое описание HTTP API в YAML или JSON: ресурсы, параметры, тела запросов, коды ответов, схемы данных и способы аутентификации. Подход «сначала контракт» означает, что спецификацию пишут и ревьюят до кода: фронтенд, мобильная команда и интеграторы видят API заранее, а из файла получают документацию, клиентов и проверки.

Мы используем OpenAPI 3.1.2 — последний патч ветки 3.1. Спецификация прямо говорит, что патч-версии исправляют текст, а не набор возможностей, и инструментам не следует различать 3.1.0 и 3.1.1. Уже вышла ветка 3.2 (на spec.openapis.org последняя — 3.2.1), но перед переходом проверьте, поддерживают ли её ваши генераторы и валидаторы. Главное отличие 3.1 от 3.0: Schema Object — надмножество JSON Schema 2020-12. Поэтому вместо nullable: true пишут type: [string, 'null'], а схемы проверяет обычный валидатор JSON Schema — этим воспользуемся в заглушке.

Спецификация, заглушка и тесты лежат в одной папке. Зависимости:

package.json
{
  "name": "bookings-api",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "test": "node --test",
    "lint:api": "redocly lint openapi.yaml"
  },
  "dependencies": {
    "ajv": "^8.20.0",
    "ajv-formats": "^3.0.1",
    "express": "^5.3.0",
    "yaml": "^2.9.1"
  },
  "devDependencies": {
    "@redocly/cli": "^2.62.1",
    "supertest": "^7.3.1"
  }
}

Сама спецификация. Схемы, ответы-ошибки и заголовок ETag описаны один раз в components и подключаются через $ref. Booking не запрещает лишние поля — так в ответ можно добавлять новые; входящие BookingCreate и BookingPatch, наоборот, закрыты additionalProperties: false, чтобы опечатка в имени поля не прошла молча.

openapi.yaml
openapi: 3.1.2
info:
  title: Bookings API
  version: 1.0.0
  summary: Client bookings for a salon.
  description: |
    Errors use Problem Details (RFC 9457, application/problem+json).
    Clients must ignore unknown response fields: new optional fields
    are added without a new major version.
  license:
    name: MIT
    identifier: MIT
servers:
  - url: https://api.salon.test/v1
security:
  - bearerAuth: []
tags:
  - name: bookings
    description: Client bookings
paths:
  /bookings:
    get:
      tags: [bookings]
      operationId: listBookings
      summary: List bookings (cursor pagination)
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: cursor
          in: query
          description: Opaque value of next_cursor from the previous page.
          schema: { type: string }
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/BookingStatus' }
        - name: master_id
          in: query
          schema: { type: string }
        - name: sort
          in: query
          description: Sort field; a leading minus means descending.
          schema: { type: string, enum: [starts_at, -starts_at], default: starts_at }
      responses:
        '200':
          description: One page of bookings.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BookingPage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [bookings]
      operationId: createBooking
      summary: Create a booking
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: |
            Quoted unique string, e.g. "8e03978e-40d5-43e8-bc93-6894a57f9324".
            A retry with the same key and body returns the stored result.
          schema: { type: string, pattern: '^"[A-Za-z0-9-]{16,64}"$' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BookingCreate' }
      responses:
        '201':
          description: Booking created.
          headers:
            Location:
              description: URL of the new booking.
              schema: { type: string, format: uri-reference }
            ETag: { $ref: '#/components/headers/ETag' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Booking' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UnprocessableContent' }
  /bookings/{bookingId}:
    parameters:
      - name: bookingId
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [bookings]
      operationId: getBooking
      summary: Get a booking
      responses:
        '200':
          description: The booking.
          headers:
            ETag: { $ref: '#/components/headers/ETag' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Booking' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [bookings]
      operationId: updateBooking
      summary: Reschedule, cancel or annotate a booking (JSON Merge Patch)
      parameters:
        - name: If-Match
          in: header
          required: true
          description: ETag from the last GET; protects against lost updates.
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema: { $ref: '#/components/schemas/BookingPatch' }
      responses:
        '200':
          description: Updated booking.
          headers:
            ETag: { $ref: '#/components/headers/ETag' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Booking' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '412': { $ref: '#/components/responses/PreconditionFailed' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '428': { $ref: '#/components/responses/PreconditionRequired' }
components:
  securitySchemes:
    bearerAuth: { type: http, scheme: bearer }
  headers:
    ETag:
      description: Version of the booking; send it back in If-Match.
      schema: { type: string }
  schemas:
    BookingStatus:
      type: string
      enum: [confirmed, cancelled]
    Booking:
      type: object
      required: [id, master_id, service, starts_at, status, created_at]
      properties:
        id: { type: string, format: uuid }
        master_id: { type: string }
        service: { type: string }
        starts_at: { type: string, format: date-time }
        status: { $ref: '#/components/schemas/BookingStatus' }
        note: { type: string, maxLength: 500 }
        created_at: { type: string, format: date-time }
    BookingCreate:
      type: object
      additionalProperties: false
      required: [master_id, service, starts_at]
      properties:
        master_id: { type: string, minLength: 1 }
        service: { type: string, minLength: 1 }
        starts_at: { type: string, format: date-time }
        note: { type: string, maxLength: 500 }
    BookingPatch:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        starts_at: { type: string, format: date-time }
        status: { const: cancelled }
        note:
          type: [string, 'null']
          maxLength: 500
          description: null removes the note (RFC 7396).
    BookingPage:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Booking' }
        next_cursor:
          type: [string, 'null']
          description: Pass as ?cursor= to get the next page; null on the last page.
    Problem:
      type: object
      required: [type, title, status]
      properties:
        type: { type: string, format: uri-reference }
        title: { type: string }
        status: { type: integer, minimum: 400, maximum: 599 }
        detail: { type: string }
        instance: { type: string, format: uri-reference }
        errors:
          type: array
          description: Extension member — one item per invalid field or parameter.
          items:
            type: object
            required: [detail]
            properties:
              detail: { type: string }
              pointer: { type: string, description: JSON Pointer into the request body. }
              parameter: { type: string, description: Name of the invalid query parameter. }
  responses:
    BadRequest:
      description: Malformed request or failed validation.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Unauthorized:
      description: Missing or invalid bearer token.
      headers:
        WWW-Authenticate:
          description: Authentication scheme the client must use.
          schema: { type: string }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    NotFound:
      description: No booking with this id.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Conflict:
      description: The slot is already taken.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    PreconditionFailed:
      description: If-Match does not match the current ETag; re-read the booking.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    PreconditionRequired:
      description: The If-Match header is required.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    UnprocessableContent:
      description: Idempotency-Key was already used with a different body.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    UnsupportedMediaType:
      description: PATCH body must be application/merge-patch+json.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
Контракт API записи: курсорная пагинация, обязательный Idempotency-Key для POST, If-Match для PATCH, все ошибки — application/problem+json

Проверка — Redocly CLI. Без файла конфигурации redocly lint применяет встроенный набор правил recommended: структура по спецификации, неразрешённые $ref, неиспользуемые компоненты, наличие ответов 4xx у операций и другие. Для строгого режима есть набор recommended-strict, собственные правила задают в redocly.yaml.

terminal
$ npm run lint:api
> bookings-api@1.0.0 lint:api
> redocly lint openapi.yaml

No configurations were provided -- using built in recommended configuration by default.

validating openapi.yaml...
openapi.yaml: validated in 23ms

Woohoo! Your API description is valid. 🎉

Чтобы убедиться, что линтер ловит ошибки, мы сделали опечатку в одной ссылке: BookingsPage вместо BookingPage. Линтер нашёл и неразрешённую ссылку, и ставшую ненужной схему, а команда завершилась с ненулевым кодом — значит, её можно поставить в CI и не пропускать сломанный контракт в основную ветку:

terminal
$ npx redocly lint openapi.yaml --format=stylish
No configurations were provided -- using built in recommended configuration by default.

validating openapi.yaml...
openapi.yaml:
  49:23  error    no-unresolved-refs    Can't resolve $ref
  176:5  warning  no-unused-components  Component: "BookingPage" is never used.

openapi.yaml: validated in 24ms

❌ Validation failed with 1 error and 1 warning.
run `redocly lint --generate-ignore-file` to add all problems to the ignore file.
$ echo $?
1

Заглушка по контракту, тесты и чек-лист ревью

Заглушка (stub) — минимальная реализация, которая ведёт себя по контракту, но хранит данные в памяти. Она позволяет фронтенду начать работу до готовности бэкенда и показывает, что контракт вообще реализуем. Главный приём: схемы из openapi.yaml загружаются в валидатор Ajv в режиме JSON Schema 2020-12, и один и тот же контракт проверяет входящие запросы на сервере и ответы в тестах.

src/contract.js
import { readFileSync } from "node:fs";
import { Ajv2020 } from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { parse } from "yaml";

const doc = parse(readFileSync(new URL("../openapi.yaml", import.meta.url), "utf8"));

// OpenAPI 3.1 schemas are JSON Schema 2020-12, so one validator serves
// both the server (requests) and the tests (responses).
const ajv = new Ajv2020({ allErrors: true, strict: false });
addFormats(ajv);
ajv.addSchema({ $id: "openapi", components: doc.components });

export function schemaErrors(name, data) {
  const validate = ajv.getSchema(`openapi#/components/schemas/${name}`);
  if (validate(data)) return [];
  return validate.errors.map((e) => {
    const missing = e.params.missingProperty ? `/${e.params.missingProperty}` : "";
    const extra = e.params.additionalProperty ? `/${e.params.additionalProperty}` : "";
    return { pointer: `#${e.instancePath}${missing}${extra}`, detail: e.message };
  });
}

Сервер на Express 5. Ошибки — класс Problem, который центральный обработчик превращает в application/problem+json. Аутентификация — заглушка: любой bearer-токен считается аккаунтом; настоящую проверку разбирают уроки про аутентификацию и авторизацию.

src/app.js
import express from "express";
import { createHash, randomUUID } from "node:crypto";
import { STATUS_CODES } from "node:http";
import { schemaErrors } from "./contract.js";

const TYPES = "https://api.salon.test/problems/";

class Problem extends Error {
  constructor(status, detail, { type, title, headers, ...extra } = {}) {
    super(detail);
    this.status = status;
    this.headers = headers ?? {};
    this.body = {
      type: type ? TYPES + type : "about:blank",
      title: title ?? STATUS_CODES[status],
      status,
      detail,
      ...extra,
    };
  }
}

const etag = (b) => `"v${b.version}"`;
const view = ({ version, ...booking }) => booking;
const encodeCursor = (b, sort) =>
  Buffer.from(JSON.stringify([sort, b.starts_at, b.id])).toString("base64url");

function decodeCursor(cursor, sort) {
  try {
    const [s, startsAt, id] = JSON.parse(Buffer.from(cursor, "base64url").toString());
    if (s === sort && typeof startsAt === "string" && typeof id === "string") return { startsAt, id };
  } catch {}
  throw new Problem(400, "Cursor is invalid or belongs to another sort order", {
    errors: [{ parameter: "cursor", detail: "use next_cursor from the previous page" }],
  });
}

export function createApp() {
  const app = express();
  app.disable("x-powered-by");
  app.set("etag", false); // ETag is the booking version, set explicitly below
  const bookings = new Map();
  const idempotency = new Map();

  const slotTaken = (masterId, startsAt, exceptId) =>
    [...bookings.values()].some(
      (b) => b.id !== exceptId && b.status === "confirmed" &&
        b.master_id === masterId && b.starts_at === startsAt,
    );

  app.use(express.json({ type: ["application/json", "application/merge-patch+json"] }));

  // Stub authentication: a real API verifies the token (see the auth lessons).
  app.use((req, res, next) => {
    const token = /^Bearer (\S+)$/.exec(req.get("authorization") ?? "")?.[1];
    if (!token) {
      throw new Problem(401, "Send a bearer token", { headers: { "WWW-Authenticate": "Bearer" } });
    }
    req.account = token;
    next();
  });

  const v1 = express.Router();

  v1.get("/bookings", (req, res) => {
    const { limit = "20", cursor, status, master_id, sort = "starts_at" } = req.query;
    const size = Number(limit);
    const errors = [];
    if (!Number.isInteger(size) || size < 1 || size > 100) {
      errors.push({ parameter: "limit", detail: "must be an integer from 1 to 100" });
    }
    if (!["starts_at", "-starts_at"].includes(sort)) {
      errors.push({ parameter: "sort", detail: "must be starts_at or -starts_at" });
    }
    if (status !== undefined && !["confirmed", "cancelled"].includes(status)) {
      errors.push({ parameter: "status", detail: "must be confirmed or cancelled" });
    }
    if (errors.length) throw new Problem(400, "Query validation failed", { errors });

    const dir = sort === "-starts_at" ? -1 : 1;
    const cmp = (a, b) => dir * (a.starts_at.localeCompare(b.starts_at) || a.id.localeCompare(b.id));
    let rows = [...bookings.values()]
      .filter((b) => (!status || b.status === status) && (!master_id || b.master_id === master_id))
      .sort(cmp);
    if (cursor) {
      const after = decodeCursor(cursor, sort);
      rows = rows.filter((b) => cmp(b, { starts_at: after.startsAt, id: after.id }) > 0);
    }
    const page = rows.slice(0, size);
    const hasMore = rows.length > size;
    res.json({
      data: page.map(view),
      next_cursor: hasMore ? encodeCursor(page.at(-1), sort) : null,
    });
  });

  v1.post("/bookings", (req, res) => {
    const key = req.get("idempotency-key");
    if (!key) {
      throw new Problem(400, "This operation requires an Idempotency-Key header", {
        type: "idempotency-key-missing", title: "Idempotency-Key is missing",
      });
    }
    if (!/^"[A-Za-z0-9-]{16,64}"$/.test(key)) {
      throw new Problem(400, "Idempotency-Key must be a quoted string of 16-64 letters, digits or dashes", {
        type: "idempotency-key-invalid", title: "Idempotency-Key is invalid",
      });
    }
    const errors = schemaErrors("BookingCreate", req.body);
    if (errors.length) throw new Problem(400, "Request validation failed", { errors });

    // The key is scoped to the account: one client cannot replay another's result.
    const scope = `${req.account}:${key}`;
    const fingerprint = createHash("sha256").update(JSON.stringify(req.body)).digest("hex");
    const saved = idempotency.get(scope);
    if (saved) {
      if (saved.fingerprint !== fingerprint) {
        throw new Problem(422, "This key was already used with a different request body", {
          type: "idempotency-key-reused", title: "Idempotency-Key is already used",
        });
      }
      return res.status(201).set(saved.headers).json(saved.body);
    }

    const startsAt = new Date(req.body.starts_at).toISOString();
    if (slotTaken(req.body.master_id, startsAt)) {
      throw new Problem(409, "The master already has a booking at this time", {
        type: "slot-taken", title: "Slot is already taken",
      });
    }
    const booking = {
      id: randomUUID(),
      master_id: req.body.master_id,
      service: req.body.service,
      starts_at: startsAt,
      status: "confirmed",
      ...(req.body.note !== undefined && { note: req.body.note }),
      created_at: new Date().toISOString(),
      version: 1,
    };
    bookings.set(booking.id, booking);
    const headers = { Location: `/v1/bookings/${booking.id}`, ETag: etag(booking) };
    idempotency.set(scope, { fingerprint, headers, body: view(booking) });
    res.status(201).set(headers).json(view(booking));
  });

  const find = (id) => {
    const booking = bookings.get(id);
    if (!booking) throw new Problem(404, `Booking ${id} not found`);
    return booking;
  };

  v1.get("/bookings/:id", (req, res) => {
    const booking = find(req.params.id);
    res.set("ETag", etag(booking)).json(view(booking));
  });

  v1.patch("/bookings/:id", (req, res) => {
    if (!req.is("application/merge-patch+json")) {
      throw new Problem(415, "Use Content-Type: application/merge-patch+json");
    }
    const booking = find(req.params.id);
    const ifMatch = req.get("if-match");
    if (!ifMatch) throw new Problem(428, "Send If-Match with the ETag from your last GET");
    if (ifMatch !== etag(booking)) {
      throw new Problem(412, "The booking has changed since you read it; GET it again");
    }
    const errors = schemaErrors("BookingPatch", req.body);
    if (errors.length) throw new Problem(400, "Request validation failed", { errors });

    const next = { ...booking };
    if ("starts_at" in req.body) next.starts_at = new Date(req.body.starts_at).toISOString();
    if ("status" in req.body) next.status = req.body.status;
    if (req.body.note === null) delete next.note; // RFC 7396: null removes the member
    else if ("note" in req.body) next.note = req.body.note;
    if (next.status === "confirmed" && slotTaken(next.master_id, next.starts_at, next.id)) {
      throw new Problem(409, "The master already has a booking at this time", {
        type: "slot-taken", title: "Slot is already taken",
      });
    }
    next.version += 1;
    bookings.set(next.id, next);
    res.set("ETag", etag(next)).json(view(next));
  });

  app.use("/v1", v1);

  app.use((req, res, next) => {
    next(new Problem(404, `Route ${req.method} ${req.path} not found`));
  });

  app.use((err, req, res, next) => {
    if (res.headersSent) return next(err);
    const problem =
      err instanceof Problem ? err
      : err.type === "entity.parse.failed" ? new Problem(400, "Request body is not valid JSON")
      : null;
    if (!problem) {
      console.error(err);
      return res.status(500).type("application/problem+json")
        .json(new Problem(500, "Unexpected server error").body);
    }
    res.status(problem.status).set(problem.headers)
      .type("application/problem+json").json(problem.body);
  });

  return app;
}
Заглушка для разработки и тестов, не production-код: данные и ключи идемпотентности в памяти, токен не проверяется
src/server.js
import { createApp } from "./app.js";

const port = Number(process.env.PORT ?? 3000);
createApp().listen(port, () => console.log(`Bookings API stub on http://localhost:${port}/v1`));

Тесты на node:test и supertest проверяют не только коды, но и соответствие тел схемам Booking, BookingPage и Problem — это контрактные тесты.

test/api.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import request from "supertest";
import { createApp } from "../src/app.js";
import { schemaErrors } from "../src/contract.js";

const AUTH = { Authorization: "Bearer test-account" };
const key = () => `"${randomUUID()}"`;
const slot = (h) => `2026-11-02T${String(h).padStart(2, "0")}:00:00.000Z`;
const draft = (h = 10) => ({ master_id: "m-1", service: "haircut", starts_at: slot(h) });

// Contract check: the body matches the schema from openapi.yaml.
function matches(name, body) {
  assert.deepEqual(schemaErrors(name, body), [], `${name}: ${JSON.stringify(body)}`);
}

function isProblem(res, status) {
  assert.equal(res.status, status);
  assert.match(res.headers["content-type"], /^application\/problem\+json/);
  assert.equal(res.body.status, status);
  matches("Problem", res.body);
}

test("POST creates a booking: 201, Location, ETag, body matches the contract", async () => {
  const res = await request(createApp()).post("/v1/bookings")
    .set(AUTH).set("Idempotency-Key", key()).send(draft());
  assert.equal(res.status, 201);
  assert.equal(res.headers.location, `/v1/bookings/${res.body.id}`);
  assert.equal(res.headers.etag, '"v1"');
  matches("Booking", res.body);
});

test("retry with the same key and body returns the same booking, no duplicate", async () => {
  const app = createApp();
  const k = key();
  const first = await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", k).send(draft());
  const retry = await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", k).send(draft());
  assert.equal(retry.status, 201);
  assert.equal(retry.body.id, first.body.id);
  const list = await request(app).get("/v1/bookings").set(AUTH);
  assert.equal(list.body.data.length, 1);
});

test("same key with another body is 422, missing key is 400", async () => {
  const app = createApp();
  const k = key();
  await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", k).send(draft(10));
  isProblem(await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", k).send(draft(11)), 422);
  isProblem(await request(app).post("/v1/bookings").set(AUTH).send(draft(12)), 400);
});

test("invalid body is 400 with pointers; taken slot is 409", async () => {
  const app = createApp();
  const bad = await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", key())
    .send({ master_id: "m-1", starts_at: "tomorrow", price: 0 });
  isProblem(bad, 400);
  const pointers = bad.body.errors.map((e) => e.pointer).sort();
  assert.deepEqual(pointers, ["#/price", "#/service", "#/starts_at"]);

  await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", key()).send(draft(10));
  isProblem(await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", key()).send(draft(10)), 409);
});

test("cursor pagination walks every booking exactly once", async () => {
  const app = createApp();
  for (const h of [13, 9, 11, 10, 12]) {
    await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", key()).send(draft(h));
  }
  const seen = [];
  let cursor = null;
  do {
    const res = await request(app).get("/v1/bookings")
      .query({ limit: 2, sort: "-starts_at", ...(cursor && { cursor }) }).set(AUTH);
    assert.equal(res.status, 200);
    matches("BookingPage", res.body);
    seen.push(...res.body.data.map((b) => b.starts_at));
    cursor = res.body.next_cursor;
  } while (cursor);
  assert.deepEqual(seen, [13, 12, 11, 10, 9].map(slot));

  isProblem(await request(app).get("/v1/bookings").query({ limit: 500 }).set(AUTH), 400);
  isProblem(await request(app).get("/v1/bookings").query({ cursor: "garbage" }).set(AUTH), 400);
});

test("PATCH needs If-Match: 428 without it, 412 when stale, 200 when current", async () => {
  const app = createApp();
  const created = await request(app).post("/v1/bookings").set(AUTH).set("Idempotency-Key", key()).send(draft());
  const url = created.headers.location;
  const patch = (body, ifMatch) => {
    const req = request(app).patch(url).set(AUTH).set("Content-Type", "application/merge-patch+json");
    return (ifMatch ? req.set("If-Match", ifMatch) : req).send(JSON.stringify(body));
  };

  isProblem(await patch({ note: "window seat" }), 428);
  const ok = await patch({ note: "window seat" }, created.headers.etag);
  assert.equal(ok.status, 200);
  assert.equal(ok.body.note, "window seat");
  assert.equal(ok.headers.etag, '"v2"');
  isProblem(await patch({ status: "cancelled" }, created.headers.etag), 412);

  const cleared = await patch({ note: null }, ok.headers.etag);
  assert.equal("note" in cleared.body, false);
  matches("Booking", cleared.body);
});

test("unknown booking is 404, request without a token is 401", async () => {
  const app = createApp();
  isProblem(await request(app).get(`/v1/bookings/${randomUUID()}`).set(AUTH), 404);
  const anon = await request(app).get("/v1/bookings");
  isProblem(anon, 401);
  assert.equal(anon.headers["www-authenticate"], "Bearer");
});
terminal
$ npm test
> bookings-api@1.0.0 test
> node --test

✔ POST creates a booking: 201, Location, ETag, body matches the contract (31.7978ms)
✔ retry with the same key and body returns the same booking, no duplicate (7.2981ms)
✔ same key with another body is 422, missing key is 400 (9.4061ms)
✔ invalid body is 400 with pointers; taken slot is 409 (5.7477ms)
✔ cursor pagination walks every booking exactly once (21.0143ms)
✔ PATCH needs If-Match: 428 without it, 412 when stale, 200 when current (7.9507ms)
✔ unknown booking is 404, request without a token is 401 (2.9521ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 374.3534

Условные запросы снаружи: без If-Match — 428, с актуальным ETag — 200 и новая версия "v2", повтор со старым ETag — 412. Заметка добавилась в конец объекта — порядок полей JSON клиент учитывать не должен.

demo-patch.sh
API=http://localhost:3917/v1
AUTH="Authorization: Bearer demo-token"
PATCH="Content-Type: application/merge-patch+json"
URL=$API/bookings/$1

# Without If-Match: 428
curl -s -w '\n' -X PATCH $URL -H "$AUTH" -H "$PATCH" -d '{"note":"first visit"}'
# With the current ETag: 200 and a new ETag
curl -si -w '\n' -X PATCH $URL -H "$AUTH" -H "$PATCH" -H 'If-Match: "v1"' -d '{"note":"first visit"}'
# The same stale ETag again: 412
curl -s -w '\n' -X PATCH $URL -H "$AUTH" -H "$PATCH" -H 'If-Match: "v1"' -d '{"status":"cancelled"}'
terminal
$ bash demo-patch.sh 9ff0c277-6654-4fd6-8282-75f1156b6564
{"type":"about:blank","title":"Precondition Required","status":428,"detail":"Send If-Match with the ETag from your last GET"}
HTTP/1.1 200 OK
ETag: "v2"
Content-Type: application/json; charset=utf-8
Content-Length: 204

{"id":"9ff0c277-6654-4fd6-8282-75f1156b6564","master_id":"m-1","service":"haircut","starts_at":"2026-11-02T05:00:00.000Z","status":"confirmed","created_at":"2026-10-12T02:25:52.357Z","note":"first visit"}
{"type":"about:blank","title":"Precondition Failed","status":412,"detail":"The booking has changed since you read it; GET it again"}
Фактический вывод, служебные заголовки убраны

Тесты мы проверили мутациями: если отключить сравнение отпечатков, проверку If-Match или сохранение ключей либо заменить строгое сравнение курсора нестрогим, падает соответствующий тест. Одна мутация выжила: если убрать id из сортировки, тесты проходят — в них нет двух записей на одно время. Закрыть этот пробел — первое упражнение.

  • Адреса — существительные во множественном числе, вложенность не глубже «коллекция/элемент/коллекция»
  • Метод соответствует смыслу: GET ничего не меняет, повтор PUT и DELETE безопасен
  • У каждой операции описаны успешный ответ и ошибки 4xx, 201 отдаёт Location
  • Все ошибки — application/problem+json, у ошибок предметной области свой type
  • Списки постраничные с первой версии, limit ограничен сверху, сортировка детерминирована
  • POST, который нельзя дублировать (оплата, запись), принимает Idempotency-Key
  • Изменения защищены от потерянного обновления: ETag и If-Match
  • Изменение не ломает клиентов — или выходит новой мажорной версией, а старая получает Deprecation и Sunset
  • redocly lint проходит в CI, тесты проверяют ответы по схемам контракта

Дальше: те же соглашения в Spring Boot — в уроке про REST API на Spring Boot, а выбор между REST и GraphQL — в отдельном сравнении.

Как проверено5 · пройдено2 · не запускалосьсверено 11 октября 2026 г.
  • запущено, работает

    openapi.yaml (OpenAPI 3.1.2)

    @redocly/cli 2.62.1 `redocly lint` (набор recommended) — «Your API description is valid», 0 проблем; мутация (опечатка в $ref) — 1 error no-unresolved-refs + 1 warning, exit 1. Тот же файл с openapi 3.1.1 и 3.2.0 тоже проходит

  • запущено, работает

    package.json, src/contract.js, src/app.js, src/server.js, test/api.test.js

    временный проект, Node.js 24.18.0, Express 5.3.0, Ajv 8.20.0 + ajv-formats 3.0.1, yaml 2.9.1, supertest 7.3.1: `npm test` (node --test) — 7/7. Мутации: без сравнения отпечатков, без проверки If-Match, без сохранения ключей, курсор >= — падают соответствующие тесты; удаление id из сортировки не ловится (вынесено в упражнение 1)

  • запущено, работает

    demo.sh, demo-patch.sh и их вывод

    запуск src/server.js на порту 3917 (в статье — порт 3917), bash + curl; вывод в статье фактический, удалены только заголовки Date/Connection/Keep-Alive; JSON-ответ 400 в разделе об ошибках — тот же вывод, отформатированный JSON.stringify(…, null, 2)

  • запущено, работает

    упражнение 1 (решение ex/tie.test.js)

    проходит на исходном app.js; после удаления `|| a.id.localeCompare(b.id)` падает (actual ['m-1'])

  • запущено, работает

    SQL курсорной страницы в подсказке (WHERE (starts_at, id) > ($1, $2) … LIMIT 21)

    postgres:17-alpine (17.11), 50 000 строк, индекс (starts_at, id): EXPLAIN — Index Only Scan с Index Cond по ROW(starts_at, id)

  • не запускалось

    заголовки Deprecation/Link/Sunset (раздел о версиях)

    пример взят из RFC 9745 (разделы 3 и 4), не исполняемый код

  • не запускалось

    упражнения 2 и 3

    решения не публикуются, только подсказки

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.