Удобный API строится вокруг ресурсов-существительных, использует методы HTTP в том смысле, который задаёт RFC 9110, отвечает точными кодами и ошибками в формате Problem Details (RFC 9457), листает большие списки курсором и развивается, не ломая клиентов. Эти соглашения фиксируют в спецификации OpenAPI 3.1 до написания кода, проверяют линтером, а реализацию — тестами на соответствие контракту.
После материала вы сможете
- спроектировать 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 г.
Содержание
- Ресурсы и URL: существительные, коллекции, вложенность
- Методы, идемпотентность и коды ответов
- Формат ошибок: Problem Details (RFC 9457)
- Пагинация, фильтры и сортировка
- Повтор POST без дублей: заголовок Idempotency-Key
- Версионирование и обратная совместимость
- Контракт в OpenAPI 3.1 и проверка линтером
- Заглушка по контракту, тесты и чек-лист ревью
Ресурсы и 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 (отформатирован для чтения):
{
"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\""
}
]
}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.
Сортировка с уникальным хвостом
Записи сортируются по
starts_at, а при равном времени — поid. Без второго ключа две записи на одно время могут поменяться местами между запросами, и одна из них потеряется.Курсор непрозрачен
В курсор кодируются порядок сортировки,
starts_atиidпоследнего элемента в base64url. Клиент не разбирает курсор, а передаётnext_cursorдальше — поэтому формат можно менять без новой версии API.Курсор привязан к сортировке
Курсор от
sort=-starts_atне принимается вместе сsort=starts_at: заглушка ответит 400 и укажет параметрcursor.Конец списка явный
На последней странице
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 против запущенной заглушки:
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"}'$ 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:
Deprecation: @1688169599
Link: <https://developer.example.com/deprecation>;
rel="deprecation"; type="text/html"
Sunset: Sun, 30 Jun 2024 23:59:59 UTCDeprecation — дата в формате 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 — этим воспользуемся в заглушке.
Спецификация, заглушка и тесты лежат в одной папке. Зависимости:
{
"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: 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' }Idempotency-Key для POST, If-Match для PATCH, все ошибки — application/problem+jsonПроверка — Redocly CLI. Без файла конфигурации redocly lint применяет встроенный набор правил recommended: структура по спецификации, неразрешённые $ref, неиспользуемые компоненты, наличие ответов 4xx у операций и другие. Для строгого режима есть набор recommended-strict, собственные правила задают в redocly.yaml.
$ 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 и не пропускать сломанный контракт в основную ветку:
$ 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, и один и тот же контракт проверяет входящие запросы на сервере и ответы в тестах.
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-токен считается аккаунтом; настоящую проверку разбирают уроки про аутентификацию и авторизацию.
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;
}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 — это контрактные тесты.
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");
});$ 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 клиент учитывать не должен.
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"}'$ 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.