Документация API: справочник, примеры, контракт
Половина третьего ночи, интегратор подключает ваш платёжный сервис. Он отправляет
POST /v1/payments, получает таймаут на тридцатой секунде и повторяет запрос — так делают
все HTTP-клиенты по умолчанию. Утром поддержка разбирает шестнадцать двойных списаний.
Интегратор не был неаккуратен. Он открыл справочную страницу и нашёл там всё: девятнадцать
полей, их типы, обязательность, диапазоны, коды ответов 200, 400, 401, 404, 500. Страница
сгенерирована из кода, обновляется каждым релизом, ни одного расхождения с реальностью.
В ней нет одного предложения: что произойдёт, если тот же запрос придёт дважды.
Ни «повтор безопасен», ни «повтор создаст второй платёж», ни «используйте Idempotency-Key».
Схема этого не выражает — и генератор не выдумал.
Документация API — не одна страница про эндпоинты, а три разных документа с тремя читателями. Справочник отвечает на «какое поле», примеры — на «заработает ли у меня», контракт — на «на что я могу опереться». Команды почти всегда пишут первый, потому что его можно сгенерировать, и почти никогда — третий, потому что его нельзя.
Это та же оптика, что в главе «Читатель и решение»:
жанр задаётся адресатом и решением, которое тот должен принять. Особенность API-документации
в том, что под одним адресом /docs соседствуют три жанра, и их регулярно путают.
Границы главы. Как проектировать API — «Стили API». Как проверять соответствие реализации контракту — «Тестирование API». Как собирать требования к интеграции — «Анализ API». Как защищать — «Безопасность API». Здесь — про текст: что написать, чем держать его в актуальном состоянии и как отличить документацию от её имитации.
Три документа под одним именем
| Слой | Читатель | Его решение | Источник правды | Кто пишет |
|---|---|---|---|---|
| Справочник | пишет код прямо сейчас | какое поле подставить, что значит код | схема и типы в коде | генератор |
| Примеры | оценивает продукт за 15 минут | стоит ли тратить неделю | исполняемый код | инженер, редко |
| Контракт | строит систему поверх вашей | на какие гарантии опереться | головы трёх человек | никто |
Последняя ячейка — источник большинства инцидентов интеграции. Справочник генерируется и потому всегда есть. Примеры пишутся один раз при запуске и потом тихо гниют. Контракт не пишется вообще, потому что не следует из кода механически: его надо вспомнить, сформулировать и решиться зафиксировать. Зафиксировать страшно — записанное становится обязательством.
Практический тест на зрелость: откройте документацию своего сервиса и найдите ответ на три вопроса — «безопасно ли повторить этот запрос», «в каком порядке придут события», «что будет при превышении лимита». Справочник полон, а этих ответов нет — у вас не документация, а её самая дешёвая треть.
Справочник: читатель уже внутри
Справочник не читают — в него попадают: из стектрейса, из ответа с незнакомым кодом, из автодополнения в IDE. Читатель приходит с одним конкретным словом и хочет уйти через сорок секунд. Отсюда требования к форме: адресуемость (у каждого поля и кода ошибки свой якорь, на который можно сослаться в тикете), единообразие (одинаковое описано одинаковыми словами в одинаковом порядке — читатель сканирует, а не читает), полнота важнее красоты, никакого повествования.
Главная содержательная ошибка — описание, пересказывающее имя поля.
<!-- Плохо: описание повторяет имя -->
customer_id (string, required) — идентификатор клиента.
amount (integer, required) — сумма.
status (string) — статус платежа.
Полезное описание отвечает на два вопроса, которых в схеме нет: откуда я это возьму и что будет, если значение неверное.
<!-- Переписано -->
customer_id (string, required) — идентификатор клиента в биллинге, вида `cus_` + 24 символа.
Возвращается при создании клиента (`POST /v1/customers`) и не меняется никогда.
Клиента нет или он удалён → 404, `code: "customer_not_found"`.
amount (integer, required) — сумма в минорных единицах валюты: 4900 = 49,00 RUB.
Дробные значения отклоняются с 400 `amount_not_integer` — самая частая ошибка интеграции.
Максимум 100 000 000 (миллион рублей) на операцию.
status (string, read-only) — состояние платежа: pending, authorized, captured, canceled,
refunded, partially_refunded. Переходы — в разделе «Жизненный цикл платежа»; поле
меняется асинхронно, опрашивать его вместо подписки на события не нужно.
Разница не в объёме, а в том, что второй вариант закрывает решения читателя: где взять, в чём мерить, что сломается. Одно предложение про минорные единицы снимает целый класс инцидентов, где интегратор списывает в сто раз больше.
Язык справочника — по правилам главы «Ясность»: никаких «может быть», «как правило», «обычно». Либо поле обязательное, либо нет; либо порядок гарантирован, либо не гарантирован. Модальность здесь — невыполненная работа автора, переложенная на читателя. И описания живут прямо в схеме, а не в документе рядом с ней:
# openapi.yaml — фрагмент, из которого собирается справочник
components:
schemas:
PaymentRequest:
type: object
required: [customer_id, amount, currency]
properties:
customer_id:
type: string
pattern: '^cus_[A-Za-z0-9]{24}$'
description: >
Идентификатор клиента в биллинге. Возвращается при создании клиента
(POST /v1/customers) и неизменен. Нет такого клиента → 404 customer_not_found.
example: cus_9Kd2mQ7fT4pLxA0vB3nR8sZ
amount:
type: integer
minimum: 1
maximum: 100000000
description: >
Сумма в минорных единицах: 4900 = 49,00 RUB.
Дробное значение → 400 amount_not_integer.
example: 4900
Справочник обязан генерироваться
Рукописный справочник расходится с кодом молча: код меняется в одном файле, текст живёт в другом, ничто их не связывает. Через полгода расхождение уже не найти иначе как поштучным сравнением. Лекарство — не дисциплина, а единственный источник правды плюс проверка на пути в прод.
| Подход | Источник правды | За | Против |
|---|---|---|---|
| Code-first | аннотации и типы в коде | справочник физически не может отстать | спека уродлива, дизайн API рождается стихийно |
| Spec-first | openapi.yaml в репозитории |
API обсуждают до реализации, клиенты генерируются заранее | код может разойтись со спекой, нужна отдельная проверка |
Оба работают. Не работает третий — «спека в вики, код живёт своей жизнью». Выбор между первыми двумя — это ADR, а не вкусовщина: он определяет, кто и когда правит контракт.
в коде"] --> spec["openapi.yaml
источник правды"] hand["Рукописные страницы:
гарантии, ошибки, миграции"] end spec --> lint["Spectral
стиль и полнота"] spec --> diff["oasdiff
ломающие изменения к main"] spec --> conf["Schemathesis
сервер отвечает по схеме"] hand --> own["CODEOWNERS
и срок ревизии"] ex["Примеры из документации"] --> run["CI запускает примеры
против стенда"] lint --> gate{"CI зелёный?"} diff --> gate conf --> gate run --> gate own --> gate gate -- нет --> stop["PR не мёржится"] gate -- да --> pub["Публикация справочника
и клиентских SDK"]
Три проверки в середине стоят полдня настройки и снимают три разных класса лжи. Линтер
спецификации ловит пустоту: метод без описания, поле без примера, отсутствующий 4xx —
без него генерация даёт формально полный и содержательно пустой справочник, «user_id —
the user id» на каждой строке. Диффер ловит ломающее изменение до того, как его увидит
клиент: удалённое поле, сузившийся enum, ставший обязательным параметр. Проверка
соответствия ловит расхождение реализации со спекой.
# .spectral.yaml — минимум, который стоит включить в первый же день
extends: ["spectral:oas"]
rules:
operation-description-required:
description: У каждой операции есть описание длиннее 40 символов
given: "$.paths[*][get,post,put,patch,delete]"
severity: error
then:
field: description
function: length
functionOptions:
min: 40
property-description-required:
description: У каждого свойства схемы есть описание
given: "$.components.schemas[*].properties[*]"
severity: error
then:
field: description
function: truthy
# в пайплайне: любая из трёх проверок красная — PR не едет
spectral lint openapi.yaml --fail-severity=error
oasdiff breaking origin/main:openapi.yaml openapi.yaml --fail-on ERR
schemathesis run --checks all --base-url "$STAGING_URL" openapi.yaml
Честная оговорка: генерация даёт полноту и синхронность, но не смысл. Она перенесёт ровно то, что вы написали в аннотациях; если там пусто, вы получите идеально актуальный бесполезный документ. Генератор снимает вопрос «расходится ли», но не вопрос «сказано ли хоть что-нибудь».
Примеры: единица доверия
Пример — единственная часть документации, которую выполняют, а не читают. Не запустился с первого раза — доверие падает ко всему тексту сразу, включая справочник.
# Плохо: не запускается ни у кого
curl -X POST https://api.example.com/v1/payments \
-H "Authorization: Bearer <your-token>" \
-d '{ "customer_id": "...", "amount": ... }'
Плейсхолдеры вместо значений; нет Content-Type (сервер вернёт 415, и читатель решит, что
сломан ваш API); не показан ответ; не сказано, где взять токен; ни слова про ошибку.
# Переписано: копируется и работает
# Тестовый ключ — в консоли, Developers → API keys. Ключи sk_test_* работают
# на песочнице и не двигают реальные деньги.
export API_KEY="sk_test_4eC39HqLyjWDarjtT1zdp7dc"
curl -X POST https://api.example.com/v1/payments \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7f3a1c9e-5b2d-4a11-9f70-2c8e6d4b1a05" \
-d '{"customer_id": "cus_9Kd2mQ7fT4pLxA0vB3nR8sZ", "amount": 4900, "currency": "RUB"}'
// 201 Created
{ "id": "pay_3Nk8QeLm2vA7", "status": "authorized", "amount": 4900,
"currency": "RUB", "capture_before": "2026-03-19T09:41:07Z" }
// Тот же запрос с картой, которую банк отклоняет (номер 4000 0000 0000 0002) — 402
{ "type": "https://api.example.com/errors/card_declined",
"title": "Карта отклонена банком-эмитентом", "status": 402,
"code": "card_declined", "decline_reason": "insufficient_funds",
"retriable": false, "payment_id": "pay_3Nk8QeLm2vB1" }
Три вещи отличают рабочий раздел примеров от декоративного.
- Пример на ошибку важнее примера на успех. Happy path интегратор пройдёт сам за
двадцать минут; неделю он потеряет на разборе того, что вернулось в четверг вечером.
Дайте способ воспроизвести отказ намеренно: тестовые карты, специальные суммы,
заголовок
X-Simulate-Failure. - Пример показывает сценарий, а не один вызов. «Создать клиента → привязать способ оплаты → списать → вернуть» — это то, что нужно на самом деле; четыре изолированных вызова читатель и так видит в справочнике.
- Примеры выполняются в CI. Иначе они устаревают быстрее всего остального: в них зашиты не только имена полей, но и значения, ключи, URL и порядок шагов.
Последний пункт — главное структурное решение раздела. Пример живёт как код, а в документ попадает включением:
# tests/docs/test_quickstart.py — тот самый пример из документации, но как тест
import os, uuid, httpx, pytest
client = httpx.Client(
base_url=os.environ["SANDBOX_URL"],
headers={"Authorization": f"Bearer {os.environ['SANDBOX_KEY']}"},
timeout=10.0,
)
def test_quickstart_creates_authorized_payment() -> None:
"""Шаг 2 быстрого старта: создание платежа возвращает authorized."""
r = client.post("/v1/payments",
headers={"Idempotency-Key": str(uuid.uuid4())},
json={"customer_id": CUSTOMER_ID, "amount": 4900, "currency": "RUB"})
assert r.status_code == 201
body = r.json()
# Проверяем ровно те поля, которые обещаны в примере ответа: перестанет отдавать —
# упадёт наш тест, а не интеграция читателя.
assert body["status"] == "authorized" and body["amount"] == 4900
assert body["id"].startswith("pay_") and "capture_before" in body
def test_declined_card_returns_documented_error() -> None:
"""Раздел «Обработка отказов»: код ошибки и retriable — часть контракта."""
r = client.post("/v1/payments",
headers={"Idempotency-Key": str(uuid.uuid4())},
json={"customer_id": DECLINE_CUSTOMER_ID, "amount": 4900, "currency": "RUB"})
assert r.status_code == 402
assert r.json()["code"] == "card_declined" # код стабилен, текст title — нет
assert r.json()["retriable"] is False
Цена — один прогон в CI. Выгода — примеры перестают быть обещаниями и становятся проверяемыми утверждениями. Тот же принцип, что в главе «Ревью текста»: непроверяемое утверждение рано или поздно окажется ложным.
Контракт: то, чего нет в схеме
Схема описывает форму сообщений, контракт — поведение системы. Ни один генератор не выведет поведение из типов, потому что его там нет. Вот что произошло в ночной сцене из начала главы:
Документ обязан ответить:
повторять с тем же ключом или с новым? C->>A: Повтор, Idempotency-Key 7f3a A->>K: Занять ключ 7f3a K-->>A: Занят, есть сохранённый ответ A-->>C: 201 pay_3Nk8, Idempotent-Replay: true
Шаг 7 — единственный, который справочник описать не может. Полный перечень того, что относится к контракту и пишется только руками:
| Гарантия | Вопрос читателя | Если не написано |
|---|---|---|
| Идемпотентность и ретраи | безопасно ли повторить? | двойные списания |
| Порядок и доставка событий | по порядку? могут ли дублироваться? | рассинхрон состояний |
| Согласованность | увижу ли в GET то, что только что записал? | «баг: данные не сохранились» |
| Пагинация | стабилен ли курсор при вставках? | пропуски и дубли при выгрузке |
| Лимиты | что при превышении, лимит на ключ или на организацию? | ночная выгрузка кладёт интеграцию |
| Коды ошибок | что перебирать в switch, что стабильно? |
парсинг человеческого текста |
| Стабильность формата | что появится и исчезнет без смены версии? | клиент падает на новом поле |
| Единицы и время | минорные единицы? UTC? граница включена? | ошибки в сто раз и на сутки |
Типичный абзац про идемпотентность выглядит так:
<!-- Плохо -->
Метод является идемпотентным. При возникновении сетевых ошибок запрос можно
безопасно повторить. Рекомендуется использовать заголовок Idempotency-Key.
Три слова делают абзац бесполезным: «является» (в каком смысле?), «рекомендуется» (а если нет?), «безопасно» (в течение какого времени?). После него читатель знает столько же, сколько до, но думает, что знает больше, — это хуже молчания.
<!-- Переписано -->
## Повторные запросы
POST и DELETE идемпотентны **только при передаче Idempotency-Key**. Без заголовка повтор
создаёт вторую операцию — это не ошибка, а поведение по умолчанию.
- Ключ — строка до 255 символов, уникальная для операции. Берите UUID v4 и сохраняйте
его у себя **до** отправки: после таймаута воспроизвести тот же ключ будет нечем.
- Первый запрос с ключом выполняется, ответ сохраняется на 24 часа.
- Повтор с тем же ключом и тем же телом возвращает сохранённый ответ с исходным
HTTP-кодом и заголовком `Idempotent-Replay: true`. Побочных эффектов нет.
- Повтор с тем же ключом и **другим** телом → 409, `idempotency_key_reuse`
(тело сравнивается по хешу, порядок полей JSON не важен).
- Повтор, пришедший пока первый ещё выполняется, → 409, `request_in_flight`.
- Через 24 часа ключ забывается, и тот же запрос создаст вторую операцию. Для сверки
за пределами суток используйте свой `external_id` (см. «Дедупликация»).
Наш таймаут — 30 с. Ответа нет → повторяйте **с тем же ключом**, максимум 5 раз
с задержкой 1, 2, 4, 8, 16 с. Статус так и неизвестен → `GET /v1/payments?idempotency_key=...`
вернёт операцию, если она была создана.
Абзац стал в пять раз длиннее, и здесь длина оправдана: он экономит инцидент. Это принцип из главы «Структура» — объём выделяется не по важности темы для автора, а по цене ошибки читателя.
Второй пример — лимиты, где почти все пишут одну и ту же пустую фразу.
<!-- Плохо -->
API имеет ограничение на количество запросов. При превышении лимита возвращается
ошибка 429. Лимиты могут быть изменены.
<!-- Переписано -->
Лимит — 100 запросов в секунду на организацию (не на ключ: все ваши ключи делят один
бюджет), скользящее окно 1 с. Пакетный `POST /v1/payments/batch` считается за N запросов
по числу элементов. Превышение → 429 с заголовками:
Retry-After: 2 — через сколько секунд повторять
RateLimit-Remaining: 0 — остаток в текущем окне
RateLimit-Reset: 2 — когда окно обнулится
429 не расходует идемпотентный ключ: повтор с тем же ключом корректен.
Нужен лимит выше — заявка в поддержку, рассматриваем 3 рабочих дня.
Снижение лимитов анонсируется за 90 дней (см. «Политика изменений»).
Коды ошибок — часть контракта не меньше, чем успешный ответ. Самая ценная их часть —
машиночитаемый code, по которому клиент ветвится; человекочитаемый title меняться будет.
| code | HTTP | Повторять? | Что делать |
|---|---|---|---|
| card_declined | 402 | нет | показать причину из decline_reason |
| customer_not_found | 404 | нет | проверить customer_id, ошибка интеграции |
| idempotency_key_reuse | 409 | нет | ключ переиспользован на вашей стороне |
| request_in_flight | 409 | да, через 1–2 с | тот же ключ, экспоненциальная задержка |
| rate_limited | 429 | да, по Retry-After | снизить параллелизм |
| provider_unavailable | 503 | да, до 5 раз | тот же ключ, задержка 1, 2, 4, 8, 16 с |
Колонка «Повторять?» напрямую отображается в if в коде читателя — тот случай, когда одна
колонка таблицы стоит трёх страниц текста. Формат тела ошибки лучше взять стандартный —
RFC 9457 «Problem Details for HTTP APIs» (https://www.rfc-editor.org/rfc/rfc9457.html),
чтобы читателю не пришлось изучать вашу персональную структуру.
Третье, чего нет в схеме, — жизненный цикл ресурса. Справочник показывает поле status
и перечисление значений; он не показывает, какие переходы возможны, какие необратимы
и где окно для действия.
Диаграмма отвечает на вопрос «когда ещё не поздно отменить», который иначе разбирается в переписке с поддержкой. Про то, когда схема помогает, а когда подменяет отсутствующий текст, — глава «Схемы в документах».
Теория под всем разделом — «Идемпотентность и доставка» и «Модели согласованности». Задача автора — не пересказать теорию, а сказать, какой вариант выбран у вас, в одном абзаце и с числами.
Депрекейт — отдельный жанр
Изменение API — это документ, а не только код. Читатель принимает решение: затронут ли я, что менять, к какому сроку. Типичное сообщение не даёт ни одного из трёх ответов.
<!-- Плохо -->
Метод `GET /v1/payments/list` устарел. Рекомендуется использовать новый метод.
<!-- Переписано -->
## `GET /v1/payments/list` объявлен устаревшим 12.03.2026
**Отключение — 15.09.2026.** После этой даты метод вернёт 410, `code: "endpoint_sunset"`.
**Затронуты вы, если** в логах есть вызовы этого пути или ответы приходят с заголовком
`Deprecation: Wed, 12 Mar 2026 00:00:00 GMT`. Проверить свой трафик:
`GET /v1/usage/deprecated?from=2026-03-01` — вернёт вызовы устаревших методов по вашим ключам.
**Что делать:** перейти на `GET /v1/payments`.
| Было (`/list`) | Стало (`/payments`) | Почему |
|---|---|---|
| `offset` / `limit` | `cursor` / `limit` | offset-пагинация пропускала записи при вставках |
| `date_from` (дата) | `created_after` (RFC 3339) | границы однозначны, часовой пояс обязателен |
| `items` | `data` | единый конверт списка во всех методах |
| нет | `has_more` | признак следующей страницы |
Полей стало больше, ни одно не исчезло, порядок сортировки прежний.
Типичная миграция — 1–2 часа. Вопросы: #api-migrations.
Ключевая часть тут структурная, а не текстовая: сигнал в самом протоколе — заголовки
Deprecation (RFC 9745, https://www.rfc-editor.org/rfc/rfc9745.html) и Sunset
(RFC 8594, https://www.rfc-editor.org/rfc/rfc8594.html) в ответах устаревшего метода
плюс отчёт по использованию. Тогда до читателя доходит не рассылка, которую он не читает,
а данные в его собственных логах, а у вас появляется честный
RFC на отключение с цифрами: «осталось семь клиентов,
94 запроса в сутки». Журнал изменений при этом пишется не списком коммитов, а в терминах
«что вам придётся сделать»: ломающее, новое, исправленное. Про запись изменений в терминах
читателя — «Совместная работа в Git».
Не только REST
Три слоя переносятся на любой стиль, меняется техника генерации справочника.
- gRPC / Protobuf. Комментарии в
.proto— и есть справочник, они уезжают в сгенерированные клиенты и всплывают в IDE; ломающие изменения ловитbuf breaking. Контрактный слой тот же: дедлайны, ретраи, идемпотентность, семантикаUNAVAILABLEпротивFAILED_PRECONDITION. - GraphQL. Описания живут в SDL,
@deprecated(reason: "...")— обязательное поле, а не украшение:reason— это ваш абзац миграции в одну строку. Специфический вопрос контракта — лимиты по сложности запроса, а не по числу вызовов. - События и очереди. AsyncAPI описывает схему сообщения; всё остальное — контракт: at-least-once или exactly-once, ключ партиционирования, гарантии порядка, что делать с дублями, время жизни в очереди, куда попадает необработанное. Здесь контрактный слой доминирует над справочным.
- Внутренние API. Соблазн «мы одна компания, спросят в чате» работает, пока команд три. Внутренние API отличаются от публичных не наличием документации, а тем, что контрактный слой у них важнее справочного: соседи прочитают ваш код, но не узнают из него, на что вы готовы дать обещание. На масштабе платформы — «Инфраструктурное API».
Почему документация устаревает и что делать структурно
Призывы «обновляйте документацию» не работают нигде по простой причине: устаревание — следствие не лени, а расстояния между документом и изменением. Чем больше шагов между «поменял код» и «поменял текст», тем вероятнее, что второй шаг не случится. Лечится это сокращением расстояния и превращением расхождения в поломку сборки.
Что генерировать, а что писать руками, определяют два свойства: выводится ли содержание из кода и как часто оно меняется.
Правый верхний угол — самый опасный: из кода не выводится, меняется постоянно. Там живут скриншоты и пошаговые сценарии, и там документация гниёт первой. Дешёвое решение — сокращать этот квадрант: скриншоты заменять текстом, сценарии — исполняемыми примерами, остаток снабжать владельцем и сроком.
Пять механизмов, которые работают вместо призывов:
- Близость. Текст лежит в том же репозитории и правится в том же PR, что и код. Документация в отдельной вики отстаёт всегда — не потому, что о ней забывают, а потому, что для её правки нужен отдельный акт воли. Правило: изменение контракта без изменения текста не проходит ревью, это часть критериев приёмки.
- Генерация. Всё, что выводится из типов, выводится из типов. Рукописный список полей — гарантированная будущая ложь.
- Проверяемость. Расхождение обязано ломать сборку: линтер спеки, диффер ломающих изменений, примеры как тесты, контрактные тесты со стороны потребителя (Pact). Документ, расхождение которого с реальностью ничего не ломает, с реальностью разойдётся.
- Владелец. У каждой рукописной страницы — команда в
CODEOWNERS, а не «отдел документации». Владелец не тот, кто пишет, а тот, кому приходит ревью при изменении. - Срок жизни. У страниц контрактного слоя — дата ревизии и напоминание раз в полгода: подтвердить или удалить. Устаревшая страница хуже отсутствующей: отсутствие заставляет спросить, ложь — действовать уверенно и неправильно. Удаление — нормальная операция с документом, а не признание поражения.
Шестой механизм — обратная связь: повторяющийся вопрос в канале интеграции заводится как баг документации с той же приоритизацией. Три одинаковых вопроса — это не три непонятливых читателя, это одна отсутствующая страница. Аналитика поиска по порталу (что искали и не нашли) — самый дешёвый источник задач, который почти никто не смотрит.
Часть документов пишется ради процесса
Честная часть главы. Некоторые документы про API существуют не для читателя, а для регламента, и признать это полезнее, чем делать вид, что они кому-то помогают. Выглядит это так: выгрузка Swagger в Word раз в квартал как приложение к акту сдачи этапа; раздел «Описание интерфейсов взаимодействия» из требований к комплекту документации; «протокол информационного обмена», подписанный двумя департаментами и с тех пор не открывавшийся; страница в вики, созданная под аудит и заполненная копипастой.
Четыре теста распознают такой документ за пять минут:
- Тест удаления. Удалите страницу — кто заметит и через сколько? Никто и никогда — документ процессный.
- Тест пути. Как читатель на неё попадает? Единственный путь — ссылка из письма о сдаче этапа? Значит, читателей нет.
- Тест последнего изменения. Правки только перед вехами проекта и ни одной по ходу разработки — документ обслуживает веху, а не интеграцию.
- Тест вопроса. Задайте документу вопрос, который реально возник у интегратора на прошлой неделе. Ответа нет — документ не про интеграцию.
Что делать. Не воевать: требование чаще всего законно. У аудитора и приёмочной комиссии действительно есть решение, которое они принимают по этому документу («принимаем этап», «система соответствует»), — это другой читатель, а не отсутствие читателя, и мерить такой документ метриками интегратора бессмысленно. Разумная стратегия — разделить слои и автоматизировать процессный: пусть он генерируется из той же спецификации одной командой в CI, тогда он стоит минуты, а не недели. И ни при каких обстоятельствах не давайте процессной копии стать источником правды: как только правки начинают вноситься в Word, живой слой умирает. Если решение приходится защищать наверху, аргумент — не «так правильно», а стоимость: часы в квартал на ручную выгрузку против одной команды в пайплайне («Работа вверх»).
И обратная честность: если ваш «живой» слой проходит те же четыре теста как процессный — никто не заметит удаления, никто туда не ходит, правки только перед релизом, — значит, вы тоже пишете ради процесса, просто ваш процесс называется «у взрослых команд есть документация». Лучше одна честная страница, которую читают, чем двадцать, которые не читает никто.
Типичные ошибки
- Считать, что Swagger UI — это документация. Это справочник, треть работы, а без описаний — пустая треть.
- Описание, повторяющее имя поля. Стоит ровно ноль и создаёт иллюзию заполненности.
- Примеры с плейсхолдерами.
<your-token>,...,PUT_YOUR_ID_HERE— читатель не может проверить, работает ли ваш API, и уходит. - Только happy path. Интегратор напишет свою эвристику по текстам сообщений, и она сломается на первом же релизе.
- Человекочитаемый текст ошибки как контракт. Как только клиенты начнут ветвиться
по
title, вы не сможете исправить в нём опечатку. - Модальность в контракте. «Обычно события приходят по порядку» — не гарантия, а ловушка. Либо гарантируете, либо пишете, что порядок не гарантирован.
- Документация в вики отдельно от кода. Расстояние гарантирует расхождение.
- Депрекейт без даты и пути миграции. Переносят не слова «используйте новый метод», а дата, отчёт по своему трафику и таблица соответствия полей.
- Молчание про лимиты и таймауты. Их всё равно узнают — на проде, ночью.
- Тон, объясняющий читателю, как ему жить. Документация API — такой же интерфейс, и правила из главы «Текст в интерфейсе» здесь работают целиком.
Мини-итог
- Документация API — три жанра под одним адресом: справочник для того, кто уже внутри; примеры для того, кто решает, входить ли; контракт для того, кто строит поверх вас.
- Справочник обязан генерироваться из единственного источника правды. Генерация даёт синхронность, но не смысл; смысл добавляют описания, которых требует линтер.
- Полезное описание поля отвечает на «откуда взять» и «что будет, если неверно».
- Пример — единственная часть, которую выполняют: копируется целиком, показывает ответ, включает случай ошибки и живёт в CI как тест.
- Контракт — то, чего нет в схеме: идемпотентность, порядок, согласованность, лимиты, коды ошибок, стабильность формата, единицы и время. Пишется руками, с числами, без «обычно» и «рекомендуется».
- Депрекейт — жанр с обязательными полями: дата отключения, способ узнать, что вы затронуты,
таблица соответствия, заголовки
DeprecationиSunsetв ответе. - Устаревание лечится структурой, а не призывами: близость к коду, генерация, проверяемость в CI, владелец, срок жизни и удаление как нормальная операция.
- Часть документов пишется ради процесса. Распознаётся тестами удаления, пути, последнего изменения и вопроса; лечится разделением слоёв и автоматизацией процессного.
Источники
- OpenAPI Specification 3.1: https://spec.openapis.org/oas/latest.html
- JSON Schema — язык схем, на котором стоит OpenAPI: https://json-schema.org/
- RFC 9457, Problem Details for HTTP APIs: https://www.rfc-editor.org/rfc/rfc9457.html
- RFC 8594, The Sunset HTTP Header Field: https://www.rfc-editor.org/rfc/rfc8594.html
- RFC 9745, The Deprecation HTTP Response Header Field: https://www.rfc-editor.org/rfc/rfc9745.html
- Черновик IETF об идемпотентном ключе, полезен как чек-лист формулировок: https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
- Stripe, раздел об идемпотентных запросах — эталон контрактного слоя: https://docs.stripe.com/api/idempotent_requests
- Google AIP, свод соглашений по проектированию и описанию API: https://google.aip.dev/
- Zalando RESTful API Guidelines: https://opensource.zalando.com/restful-api-guidelines/
- Microsoft REST API Guidelines: https://github.com/microsoft/api-guidelines
- Spectral, линтер OpenAPI: https://docs.stoplight.io/docs/spectral
- oasdiff, поиск ломающих изменений между версиями спеки: https://github.com/oasdiff/oasdiff
- Schemathesis, проверка соответствия сервера спецификации: https://schemathesis.readthedocs.io/
- Pact, контрактные тесты со стороны потребителя: https://docs.pact.io/
- AsyncAPI, описание событийных интерфейсов: https://www.asyncapi.com/
- buf, линтер и детектор ломающих изменений для Protobuf: https://github.com/bufbuild/buf
- Docs as Code, Write the Docs: https://www.writethedocs.org/guide/docs-as-code/
- Google developer documentation style guide: https://developers.google.com/style
- Jared Bhatti et al., «Docs for Developers» (Apress, 2021) — учебник по документации для инженеров, отдельные главы про справочники и примеры.
- Arnaud Lauret, «The Design of Web APIs» (Manning) — как дизайн API и его описание влияют друг на друга.
Что дальше
Справочник, примеры и контракт отвечают на вопросы человека, который уже решил работать с вашей системой. Между «скопировал curl» и «понимаю, как здесь всё устроено» лежит пространство обучающих текстов, и оно устроено по другим правилам. Быстрый старт, руководство по задаче, объяснение концепции и справочник — четыре разных жанра, которые регулярно смешивают в один документ, и от этого не работает ни один из четырёх.
Руководства и обучающие тексты: разные жанры, разные правила