Интерактивная API-документация
Не вся документация одинаковая. Есть популярная классификация, по которой выделяют четыре типа доки: туториалы (tutorial), how-to руководства (how-to guides), справочная документация (technical references) и разборы (explanations).

Де-факто стандарт справочной документации для API — OpenAPI. Вполне удобная штука. Но совсем не подходит для how-to и туториалов.
В этой статье я предложу краткий и удобный формат интерактивной API-документации для любых HTTP API (REST, RPC и что угодно еще). И для этого (сюрприз, сюрприз) мы используем сам протокол HTTP.
Краткий курс по HTTP-сообщениям
HTTP/1.x — это текстовый протокол обмена сообщениями между клиентом и сервером. Клиент посылает сообщения такого вида:
POST /anything/chat HTTP/1.1
host: httpbingo.org
content-type: application/json
user-agent: curl/7.87.0
{
    "message": "Hello!"
}
И получает такие сообщения в ответ:
HTTP/1.1 200 OK
date: Mon, 28 Aug 2023 07:51:49 GMT
content-type: application/json
{
    "message": "Hi!"
}
HTTP/2 (следующая версия стандарта) — бинарный протокол. Но все утилиты (вроде DevTools браузера или curl) показывают сообщения HTTP/2 в текстовом виде (аналогично HTTP/1.1), так что мы можем эту бинарность спокойно игнорировать.

HTTP-запрос состоит из трех секций:
- Строка запроса:
POST /anything/chat HTTP/1.1
- Метод (POST) задает операцию, которую хочет выполнить клиент.
- Путь (/anything/chat) задает URL запрошенного ресурса (без протокола, домена и порта).
- Версия (HTTP/1.1) указывает версию HTTP-протокола.
- Заголовки запроса:
host: httpbingo.org
content-type: application/json
user-agent: curl/7.87.0
Каждый заголовок — пара вида «ключ-значение», которая сообщает серверу некоторую полезную информацию о запросе. В нашем случае это доменное имя сервера (httpbingo.org), тип содержимого (application/json) и самоидентификация клиента (user-agent).
- Тело запроса:
{
    "message": "Hello!"
}
Данные, которые клиент передает серверу.
HTTP-протокол не предусматривает отдельное сохранение состояния. Так что любое состояние должно передаваться прямо в заголовках или теле запроса.
HTTP-ответ тоже состоит из трех секций:
- Строка статуса:
HTTP/1.1 200 OK
- Версия (HTTP/1.1) указывает версию HTTP-протокола.
- Код статуса (200) сообщает, выполнился запрос успешно или нет, и почему (протокол предусматривает разные статусы для разных ситуаций).
- Статусное сообщение — расшифровка кода статуса. В HTTP/2 не передается.
- Заголовки ответа:
date: Mon, 28 Aug 2023 07:51:49 GMT
content-type: application/json
Аналогично заголовкам запроса, сообщают клиенту полезную информацию об ответе сервера.
- Тело ответа:
{
    "message": "Hi!"
}
Данные, которые сервер передает клиенту.
Протокол HTTP еще много чего предусматривает, но этих базовых знаний достаточно, чтобы закрыть большинство сценариев использования API. Так что ими и ограничимся.
Используем HTTP для API-документации
Возьмем HTTP-запрос:
POST /anything/chat HTTP/1.1
host: httpbingo.org
content-type: application/json
user-agent: curl/7.87.0
{
    "message": "Hello!"
}
И немного изменим:
- поставим полный URL вместо пути;
- уберем версию протокола.
POST http://httpbingo.org/anything/chat
content-type: application/json
{
    "message": "Hello!"
}
Такой формат идеально подходит для примеров использования API. Он краткий и читаемый, но при этом достаточно формальный, чтобы можно было выполнить программно (прямо из документации, как мы вскоре увидим).
Пишем интерактивное API-руководство
Вместо того чтобы рассказывать, как писать интерактивные руководства, я покажу готовый пример. Будем использовать Gists API — это небольшой полезный сервис гитхаба для работы с примерами кода (gists).

Даже если вы не пользуетесь гитхабом, доступ к Gists API у вас все равно есть.
Читаем примеры
Посмотрим на публичные примеры одного моего знакомого (пользователь rednafi). Ответ сервиса довольно громоздкий, так что выведем 3 последних примера (per_page = 3):
GET https://api.github.com/users/rednafi/gists?per_page=3
accept: application/json
Пачка нестандартных заголовков x-ratelimit показывает, как гитхаб ограничивает запросы:
- Всего доступно x-ratelimit-limitзапросов в час.
- Уже использовали x-ratelimit-usedзапросов.
- Осталось x-ratelimit-remainingзапросов.
За ними стоит следить, чтобы не превысить лимит.
Чтобы получить страницу примеров, используем комбинацию параметров page и per_page. Например, вот примеры 10-15:
GET https://api.github.com/users/rednafi/gists?page=3&per_page=5
accept: application/json
Гитхаб описывает постраничную навигацию в заголовке link:
link:
    <https://api.github.com/user/30027932/gists?page=2&per_page=5>; rel="prev",
    <https://api.github.com/user/30027932/gists?page=4&per_page=5>; rel="next",
    <https://api.github.com/user/30027932/gists?page=7&per_page=5>; rel="last",
    <https://api.github.com/user/30027932/gists?page=1&per_page=5>; rel="first"
Весьма предусмотрительно!
Теперь посмотрим на конкретный пример с идентификатором 88242fd822603290255877e396664ba5:
GET https://api.github.com/gists/88242fd822603290255877e396664ba5
accept: application/json
Видим среди файлов (files) файл greet.py на языке (language) Python с таким содержимым (content):
class Greeter:
    def __init__(self, greeting):
        self.greeting = greeting
    def greet(self, who):
        print(f"{self.greeting}, {who}!")
gr = Greeter("Hello")
gr.greet("world")
(да, примеры на Python тоже могут быть интерактивными!)
Интересно — у примера есть история (history). Оказывается, гитхаб создает новую версию файла при каждом изменении, и старые версии тоже хранит.
Давайте получим самую раннюю версию, с идентификатором version = 4c10d27cfb163d654745f1d72f2c7ce14225b83b (ну и длиннющие они, конечно):
GET https://api.github.com/gists/88242fd822603290255877e396664ba5/4c10d27cfb163d654745f1d72f2c7ce14225b83b
accept: application/json
Код в примере тогда был сильно проще:
msg = "Hello, world!"
print(msg)
Изменяем примеры
Итак, мы знаем, как выбирать несколько примеров, конкретный пример, и даже конкретную версию. Теперь давайте создадим новый пример!
POST https://api.github.com/gists
content-type: application/json
accept: application/json
{
    "description": "Greetings in Markdown",
    "public": true,
    "files":{
        "README.md":{
            "content":"Hello, world!"
        }
    }
}
Что это? Мы получили ошибку 401 Unauthorized. Тело ответа поясняет: требуется аутентификация (requires authentication) и даже предоставляет ссылку на документацию (до чего же хороши API у гитхаба).
Логично: гитхаб не позволяет анонимным пользователям создавать новые примеры. Необходимо аутентифицироваться с помощью API-токена.
Чтобы следующие примеры работали, укажите API-токен в текстовом поле ниже. Перед этим создайте его с областью действия 'gist' в настройках гитхаба.
Когда укажете токен, он сохранится локально в браузере и никуда не будет отправлен (кроме как в API гитхаба, когда нажмете Run).
Попробуем снова, на этот раз с заголовком authorization.
Обратите внимание на параметр public. Сервис поддерживает «секретные» примеры (public = false), но это так себе секретность. Секретные примеры не показываются в результатах запроса GET /gists, но по-прежнему доступны по идентификатору, даже для анонимных пользователей.
POST https://api.github.com/gists
content-type: application/json
accept: application/json
authorization: bearer {token}
{
    "description": "Greetings in Markdown",
    "public": true,
    "files":{
        "README.md":{
            "content":"Hello, world!"
        }
    }
}
У меня нет токена, покажите результат
HTTP/1.1 201 
cache-control: private, max-age=60, s-maxage=60
content-length: 3758
content-type: application/json; charset=utf-8
etag: "819f6b4f728843abcb50ad63da200a4c110245585b3eb1c0f59a5ebe86c8ecf5"
location: https://api.github.com/gists/b17474320a629af38255c0a6efbc72b9
x-accepted-oauth-scopes: 
x-github-media-type: github.v3
x-github-request-id: E8B5:8EDA:511F73:51AC33:64EE0266
x-oauth-scopes: gist
x-ratelimit-limit: 5000
x-ratelimit-remaining: 4997
x-ratelimit-reset: 1693323114
x-ratelimit-resource: core
x-ratelimit-used: 3
{
  "url": "https://api.github.com/gists/b17474320a629af38255c0a6efbc72b9",
  "forks_url": "https://api.github.com/gists/b17474320a629af38255c0a6efbc72b9/forks",
  "commits_url": "https://api.github.com/gists/b17474320a629af38255c0a6efbc72b9/commits",
  "id": "b17474320a629af38255c0a6efbc72b9",
  "node_id": "G_kwDOACz0htoAIGIxNzQ3NDMyMGE2MjlhZjM4MjU1YzBhNmVmYmM3MmI5",
  "git_pull_url": "https://gist.github.com/b17474320a629af38255c0a6efbc72b9.git",
  "git_push_url": "https://gist.github.com/b17474320a629af38255c0a6efbc72b9.git",
  "html_url": "https://gist.github.com/nalgeon/b17474320a629af38255c0a6efbc72b9",
  "files": {
    "README.md": {
      "filename": "README.md",
      "type": "text/markdown",
      "language": "Markdown",
      "raw_url": "https://gist.githubusercontent.com/nalgeon/b17474320a629af38255c0a6efbc72b9/raw/5dd01c177f5d7d1be5346a5bc18a569a7410c2ef/README.md",
      "size": 13,
      "truncated": false,
      "content": "Hello, world!"
    }
  },
  ...
}HTTP-статус 201 Created означает, что в результате запроса был создан новый пример.
Теперь попробуем изменить пример по идентификатору (не забудьте заменить {gist_id} в строке запроса на реальное значение id):
PATCH https://api.github.com/gists/{gist_id}
content-type: application/json
accept: application/json
authorization: bearer {token}
{
    "description": "Greetings in Markdown",
    "public": true,
    "files":{
        "README.md":{
            "content":"¡Hola, mundo!"
        }
    }
}
У меня нет токена, покажите результат
HTTP/1.1 200 
cache-control: private, max-age=60, s-maxage=60
content-type: application/json; charset=utf-8
etag: W/"989eaec7cdb50ba6441e77ea2defba257b98a535f26c2ba6062f152ceffb2d77"
x-accepted-oauth-scopes: 
x-github-media-type: github.v3
x-github-request-id: E8B5:8EDA:5188AA:52163F:64EE027F
x-oauth-scopes: gist
x-ratelimit-limit: 100
x-ratelimit-remaining: 98
x-ratelimit-reset: 1693323129
x-ratelimit-resource: gist_update
x-ratelimit-used: 2
{
  "url": "https://api.github.com/gists/b17474320a629af38255c0a6efbc72b9",
  "forks_url": "https://api.github.com/gists/b17474320a629af38255c0a6efbc72b9/forks",
  "commits_url": "https://api.github.com/gists/b17474320a629af38255c0a6efbc72b9/commits",
  "id": "b17474320a629af38255c0a6efbc72b9",
  "node_id": "G_kwDOACz0htoAIGIxNzQ3NDMyMGE2MjlhZjM4MjU1YzBhNmVmYmM3MmI5",
  "git_pull_url": "https://gist.github.com/b17474320a629af38255c0a6efbc72b9.git",
  "git_push_url": "https://gist.github.com/b17474320a629af38255c0a6efbc72b9.git",
  "html_url": "https://gist.github.com/nalgeon/b17474320a629af38255c0a6efbc72b9",
  "files": {
    "README.md": {
      "filename": "README.md",
      "type": "text/markdown",
      "language": "Markdown",
      "raw_url": "https://gist.githubusercontent.com/nalgeon/b17474320a629af38255c0a6efbc72b9/raw/95975f3d0bac707ce4355dfc4a7955310d212fac/README.md",
      "size": 14,
      "truncated": false,
      "content": "¡Hola, mundo!"
    }
  },
  ...
}Теперь код приветствует всех на испанском 🇪🇸
Очень хорошо. Наконец, удалим пример:
DELETE https://api.github.com/gists/{gist_id}
accept: application/json
authorization: bearer {token}
У меня нет токена, покажите результат
HTTP/1.1 204 
x-accepted-oauth-scopes: 
x-github-media-type: github.v3
x-github-request-id: E8B5:8EDA:51E584:5273CC:64EE027F
x-oauth-scopes: gist
x-ratelimit-limit: 5000
x-ratelimit-remaining: 4996
x-ratelimit-reset: 1693323114
x-ratelimit-resource: core
x-ratelimit-used: 4HTTP-статус 204 No Content означает, что мы удалили пример, поэтому гитхаб больше ничего не может о нем сообщить. Немного жаль с ним расставаться, но всегда ведь можно создать новый, верно?
У Gists API есть и другие полезные возможности, но мы не будем их рассматривать. Вот функции, которые мы попробовали:
- Получить список примеров.
- Получить конкретный пример или конкретную версию примера.
- Создать новый пример.
- Изменить существующий пример.
- Удалить пример.
Теперь попробуйте управлять примерами через API! Вы всегда можете использовать эту статью как песочницу.
Реализация
Чтобы вызывать API прямо из документации (как в предыдущем разделе), понадобится JavaScript код, который делает вот что:
- Парсит описание HTTP-запроса.
- Вызывает API.
- Показывает результат.

Песочница поддерживает только небольшую часть возможностей HTTP, так что парсинг не слишком сложный:
// parse parses the request specification.
function parse(text) {
    const lines = text.split("\n");
    let lineIdx = 0;
    // parse method and URL
    const methodUrl = lines[0].split(" ").filter((s) => s);
    const [method, url] =
        methodUrl.length >= 2 ? methodUrl : ["GET", methodUrl[0]];
    lineIdx += 1;
    // parse headers
    const headers = {};
    for (let i = lineIdx; i < lines.length; i++) {
        const line = lines[i].trim();
        if (line === "") {
            break;
        }
        const [headerName, headerValue] = line.split(":");
        headers[headerName.trim()] = headerValue.trim();
        lineIdx += 1;
    }
    // parse body
    const body = lines.slice(lineIdx + 1).join("\n");
    return { method, url, headers, body };
}
const spec = parse(`GET https://httpbingo.org/uuid`);
console.log(JSON.stringify(spec, null, 2));
Вызов API и показ результатов и того проще — используем браузерное Fetch API и выводим ответ как текст:
// execCode sends an HTTP request according to the spec
// and returns the response as text with status, headers and body.
async function execCode(spec) {
    const resp = await sendRequest(spec);
    const text = await responseText(resp);
    return text;
}
// sendRequest sends an HTTP request according to the spec.
async function sendRequest(spec) {
    const options = {
        method: spec.method,
        headers: spec.headers,
        body: spec.body || undefined,
    };
    return await fetch(spec.url, options);
}
// responseText returns the response as text
// with status, headers and body.
async function responseText(resp) {
    const version = "HTTP/1.1";
    const text = await resp.text();
    const messages = [`${version} ${resp.status} ${resp.statusText}`];
    for (const hdr of resp.headers.entries()) {
        messages.push(`${hdr[0]}: ${hdr[1]}`);
    }
    if (text) {
        messages.push("", text);
    }
    return messages.join("\n");
}
const spec = {
    method: "GET",
    url: "https://httpbingo.org/uuid",
};
const text = await execCode(spec);
console.log(text);
Fetch API работает прямо в браузере, промежуточный сервер не требуется. Единственный нюанс: либо документация должна быть на том же домене, что API, либо API должно разрешать кросс-доменные запросы. Но даже если это не так, всегда можно проксировать запросы — это не так сложно.
Если хотите готовое решение — я написал небольшую библиотеку, которая поддерживает JavaScript и Fetch API песочницы:
Вообще я хотел бы, чтобы вся документация была интерактивной. Не только API-туториалы, но все от алгоритмов (hashing) до языков программирования (Go, Odin), баз данных (SQLite), фреймворков, утилит и даже отдельных пакетов.
И (наглая самореклама!) я сейчас разрабатываю платформу, которая дает именно это — встраиваемые песочницы для документации, онлайн-обучения и просто развлечения. Посмотрите, если интересно:
И попробуйте написать интерактивный туториал в следующий раз, когда будете разрабатывать API!
★ Подписывайтесь на новые заметки.