Интерактивная 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: 4
HTTP-статус 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!
★ Подписывайтесь на новые заметки.