Интерактивная 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 запросы и ответы
После небольшой тренировки читать HTTP-сообщения легко и приятно.

HTTP-запрос состоит из трех секций:

  1. Строка запроса:
POST /anything/chat HTTP/1.1
  • Метод (POST) задает операцию, которую хочет выполнить клиент.
  • Путь (/anything/chat) задает URL запрошенного ресурса (без протокола, домена и порта).
  • Версия (HTTP/1.1) указывает версию HTTP-протокола.
  1. Заголовки запроса:
host: httpbingo.org
content-type: application/json
user-agent: curl/7.87.0

Каждый заголовок — пара вида «ключ-значение», которая сообщает серверу некоторую полезную информацию о запросе. В нашем случае это доменное имя сервера (httpbingo.org), тип содержимого (application/json) и самоидентификация клиента (user-agent).

  1. Тело запроса:
{
    "message": "Hello!"
}

Данные, которые клиент передает серверу.

HTTP-протокол не предусматривает отдельное сохранение состояния. Так что любое состояние должно передаваться прямо в заголовках или теле запроса.

HTTP-ответ тоже состоит из трех секций:

  1. Строка статуса:
HTTP/1.1 200 OK
  • Версия (HTTP/1.1) указывает версию HTTP-протокола.
  • Код статуса (200) сообщает, выполнился запрос успешно или нет, и почему (протокол предусматривает разные статусы для разных ситуаций).
  • Статусное сообщение — расшифровка кода статуса. В HTTP/2 не передается.
  1. Заголовки ответа:
date: Mon, 28 Aug 2023 07:51:49 GMT
content-type: application/json

Аналогично заголовкам запроса, сообщают клиенту полезную информацию об ответе сервера.

  1. Тело ответа:
{
    "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).

GitHub Gists
Gists пригодится, когда городить полноценный Git-репозиторий неохота.

Даже если вы не пользуетесь гитхабом, доступ к 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: 4

HTTP-статус 204 No Content означает, что мы удалили пример, поэтому гитхаб больше ничего не может о нем сообщить. Немного жаль с ним расставаться, но всегда ведь можно создать новый, верно?

У Gists API есть и другие полезные возможности, но мы не будем их рассматривать. Вот функции, которые мы попробовали:

  • Получить список примеров.
  • Получить конкретный пример или конкретную версию примера.
  • Создать новый пример.
  • Изменить существующий пример.
  • Удалить пример.

Теперь попробуйте управлять примерами через API! Вы всегда можете использовать эту статью как песочницу.

Реализация

Чтобы вызывать API прямо из документации (как в предыдущем разделе), понадобится JavaScript код, который делает вот что:

  1. Парсит описание HTTP-запроса.
  2. Вызывает API.
  3. Показывает результат.
Песочница Fetch 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 песочницы:

codapi-js

Вообще я хотел бы, чтобы вся документация была интерактивной. Не только API-туториалы, но все от алгоритмов (hashing) до языков программирования (Go, Odin), баз данных (SQLite), фреймворков, утилит и даже отдельных пакетов.

И (наглая самореклама!) я сейчас разрабатываю платформу, которая дает именно это — встраиваемые песочницы для документации, онлайн-обучения и просто развлечения. Посмотрите, если интересно:

codapi

И попробуйте написать интерактивный туториал в следующий раз, когда будете разрабатывать API!

Подписывайтесь на твитер, чтобы не пропустить новые заметки 🚀