Представьте ситуацию. Модель данных вашего веб-приложения начала принимать оформленный вид, и вам пора браться за публичный API. Вы знаете, что после релиза какие-либо существенные изменения в API будет сделать трудно, поэтому хотите максимально отладить все заранее. А в интернете как раз полным-полно советов по разработке API. Но поскольку какого-то стандарта, который подходит всем, не существует, во многих ситуациях вам придется выбирать, как лучше поступить. Какие форматы будет поддерживать API? Как будет производиться аутентификация? Должны ли у API быть версии?

Работая над API для Enchant (аналог Zendesk), я пытался отыскать прагматичные ответы на эти вопросы. Моя цель – сделать так, чтобы Enchant API было легко использовать, внедрять. Также он должен быть достаточно гибким, чтобы адаптироваться под наш собственный пользовательский интерфейс.

TL;DR:

Ключевые требования для API

Обсуждения разработки API в интернете обычно сводятся к академическим дискуссиям о субъективных интерпретациях нечетких стандартов. К реальной жизни все это не имеет никакого отношения. Мне такой подход кажется неправильным, и я даже не собираюсь его воспроизводить. В этой статье я опишу проверенные способы разработки практичного API для современных веб-приложений. И для начала я перечислю базовые требования, на которые я ориентировался при разработке Enchant API:

  • API должен использовать веб-стандарты там, где они имеют смысл
  • Он должен быть дружественным разработчику. У пользователя должна быть возможность перемещаться по API через адресную строку браузера
  • Он должен быть простым, интуитивно понятным и последовательно устроенным, чтобы его было не просто легко, но и приятно использовать
  • Он должен быть достаточно гибким, чтобы поддерживать большую часть пользовательского интерфейса Enchant
  • Он должен быть эффективным, но не в ущерб другим требованиям

API – это интерфейс разработчика. Как и в случае с любым другим интерфейсом тут важно все тщательно продумать, чтобы пользователю было комфортно!

Приведите наименования ресурсов и действий в соответствие с принципами REST

Можно смело утверждать, что принципы REST распространились очень широко. А впервые их сформулировал в 2000 году Рой Филдинг в пятой главе докторской диссертации о сетевых программных архитектурах.

Основным принципом REST является разделение API на логические ресурсы. Эти ресурсы управляются с помощью HTTP-запросов, где у каждого метода запроса (GET, POST, PUT, PATCH, DELETE) есть определенное значение.

Что можно назначить ресурсом? Ну, для начала выбирайте существительные (не глаголы!), которые наделены смыслом с точки зрения пользователя API. Несмотря на то, что внутренняя модель может быть аккуратно переложена на ресурсы, это не обязательно соответствие один к одному. Важно не разглашать детали реализации в API сверх необходимости! В Enchant мы используем такие существительные как ticket, user, group и др.

Когда вы определитесь с ресурсами, нужно решить, какие действия будут к ним применяться, и как они будут сопоставляться с API. Принципы REST дают возможность использовать стратегию CRUD с помощью HTTP-методов:

  • GET /tickets — Получить список тикетов
  • GET /tickets/12 — Получить определенный тикет
  • POST /tickets — Создать новый тикет
  • PUT /tickets/12 — Обновить тикет №12
  • PATCH /tickets/12 — Частично обновить тикет №12
  • DELETE /tickets/12 — Удалить тикет №12

REST замечателен тем, что вы пользуетесь всеми преимуществами HTTP-методов, чтобы применить обширные функциональные возможности только для одной точки входа /tickets. Нет никаких правил именования методов. У URL простая и понятная структура. REST FTW!

Как писать имя точки входа — в единственном или множественном числе? Здесь главное не усложнять. Ваш внутренний эксперт по грамматике будет настаивать, что неправильно описывать один ресурс с помощью множественного числа. Однако прагматический подход состоит в том, чтобы формат URL всегда был одинаковым. Поэтому используйте множественное число, и вы облегчите жизнь всем. Пользователям API не придется заморачиваться со сложными случаями множественного числа (person/people). А разработчикам будет просто легче реализовать такой сценарий (большинство современных фреймворков будут обрабатывать /tickets и /tickets/12 одним и тем же контроллером).

А как же быть с отношениями между ресурсами? Если отношение может существовать только в контексте другого ресурса, ориентируйтесь по принципам REST. Рассмотрим пример. Тикет в Enchant состоит из нескольких сообщений. Эти сообщения можно логическим образом проецировать на точку входа /tickets:

  • GET /tickets/12/messages — Получить список сообщений для тикета №12
  • GET /tickets/12/messages/5 — Получить сообщение №5 для тикета №12
  • POST /tickets/12/messages — Создать новое сообщение в тикете №12
  • PUT /tickets/12/messages/5 — Обновить сообщение №5 в тикете №12
  • PATCH /tickets/12/messages/5 — Частично обновить сообщение №5 в тикете №12
  • DELETE /tickets/12/messages/5 – Удалить сообщение №5 в тикете №12

В ином случае, если отношение может существовать независимо от ресурса, имеет смысл просто добавить идентификатор отношения в возвращаемое представление ресурса. И тогда пользователь API обратится к точке входа отношения. Однако, если отношение обычно запрашивается вместе с ресурсом, у API может быть функция, которая автоматически внедряет представление отношения и позволяет избежать второго обращения к API.

Что насчет действий, которые не попадают под стратегию CRUD? Тут возможна путаница. Существует несколько подходов:

  1. Измените действие таким образом, чтобы оно было представлено как поле ресурса. Это работает, если у действия отсутствуют параметры. Например, действие activate можно определить в виде булевого поля (activated) и обновить с помощью PATCH.
  2. В соответствии с принципами REST такие действия следует реализовать как вложенный ресурс. Например, в API GitHub можно отметить gist звездочкой c помощью метода PUT /gists/:id/star и снять звездочку с помощью DELETE /gists/:id/star.
  3. Иногда просто нет возможности грамотно перенести действие в структуру REST. Например, нет особого смысла применять поиск нескольких ресурсов к точке входа какого-то конкретного ресурса. В этом случае лучше всего воспользоваться /search, хоть это и не ресурс. Это нормально. Поступайте правильно с точки зрения пользователя API и убедитесь, что ваши действия чётко задокументированы, чтобы потом не было путаницы.

SSL всегда и везде

Всегда используйте SSL. Без исключений. Доступ к вашим API можно получить отовсюду, где есть интернет (например, в библиотеке, кафе или аэропорту). Не все эти способы подключения безопасны. Многие вообще не используют шифрования каналов передачи данных, давая возможность легко «подслушать» и использовать данные аутентификации.

Помимо безопасности у SSL есть и другое преимущество. Гарантированно зашифрованные сообщения упрощают аутентификацию: не нужно подписывать каждый запрос API, достаточно обычных токенов доступа.

Остерегаться нужно только доступа к URL-адресам API не по SSL. Не перенаправляйте такие запросы на SSL-аналоги. Вместо этого возвращайте сообщение об ошибке. Вам совершенно не нужно, чтобы плохо настроенные клиенты обращались к незашифрованной точке входа, чтобы потом «тихо» быть перенаправленными на версию с шифрованием.

Документация

API хорош только в том случае, если у него все в порядке с документацией. Документы должны лежать в открытом доступе, и их должно быть легко найти. Большинство разработчиков проверяют документы, прежде чем предпринимать попытки интеграции. Иногда документация спрятана в PDF-файле, иногда чтобы получить их, нужно логиниться. Не надо так: пользователь не только замучается, пока их получит, он ещё и вряд ли их найдёт.

В документах должны быть приведены примеры полных циклов запросов/ответов. Желательно, чтобы запросы были представлены в виде копируемых примеров. Это могут быть ссылки, которые можно вставить в браузер, либо примеры curl, которые можно вставить в терминал. Посмотрите, как хорошо это сделано у GitHub и Stripe.

После релиза публичного API вы обязуетесь не нарушать его работу без предупреждения. В документах должны содержаться графики устаревания и сведения об обновлениях API, которые влияют на работу пользователя. Обновления нужно публиковать в блоге (например, в журнале изменений) или рассылать по почте (желательно и так, и так!).

Управление версиями

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

Существуют разные мнения о том, где следует указывать версию API: в URL-адресе или в заголовке. Теоретически, наверное, в заголовке. Однако версия должна быть указана в URL-адресе, чтобы браузер имел доступ к ресурсам всех версий (см. требования для API в начале статьи).

Мне очень нравится, как нумеруют версии в Stripe. В URL содержится основной номер версии (v1). При этом у API есть привязанные к дате подверсии, которые можно выбрать с помощью настраиваемого заголовка запроса HTTP. В этом случае основная версия обеспечивает структурную стабильность API в целом, а в подверсиях учтены небольшие изменения (удаление устаревших полей, изменения конечных точек и т. д.).

API не может совсем не меняться. Перемены неизбежны. Главное правильно с ними справляться. Многим API может быть полезно объявлять графики устаревания на много месяцев вперед. Всё зависит от того, что необходимо в каждом случае с учётом отрасли и возможных потребителей API.

Фильтрация результатов, сортировка и поиск

Старайтесь, чтобы URL-адреса базовых ресурсов были как можно более лаконичными. Сложные фильтры, требования к сортировке и расширенный поиск (если он ограничен одним типом ресурса) могут быть легко реализованы как параметры запроса поверх основного URL. Давайте рассмотрим подробнее:

Фильтрация: Используйте уникальный параметр запроса для каждого поля, которое осуществляют фильтрацию. Например, при запросе списка тикетов из точки входа /tickets вы можете ограничить поиск только тикетами в открытом состоянии. Это можно осуществить с помощью вот такого запроса: GET /tickets?state=open. Здесь state — это параметр запроса, по которому осуществляется фильтрация.

Сортировка: По аналогии с фильтрацией для описания правил сортировки можно использовать универсальный параметр sort. Чтобы установить несколько условий для сортировки, сделайте так, чтобы параметр сортировки принимал список полей, перечисленных через запятую. Перед наименованием параметра можно вставить символ «минус», чтобы сортировка производилась по убыванию. Давайте рассмотрим несколько примеров:

  • GET /tickets?sort=-priority — Получить список тикетов в порядке убывания приоритета;
  • GET /tickets?sort=-priority,created_at — Получить список тикетов в порядке убывания приоритета. В рамках определенного приоритета сначала показываются старые тикеты.

Поиск: Иногда базовых фильтров недостаточно, и вам нужна вся мощь полнотекстового поиска. Возможно, вы уже используете ElasticSearch или другую технологию поиска на основе Lucene. Когда полнотекстовый поиск используется, чтобы получить ресурсы определенного типа, он может отображаться в API в качестве параметра запроса на точке входа ресурса. Допустим, q. Поисковые запросы должны направляться прямо в поисковик, и результаты поиска в API должны отображаться как обычный список.

Объединив всё вместе, мы можем строить запросы так:

  • GET /tickets?sort=-updated_at — Получить недавно обновленные тикеты;
  • GET /tickets?state=closed&sort=-updated_at — Получить недавно закрытые тикеты;
  • GET /tickets?q=return&state=open&sort=-priority,created_at — Извлечь открытые тикеты с наивысшим приоритетом, в которых упоминается слово «return».

Псевдонимы для часто используемых запросов. Чтобы сделать работу в API более приятной для среднего пользователя, организуйте наборы условий поиска в простые пути REST. Например, запрос недавно закрытых тикетов можно оформить так: GET /tickets/recently_closed

Ограничения полей

Пользователю API не всегда требуется полное представление ресурса. Возможность выбирать, какие поля будут возвращаться, помогает пользователю минимизировать сетевой трафик и ускорить работу в API.

Используйте параметр запроса fields, который включает список полей через запятую. Например, приведенного запроса достаточно, чтобы получить отсортированный список открытых тикетов: GET /tickets?fields=id,subject,customer_name,updated_at&state=open&sort=-updated_at

При обновлении и создании ресурса API должен возвращать представление

Методы PUT, POST или PATCH могут вносить изменения в поля базового ресурса, которые не были частью предоставленных параметров (например, отметки времени created_at и updated_at). Чтобы пользователю не пришлось повторно обращаться к API за обновленным представлением, сделайте так, чтобы обновленное (или созданное) представление возвращалось как часть ответа API.

Если в результате метода POST было создан ресурс, используйте код HTTP 201 и добавьте заголовок Location, в котором указывается URL нового ресурса.

Нужен ли вам HATEOAS?

Существует много разных мнений насчет того, должен ли пользователь API самостоятельно создавать ссылки, или все URL должны уже присутствовать в API. Согласно принципам REST, подход HATEOAS заключается в том, что взаимодействие с точкой входа определяется метаданными в представлении ресурса, а не при помощи сторонних данных.

Несмотря на то, что в Интернет в основном используются принципы, аналогичные HATEOAS (мы заходим на главную страницу сайта и оттуда следуем по предложенным ссылкам), я не думаю, что настало время перейти на HATEOAS в API. Когда мы просматриваем веб-страницу, решения о том, какие ссылки кликать дальше, мы принимаем в тот же самый момент. Однако в случае API это не так. Решения о том, какие запросы будут выполняться, принимаются, когда создается код для интеграции API. Можно ли отложить эти решения до момента их выполнения? Конечно. Но это невыгодный путь, так как код все равно не сможет справляться с существенными изменениями API без повреждений. Тем не менее, я думаю, что HATEOAS – это многообещающий подход, просто его время пока не пришло. Необходимо приложить дополнительные усилия, чтобы определить стандарты и инструментарий вокруг принципов HATEOAS, чтобы его потенциал был полностью реализован.

А пока что лучший вариант – предположить, что у пользователя есть доступ к документации, и поместить в представления идентификаторы ресурсов, с помощью которых пользователь будет создавать ссылки. У идентификаторов есть несколько преимуществ: к минимуму сводятся как данные, передаваемые по сети, так и данные, которые хранятся у пользователей API (так как это небольшие идентификаторы, а не целые ссылки, в которых содержатся идентификаторы).

Кроме того, раз уж эта статья призывает указывать номера версий в URL, в долгосрочном плане для пользователя более логично сохранять идентификаторы ресурсов, а не URL-адреса. Ведь идентификатор один и тот же в разных версиях, а представляющий его URL – нет!

Для ответов используйте только JSON

Пришла пора забыть про XML в API. Он многословный, его трудно разбирать, его модель данных не совместима с моделями большинства языков программирования, а возможности XML по расширяемости не имеют особого значения, потому что для выходного представления в первую очередь требуется сериализация от внутреннего представления.

Я не стану пускаться в пространные объяснения этой мысли, так как другие компании (YouTube, Twitter & Box) уже написали о расставании с XML.

Я просто оставлю здесь вот этот график Google Trends (XML API vs JSON API). Смотрите сами:

График Google Trends (XML API vs JSON API)

Однако, если в клиентской базе много корпоративных клиентов, поддержка XML может понадобиться. И тогда встает новый вопрос:

Тип данных должен изменяться с помощью заголовков Accept или через URL? Чтобы обеспечить навигацию через браузер, выбирайте URL. Наиболее разумно будет добавить расширение .json или .xml к URL-адресу точки входа.

Имена полей: snake_case или camelCase?

Если вы используете JSON (JavaScript Object Notation) в качестве основного формата представления, «правильно» будет следовать принципам наименования, принятым в JS. А это значит, что в именах полей нужно использовать camelCase! Если потом вы будете создавать клиентские библиотеки на разных языках программирования, следует придерживаться принятого в них идиоматического стиля. А это значит, что в C# и Java названия пишутся регистром camelCase, а в python и ruby используется snake_case.

Пища для размышлений: я всегда считал, что snake_case легче читать, чем принятый в JavaScript camelCase. Однако до недавнего времени у меня не было никаких доказательств, подтверждающих мои впечатления. Однако в 2010 году провели исследование, в котором отслеживалось движение глаз при чтении camelCase и snake_case (PDF). И выяснилось, что snake_case читается на 20% легче, чем camelCase! А от удобочитаемости зависят удобство поиска по API и восприятие примеров в документации.

Во многих популярных JSON API используется snake_case. Подозреваю, что это происходит потому, что библиотеки сериализации следуют правилам наименования используемого ими языка. Возможно, нужно, чтобы библиотеки сериализации JSON учитывали изменения правил наименования.

Pretty print по-умолчанию и gzip

Когда API возвращает данные без пробелов, в браузере это выглядит не очень приятно. Несмотря на то, что с помощью некоторых параметров запроса (например, ?pretty=true) можно включить pretty print форматирование, намного удобнее использовать pretty print по умолчанию. Издержки, вызванные дополнительной передачей данных, почти незаметны, особенно если сравнивать с тем, что бывает, если не проводить сжатие gzip.

Какая от этого польза на практике? Допустим, пользователь API занимался отладкой и с помощью кода вывел на экран данные, полученные от API. Они по умолчанию будут читаемыми. Или, например, пользователь взял ссылку, которую сгенерировал код, и перешел по ней непосредственно в браузере. Данные от API тоже будут читаемыми. Это, конечно, мелочи. Которые и составляют удовольствие от работы с API!

Но что насчет дополнительной передачи данных?

Давайте рассмотрим реальный пример. Я взял данные из API GitHub, где по умолчанию используется pretty print. И сравнил размеры файлов с пробелами и без, а также в сжатом с помощью gzip виде.

$ curl https://api.github.com/users/veesahni > with-whitespace.txt
$ ruby -r json -e 'puts JSON JSON.parse(STDIN.read)' < with-whitespace.txt > without-whitespace.txt
$ gzip -c with-whitespace.txt > with-whitespace.txt.gz
$ gzip -c without-whitespace.txt > without-whitespace.txt.gz

Размеры полученных файлов:

  • without-whitespace.txt — 1 252 байта
  • with-whitespace.txt — 1 369 байтов
  • without-whitespace.txt.gz — 496 байтов
  • with-whitespace.txt.gz — 509 байтов

В этом примере пробелы увеличили размер выходных данных на 8,5% без gzip и на 2,6% после сжатия gzip. А вот сжатие с gzip более чем на 60% снизило загрузку сети. Поскольку у pretty print довольно незначительные издержки, лучше пользоваться этим методом по умолчанию и не забывать включать сжатие gzip!

Подкрепим эту мысль еще одним примером. Твиттер обнаружил, что при сжатии gzip в Streaming API экономия трафика (в некоторых случаях) достигала 80%. А Stack Exchange вообще возвращает только сжатые ответы!

Не используйте обёртки по умолчанию, но сохраните такую возможность

Многие API помещают ответы в обёртки:

{
  "data" : {
    "id" : 123,
    "name" : "John"
  }
}

У этого есть несколько объяснений. Так проще добавлять дополнительные метаданные или сведения о разбиении на страницы. Некоторые REST клиенты не позволяют посмотреть HTTP-заголовки, а запросы JSONP вообще не имеют к ним доступ. Однако поскольку все активно переходят на использование CORS или используют заголовок Link из RFC 5988, необходимость в обёртках отпадает.

Давайте же подготовим API к реалиям будущего и не будем использовать обёртки по умолчанию (но сохраним эту опцию для исключительных случаев).

Что это за исключительные случаи? Обёртки действительно необходимы в двух сценариях: если API нужно поддерживать междоменные запросы с помощью JSONP или если клиент не работает с HTTP-заголовками.

Запросы JSONP поступают с дополнительным параметром запроса (обычно он называется callback или jsonp), отображающим имя функции обратного вызова. Если этот параметр присутствует, API должен переключаться на полноценный режим конверта, когда API всегда отвечает кодом состояния HTTP 200 и передает реальный код состояния в JSON. Любые дополнительные заголовки HTTP, которые были бы переданы вместе с ответом, должны быть вставлены в поля JSON:

callback_function({
    status_code: 200,
    next_page: "https://..",
    response: {
        /* ... actual JSON response body ... */
    }
})

Аналогично, для поддержки ограниченных HTTP-клиентов можно использовать специальный параметр запроса ?envelope=true, который вызывает полное заключение в обёртку (без использования функции обратного вызова в JSONP).

JSON для запросов POST, PUT и PATCH

Если вы следуете подходу, описанному в этой статье, то вы перешли на JSON для всех представлений данных, отдаваемых API. Рассмотрим JSON для входящих данных API.

Многие API используют кодировку URL в телах запросов. Кодировка URL – это именно то, что вы подумали. В телах запросов ключевые пары значений кодируются по тем же правилам, которые используются для кодирования данных в параметрах запроса URL. Это просто, очень распространено и эффективно.

Однако у кодировки URL есть несколько сомнительных моментов. При такой кодировке не различаются типы данных. В итоге API приходится вычленять целые числа и булевые переменные из строк. Кроме того, кодировка URL не поддерживает иерархическую структуру. Несмотря на то, что существуют правила, с помощью которых можно выстроить структуру из ключевых пар значений (например, добавить [] к ключу для обозначения массива), это не идет ни в какое сравнение с собственной иерархической структурой JSON.

Если у вас простой API, кодировки URL может быть достаточно. Однако для входных данных сложных API лучше используйте JSON. В любом случае выбирать надо какой-то один вариант и последовательно его придерживаться.

Если API принимает JSON в запросах POST, PUT и PATCH, то заголовке Content-Type должно быть указано application/json, иначе необходимо возвращать код 415 Unsupported Media Type.

Постраничный вывод

Если в API используются конверты, то информация о разбивке на страницы обычно в них и находится. Я не виню разработчиков: до недавнего времени это был один из лучших вариантов. Однако сегодня правильнее всего указывать данные о разбивке на страницы с помощью заголовка Link, предложенного RFC 5988.

API, в которых используется заголовок Link, возвращает готовые ссылки, и пользователю не приходится конструировать их самостоятельно. Это особенно важно, если разбивка на страницы привязана к курсору. Посмотрите, как заголовок Link использует GitHub: Link: <https://api.github.com/user/repos?page=3&per_page=100>; rel="next", <https://api.github.com/user/repos?page=50&per_page=100>; rel="last"

Но это не окончательное решение, так как многие API возвращают дополнительные сведения о разбивке страниц, например, количество доступных результатов. Для отправки этого значения можно использовать специально созданный HTTP-заголовок, например X-Total-Count.

Автозагрузка связанных представлений

Нередко пользователю API необходимо загрузить данные, связанные с запрашиваемым ресурсом. Вместо того, чтобы многократно обращаться API для получения этой информации, было бы намного эффективнее, если бы нужные данные по требованию пользователя возвращались или загружались вместе с изначально запрошенным ресурсом.

Однако это противоречит некоторым принципам REST (Internet Archive). Чтобы свести к минимуму отклонение от принципов, вышеописанные действия можно совершать только на основе параметра запроса embed (или expand).

В нашем случае embed — это разделенный запятыми список полей, которые нужно встроить. Для ссылки на подполя можно использовать точечную нотацию. Например:

GET /tickets/12?embed=customer.name,assigned_user

Этот метод возвращает тикет со встроенными дополнительными подробностями, например:

{
  "id" : 12,
  "subject" : "I have a question!",
  "summary" : "Hi, ....",
  "customer" : {
    "name" : "Bob"
  },
  "assigned_user": {
    "id" : 42,
    "name" : "Jim"
  }
}

Разумеется, возможность реализовать что-то подобное зависит от внутренней сложности API. Такой тип встраивания может легко привести к возникновению проблемы SELECT N+1.

Переопределение HTTP-метода

Некоторые HTTP-клиенты могут работать только с простыми запросами GET и POST. Чтобы расширить возможности для этих клиентов, API нужно переопределять HTTP-метод. Хотя жестких стандартов тут нет, наиболее популярный ход – принимать заголовок запроса X-HTTP-Method-Override со строковым значением, содержащим PUT, PATCH или DELETE.

Обратите внимание, что заголовок переопределения должен приниматься только в запросах POST. GET-запросы никогда не должны изменять данные на сервере!

Ограничение частоты запросов

Чтобы предотвратить злоупотребления, принято устанавливать ограничение частоты запросов в API. В RFC 6585 для этих целей впервые был использован код состояния 429 Too Many Requests.

Тем не менее, может быть очень полезно уведомлять пользователей о лимитах до того, как они их достигнут. Четких правил тут пока нет, однако обычно этот вопрос решают с помощью заголовков ответа HTTP.

Как минимум, используйте следующие заголовки (прописные буквы обычно не ставятся в середине заголовка, но тут использованы правила именования Twitter):

  • X-Rate-Limit-Limit — число разрешенных запросов за текущий период
  • X-Rate-Limit-Remaining — число оставшихся запросов в текущем периоде
  • X-Rate-Limit-Reset — сколько секунд осталось в текущем периоде

Почему для X-Rate-Limit-Reset используется количество секунд, а не отметка времени? Отметка времени содержит все виды полезных, но ненужных сведений (например, дата или часовой пояс). Пользователь API просто хочет знать, когда можно отправить запрос снова. Количество секунд – лучший ответ на этот вопрос, который требует минимальной обработки со стороны пользователя. Еще он помогает избежать проблем из-за рассинхронизации часов.

В некоторых API используются отметка времени UNIX (количество секунд с 1 января 1970 года) для X-Rate-Limit-Reset. Не делайте так!

Почему неверно использовать отметку времени Unix для X-Rate-Limit-Reset? В спецификации HTTP указано, что необходимо использовать формат даты RFC 1123 (он используется в HTTP-заголовках Date, If-Modified-Since и Last-Modified). Если необходимо указать новый заголовок HTTP, который использует какую-то отметку времени, он должен соответствовать RFC 1123, а не тому, что принято в Unix.

Аутентификация

У API по принципам REST не должно быть состояния. Это означает, что аутентификация запроса не должна зависеть от файлов cookie или сеансов. Вместо этого каждый запрос должен иметь учетные данные для проверки подлинности.

Благодаря SSL данные аутентификации можно упростить до случайно генерируемого токена доступа, который передаётся в поле «имя пользователя» при использовании метода HTTP Basic Auth. Этот способ замечателен тем, что всё делается в браузере. Браузер просто выведет всплывающее окно с запросом учетных данных, если он получит от сервера код состояния 401 Unauthorized.

Однако, такой метод проверки подлинности с помощью токена допустим только в случаях, когда пользователь имеет возможность скопировать токен из интерфейса администрирования в пользовательскую среду API. Когда это невозможно, необходимо использовать OAuth2 для безопасной передачи токена третьей стороне. В OAuth2 используются токены bearer, а основное шифрование передачи осуществляется с помощью SSL.

Если API поддерживает JSONP, потребуется третий метод аутентификации, поскольку запросы JSONP не могут отправлять учетные данные методом HTTP Basic Auth или токены bearer. В этом случае можно использовать специальный параметр запроса access_token. Примечание: при использовании параметра для передачи токена возникает внутренняя проблема безопасности, поскольку большинство веб-серверов хранят параметры запросов в логах сервера.

Как бы то ни было, все три описанных подхода — это просто способы передать токен через границу API. Сам токен может быть один и тот же.

Кэширование

В HTTP встроена служба кэширования! Все, что вам нужно сделать, это включить дополнительные исходящие заголовки ответа и делать небольшую проверку, когда вы получаете входящие заголовки запроса.

Существует два подхода: ETag и Last-Modified.

ETag: При генерации запроса включите в него HTTP-заголовок ETag, содержащий хэш или контрольную сумму представления. Это значение должно меняться всякий раз, когда изменяется представление. Однако, если входящие HTTP-запросы содержат заголовок If-None-Match с соответствующим значением ETag, API должен вернуть код состояния 304 Not Modified вместо представления ресурса.

Last-Modified: Похоже на ETag, но используются отметки времени. Заголовок ответа Last-Modified содержит отметку времени в формате RFC 1123, которые проверяется с помощью If-Modified-Since. Обратите внимание, что в спецификации HTTP указаны 3 допустимых формата дат, и сервер должен принимать любой из них.

Ошибки

Подобно тому, как страница с ошибкой HTML показывает полезное сообщение об ошибке посетителю, API должен выдавать полезное сообщение об ошибке в известном и удобном формате. Представление ошибки ничем не должно отличаться от представления любого другого ресурса, только с собственным набором полей.

API всегда должен возвращать правильные коды состояния HTTP. Ошибки API обычно бывают двух типов: коды состояния класса 400 для проблем с клиентом и коды состояния класса 500 для проблем с сервером. Как минимум сделайте так, чтобы API стандартно сопровождал ошибки класса 400 понятным представлением ошибки в формате JSON. По возможности это должно распространяться и на ошибки класса 500 (если балансировщики нагрузки и обратные прокси сервера могут создавать собственные сообщения об ошибках).

В теле ошибки JSON разработчику должно сообщаться несколько вещей: полезное сообщение об ошибке, уникальный код ошибки (который можно найти в документации) и по возможности подробное описание. Выходное представление JSON в таком случае выглядит следующим образом:

{
  "code" : 1234,
  "message" : "Something bad happened :(",
  "description" : "More details about the error here"
}

Ошибки валидации для запросов PUT, PATCH и POST требуют разбивки полей. Лучше всего сначала указать фиксированный код ошибки верхнего уровня, используемый для сбоев валидации, а ниже привести подробные ошибки в дополнительных полях errors. Вот так:

{
  "code" : 1024,
  "message" : "Validation Failed",
  "errors" : [
    {
      "code" : 5432,
      "field" : "first_name",
      "message" : "First name cannot have fancy characters"
    },
    {
      "code" : 5622,
      "field" : "password",
      "message" : "Password cannot be blank"
    }
  ]
}

Коды состояния HTTP

API может возвращать массу информативных кодов состояния, описанных протоколом HTTP. Эти коды помогают пользователям API правильно направлять ответы. Я составил краткий список кодов, без которых не обойтись:

  • 200 OK — запрос GET, PUT, PATCH или DELETE выполнен успешно. Также этот код можно использовать для запросов POST, которые не создают ресурс;
  • 201 Created — запрос POST привел к созданию ресурса. Сюда следует добавить заголовок Location, в котором указывается расположение нового ресурса;
  • 204 No Content — запрос успешно обработан, но не нужно возвращать какие либо данные в теле ответа (например, запрос DELETE);
  • 304 Not Modified — такой ответ возвращается, когда используются заголовки кеширования;
  • 400 Bad Request — недопустимый запрос, например, невозможно провести синтаксический анализ (парсинг) тела запроса;
  • 401 Unauthorized — аутентификационные данные не предоставлены или предоставлены не полностью. Также этот код полезен для запуска всплывающего окна с запросом учетных данных, если API используется в браузере;
  • 403 Forbidden — Аутентификация прошла успешно, но у пользователя нет доступа к ресурсу;
  • 404 Not Found — запрашивается несуществующий ресурс;
  • 405 Method Not Allowed — запрашиваемый HTTP-метод не разрешен для прошедшего аутентификацию пользователя;
  • 410 Gone — ресурс в этой точке входа больше недоступен. Можно использовать как универсальный ответ на запросы, адресованные старым версиям API;
  • 415 Unsupported Media Type — в теле запроса содержится неподдерживаемый тип данных;
  • 422 Unprocessable Entity — используется в случае ошибок валидации;
  • 429 Too Many Requests — запрос отклонен из-за ограничения скорости.

Подведем итог

API — это пользовательский интерфейс для разработчиков. Постарайтесь сделать его не только функциональным, но и приятным в использовании.

Перевод статьи «Best Practices for Designing a Pragmatic RESTful API» by Vinay Sahni