API Design Guide
Общие правила
- Для реализации API используется архитектурный стиль REST.
- Форматом передачи данных ДОЛЖЕН быть json.
- Название ресурса в эндпоинте ДОЛЖНО быть во множественном числе (POST /v1/users, а не POST /v1/user), за исключением тех случаев когда ресурс может существовать только в единственном числе (/v1/profile).
- Название ресурса в эндпоинте ДОЛЖНО быть в kebab-case.
- Параметры в query и поля в body ДОЛЖНЫ быть в snake_case.
- Идентификатор версии API всегда ДОЛЖЕН присутствовать в урле, например POST /v1/users.
- Несуществующие страницы API ДОЛЖНЫ отдавать 404 ошибку и json ответ соответствующий формату описанному в разделе “Формат ответа” c
code: "NotFoundHttpException"
.
Формат полей
- Все целочисленные идентификаторы сущностей ДОЛЖНЫ иметь тип integer.
- Datetime поля должны передаваться строкой в формате ISO-8601 в UTC. Например
“updated\_at”: “2020-01-01T15:47:21.000000Z”
В OpenApi такое поле описывается какtype: string, format: date-time
. - Даты (без времени) должны передаваться строкой в формате ISO-8601 full date.
Например
“birhtday”: “1990-01-25”
В OpenApi такое поле описывается какtype: string, format: date
. - Цены должны передаваться в копейках, с типом integer.
Формат ответа
Тело ответа ДОЛЖНО содержать только следующие поля:
data - основной объект ответа, может иметь следующие типы:
- null;
- object - в случае, если запрашивается одна сущность, например, запрос по id;
- array - в случае, если запрашивается список сущностей, каждый элемент массива представляет собой отдельную конкретную сущность.
errors - необязательный массив ошибок запроса, каждый элемент в массиве содержит:
- code - обязательный строковый код ошибки, скорее всего берется из фиксированного списка. В документации к api ДОЛЖЕН присутствовать enum, в котором перечислены все коды ошибок;
- message - обязательное строковое описание ошибки;
- meta - опциональный объект с дополнительными метаданными ошибки.
meta - необязательный объект с дополнительной информацией о запросе, например для передачи отладочной информации или информации для пагинации. Каждый компонент, который хочет добавить данные в этот объект, должен добавлять их через дополнительное поле, например: meta.pagination.* В корень объекта meta нельзя добавлять информацию для исключения конфликта в названии данных разных компонентов.
Концепция построения API
Back-сервисы
Внутренние api следует проектировать в разрезе ресурсов.
Стандартные методы
Стандартные методы позволяют реализовать CRUD функциональность для ресурса, которой достаточно в большом числе случаев. API не обязано реализовывать все стандартные методы для всех ресурсов.
Метод | HTTP реализация | Цель |
---|---|---|
Get | GET <resource-url>/<id> | Получение объекта по id |
Create | POST <resource-url> | Создание объекта |
Replace | PUT <resource-url>/<id> | Обновление всех полей объекта |
Patch | PATCH <resource-url>/<id> | Обновление указанных полей объекта |
Delete | DELETE <resource-url>/<id> | Удаление объекта |
Search | POST <resource-url> :search | Поиск объектов по фильтрам |
SearchOne | POST <resource-url> :search-one | Быстрый поиск одного объекта по фильтру |
где <resource-url>
например /api/v1/users.
Стандартные методы: Get
Формат запроса:
GET /api/v1/users/17?include=addresses,loyality\_cards
параметр include является опциональным
Формат ответа:
{
"data": {
"id": 17,
"name": "John Doe",
"last_login_at": "2020-01-01T15:47:21.000000Z",
"addresses": [
{
"id": 1,
// ...
}
],
"loyality_cards": [
{
"id": 1,
// ...
}
],
}
}
Стандартные методы: Create
Формат запроса:
POST /api/v1/users
{
"name": "John Doe",
"last_login_at": "2020-01-01T15:47:21.000000Z"
}
Формат ответа:
{
"data": {
"id": 1006779,
"name": "John Doe",
"last_login_at": "2020-01-01T15:47:21.000000Z"
}
}
Объект в data полностью соответствует объекту в методе Get без параметров.
Стандартные методы: Replace
Формат запроса:
PUT /api/v1/users/1006779
{
"name": "John Doe",
"last_login_at": null
}
Запрос ДОЛЖЕН являться идемпотентным. Таким образом в data могут отсутствовать только необязательные поля, которые в этом случае будут сброшены до значения по умолчанию. Поле id из data ДОЛЖНО игнорироваться.
Формат ответа:
{
"data": {
"id": 1006779,
"name": "John Doe",
"last_login_at": null
}
}
Объект в data полностью соответствует объекту в методе Get без параметров.
Стандартные методы: Patch
Формат запроса:
PATCH /api/v1/users/1006779
{
"name": "John Doe"
}
Только поля указанные в data ДОЛЖНЫ быть изменены. Поле id из data ДОЛЖНО игнорироваться.
Формат ответа:
{
"data": {
"id": 1006779,
"name": "John Doe",
"last_login_at": "2020-01-01T15:47:21.000000Z"
}
}
Объект в data полностью соответствует объекту в методе Get без параметров.
Стандартные методы: Delete
Формат запроса:
DELETE /api/v1/users/1006779
Формат ответа:
{
"data": null
}
Если объект уже был удален до этого это не должно приводить к 404 ошибке. Аналогичным образом должны вести себя дополнительные “удаляющие методы”. Например, удаление файла привязанного к объекту.
Стандартные методы: Search
Формат запроса:
POST /api/v1/users:search
{
"sort": [
"-last_login_at", // по убыванию last_login_at
"id"
],
"filter": {
"id": [
12125,
1006779
],
"last_login_gte": "2020-01-01T15:47:21.000000Z"
},
"include": [
"roles",
"loyality_cards",
"orders_count"
],
"pagination": {
// ...
}
}
Все поля являются опциональными.
Подробнее про фильтры можно почитать в разделе посвященным им
Подмножество экземпляров ресурса отфильтрованных сложным образом можно также выносить в отдельный ресурс и отдельный endpoint для него соответственно.
Запрос использует POST чтобы избежать некоторых ограничений, связанных с реализацией параметров в GET запросах как в самом протоколе HTTP, так и в OpenApi генераторах.
Возможные форматы пагинации в запросе и ответе описаны в разделе посвященном пагинации.
Формат ответа:
{
"data": [
{
"id": 1006779,
"name": "John Doe",
"last_login_at": "2020-01-01T15:47:21.000000Z",
"roles": [
{
"id": 1,
"code": "admin"
}
]
}
],
"meta": {
"pagination": {
...
}
}
}
Стандартные методы: SearchOne
Формат запроса: полностью совпадает с форматом метода Search
Формат ответа:
{
"data": {
"id": 1006779,
"name": "John Doe",
"last_login_at": "2020-01-01T15:47:21.000000Z"
}
}
Объект в data полностью соответствует объекту в методе Get без параметров.
Дополнительные методы
При проектировании API СЛЕДУЕТ стараться ограничиваться стандартными методами, которых обычно достаточно для большинства задач, однако при необходимости можно добавлять и дополнительные.
Пользовательские методы всегда ДОЛЖНЫ использовать POST и образовываются добавлением названия метода к <resource url>
через двоеточие.
Форматы тела запроса и ответа не регламентируются за исключением общих правил для формата ответа, описанных в начале этого руководства.
В любом случае СЛЕДУЕТ проектировать их похожими на стандартные методы.
Пример запроса ко всему ресурсу:
POST /api/v1/users:mass-delete
{
"id": [1, 2, 3]
}
Пример ответа:
{
"data": null
}
Пример запроса к элементу ресурса:
POST /api/v1/offer-certificates/5:upload-file
Пример ответа:
{
"data": {
"url": "https://.../offers/certs/25/c5/test.pdf",
"path": "certs/25/c5/test.pdf",
"disk_type": "public"
}
}
Фильтры
Методы Search, SearchOne а также дополнительные методы могут использовать фильтры для получения отфильтрованной выборки.
Все фильтры ДОЛЖНЫ передаваться в одном объекте filter, пример:
"filter": {
"active": true,
"id": [1, 2],
"last_login_at_gte": "2020-01-01T15:47:21.000000Z"
},
Каждый пара “ключ : значение” в объекте filter является отдельным фильтром. Ключ - название фильтра. Значение - значение/значения фильтра.
Рекомендации по трактовке названий и значений фильтров при проектировании и реализации API:
- Если в названии фильтра нет префикса-модификатора или суффикса-модификатора, то при применении фильтра СЛЕДУЕТ использоваться оператор РАВЕНСТВА ( = ). Однако, за фильтром может стоять и более сложная логика. Например, в примере выше "active": true может в реализации API транслироваться во что-то вроде is_active = true AND active_to <= now()
- При проектировании фильтра РЕКОМЕНДУЕТСЯ заложить в него поддержку не только одного значения, но и массива однородных значений. Если передан такой массив, то все значения ДОЛЖНЫ быть объединены через ИЛИ (OR). В примере выше мы оставляем в выборке записи с id = 1 OR id = 2
- Если при проектировании API появилась потребность в фильтре за которым стоит логическая операция отличная от равенства, то необходимо создать НОВЫЙ фильтр, закодировав логическую операцию в названии в качестве суффикса или префикса. Подробнее об этом чуть ниже.
Все фильтры в объекте filter ДОЛЖНЫ объединяться через логический оператор И (AND)
Модификаторы фильтров
Создав новый фильтр, добавив к его названию фильтра суффикс или префикс-модификатор, мы можем изменить его логическую операцию.
Например, в большинстве случаев фильтр last_login_at >= x намного полезнее чем last_login_at = x
Пример фильтра с модификатором:
{
// ...
"filter": {
"last_login_at_gte": "2020-01-01T15:47:21.000000Z"
},
// ...
}
Список модификаторов:
Модификатор | Описание | Поддерживает массив однородных значений? |
---|---|---|
*_gt | Значение поля больше чем переданное | - |
*_gte | Значение поля больше или равно чем переданное | - |
*_lt | Значение поля меньше чем переданное | - |
*_lte | Значение поля меньше или равно чем переданное | - |
*_not | Значение поля не равно переданному. Если передан массив значение, то не равен ни одному из значений | + |
*_and | При передаче массива однородных значений фильтровать используя оператор И (AND) вместо ИЛИ (OR) | + |
*_like | db_field like '%sent_value%' | + |
*_llike | db_field like '%sent_value' | + |
*_rlike | db_field like 'sent_value%' | + |
*_regex | Использовать переданное в фильтр значение-строку как регулярное выражение | + |
has_* | has_props: true - у экземпляра сущности есть хотя бы один связанный экземпляр сущности prop has_props: false - у экземпляра сущности строго 0 связанных экземпляров сущности prop | - |
*_count | props_count: 5 - у экземпляра строго 5 связанных экземпляров сущности prop | + |
При желании модификаторы можно комбинировать, например props_count_gte: 5
API не обязано реализовывать все фильтры-модификаторы для всех полей. В большинстве случаев это лишено смысла.
Зарезервированные фильтры
Зарезервированные фильтры - фиксированные форматы названий и значений фильтров общего назначения
Название фильтра | Описание | Поддерживает массив однородных значений? |
---|---|---|
id | Фильтр по первичному ключу. Может быть как целочисленный, так и строковый в зависимости от типа первичного ключа. | + |
trashed | Должны ли в выборку попасть экземпляры удаленные через мягкое удаление: - with - в выборке и удаленные и нет; - only - в выборке только удаленные; Если фильтр не передан или вообще не реализован, то в выборке ДОЛЖНЫ быть только неудаленные экземпляры | - |
query | Строка, содержащая запрос на DSL БД, лежащей в основе реализуемого API. Например query:"title:red chair AND price:[10 TO 100]" | - |
API не обязано реализовывать все зарезервированные фильтры, но если они реализуются, то они ДОЛЖНЫ быть спроектированы именно так или названы иначе.
Сложные нестандартные фильтры
Могут возникнут случаи когда необходима фильтрация, которую не получается реализовать используя описанные выше подходы.
Например, необходимо получить список ценнейших покупателей с суммой покупок за год более 10т.р ИЛИ которые являются нашими клиентами более 5 лет.
Возможные варианты решения подобной задачи
- Реализовать сложные фильтр наподобие annual_sum_gt_or_registered_years: [10000, 5];
- Послать 2 запроса в API, использующие имеющиеся простые фильтры, а затем склеить их результаты в потребителе. Однако в этом подходе мы лишаемся нормальной пагинации;
- Перенести данную логику внутрь API и скрыть за простым фильтром наподобие vip: true;
Как видите реализовать сложные фильтры принимающие массив неоднородных значений технически вполне возможно.
Однако, зачастую потребность в таком фильтре, говорит о том, что детали бизнес-логики утекают из API в его потребителей. Другие способы решения задачи могут оказаться более предпочтительным
Пагинация
Методы API отдающие списки элементов ресурсов ДОЛЖНЫ поддерживать пагинацию.
Пример такого метода - стандартный метод Search.
СЛЕДУЕТ поддерживать сразу два способа пагинации:
Offset-based pagination
Запрос:
{
// ...
"pagination": {
"type": "offset",
"offset": 40, // nullable
"limit": 20
}
}
Ответ:
{
"meta": {
"pagination": {
"offset": 40,
"limit": 20,
"total": 253, // с учётом фильтров
"type": "offset"
}
}
}
Cursor-based pagination
Запрос:
{
// ...
"pagination": {
"type": "cursor",
"cursor": "eyJpZCI6MTAsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX0", // nullable
"limit": 20
}
}
Ответ:
{
"meta": {
"pagination": {
"cursor": "eyJpZCI6MTAsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX0", // nullable
"limit": 20,
"next_cursor": "eyJpZCI6MjEsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX1", // nullable
"previous_cursor": "eyJpZCI6MTIsIl9wb2ludHNUb05leHRJdGVtcyI6ZmFsc2V9", // nullable
"type": "cursor"
}
}
}
Почитать какой вариант пагинации в каких случаях предпочтительнее для потребителя можно, например, здесь
Если объект пагинации в запросе не задан, то по-умолчанию СЛЕДУЕТ считать
{
"pagination": {
"type": "offset",
"offset": 0,
"limit": 10
}
}
Если limit не указан в запросе, то СЛЕДУЕТ брать limit по-умолчанию - 10
Если limit указан равным 0, то СЛЕДУЕТ возвращать 0 элементов
Если limit указан отрицательным, то СЛЕДУЕТ выводить все элементы без учёта offset/cursor
Для предотвращения высоких нагрузок на API допускается следующее:
- отключение offset пагинации в конкретном методе
- отключение возможности получить все элементы без пагинации через limit = -1 в конкретном методе. В этом случае возвращается 0 элементов как при limit = 0
- выставлении максимально допустимого лимита для конкретного метода. Если в запросе будет передан limit больший чем указанный, то он будет автоматически уменьшен до максимально допустимиого
Подресурсы
Использование подресурсов (например GET /customers/1/addresses/5) может сделать API более читабельным, но имеет ряд неприятных последствий
- Получаем менее унифицированный формат описания ресурса. В ответе методов Get и Search должен быть customer_id, а методах создания и обновления - нет
- При смене связи подресурса с родительским (перепривязки адреса к другому покупателю) меняется URL ресурса, старый перестает работать
- Невозможно работать с подресурсом вне контекста одного конкретного родительского ресурса, что в итоге приводит к созданию независимого ресурса и ненужному дублированию
- При высокой вложенности все преимущества читабельности исчезают.
В общем случае использование подресурсов НЕ РЕКОМЕНДУЕТСЯ, следует сразу заводить отдельные ресурсы.
Исключения
- Идентификатор ресурса не является уникальным. Уникальной является лишь пара идентификаторов {parent_id, child_id}
- Если выполняется всё из нижеперечисленного:
- Есть явная иерархия и существование ресурса невозможно без существования другого родительского ресурса
- Работа с ресурсом вне контекста родителя не имеет смысла
- Связь ресурса с родительским ресурсом постоянна
В любом случае вложенность подресурсов ДОЛЖНА быть не более 2.
Если используется модульный подход к реализации апи, то в начало урла СЛЕДУЕТ добавлять название модуля, например все эндпоинты модуля Customers стоит начинать с customers, например
/api/v1/customers/addresses.
Это не считается подресурсом.
Включения (include)
Иногда бывает удобно получить связанные ресурсы одним запросом. Для реализации данной функциональности НЕОБХОДИМО использовать параметр запроса include
Названия ключей в теле ответа ДОЛЖНЫ совпадающими с ключами в include.
include не поддерживает сложную логику для списков вроде управляемой сортировки, фильтрации и пагинации. Для таких вещей НЕОБХОДИМО использовать отдельные ресурсы
include в теле ответа может содержать три типа данных
- объект, если используется отношение “X to One”. На примере ниже -
profile
- массив объектов, если используется отношение “X to Many”. На примере ниже -
loyality_cards
- число, если используется include с префиксом
_count
. На примере ниже -addresses_count
Пример для метода Get
Формат запроса:
GET /api/v1/users/17?include=addresses\_count,loyality\_cards,profile
параметр include является опциональным
Формат ответа:
{
"data": {
"id": 17,
"name": "John Doe",
// ...
"addresses_count": 7,
"loyality_cards": [
{
"id": 1,
// ....
}
],
"profile": {
"id": 1,
// ...
}
}
}
Пример для метода Search
POST /api/v1/users:search
{
// ...
"include": [
"addresses_count",
"loyality_cards",
"profile"
],
"pagination": {
// ...
}
}
Формат ответа:
{
"data": [
{
"id": 17,
"name": "John Doe",
// ....
"addresses_count": 7,
"loyality_cards": [
{
"id": 1,
// ....
}
],
"profile": {
"id": 1,
// ...
}
}
],
"meta": {
"pagination": {
// ...
}
}
}
Загрузка файлов
Загрузку крупных файлов СЛЕДУЕТ осуществлять отдельным POST запросом с
Content-Type: multipart/form-data
POST /api/v1/offer-certificates/5:upload-file
Gateway-сервисы
Api для внешних потребителей (витрины, внешние системы) следует проектировать не гибким, а более специализированным. Задача в том, чтобы не перекладывать на потребителей бизнес-логику. Например, настройку фильтра для вывода товаров в каталог, или группировку товаров в категории. Таким образом, при проектировании подобных API, следует придерживаться концепции 1 задача = 1 метод. Для BFF сервисов это будет 1 экран = 1 метод на получение данных + методы для сохранения.
Выделение модулей
При определении модулей следует придерживаться правил:
- Api для фронта - модуль соответствует разделу фронт-приложения: лк, каталог, корзина, чекаут.
- Коннекторы - модуль может соответствовать домену-владельцу ресурса, с которым идёт работа: orders, customers. В небольших коннекторах, работающих только с ресурсами одного домена разделение на домены не требуется.
Стандартные методы
В ряде случаев допустимо использовать стандартные практики Back-сервисов:
- Если работа с ресурсом во внешнем потребителе предполагает crud подход, то следует реализовывать эти методы в соответствии со стандартными. Например, работа со списком адресов на витрине.
- Бывают ситуации, когда ресурс на фронте != ресурсу на бэке. В такой ситуации необходимо предоставлять crud для одного большого ресурса в формате фронта, не перекладывая на внешнюю систему работу по склейке.
- Поиск ресурсов следует реализовывать по стандартному подходу, адаптируя его. Например, если связанные сущности для ресурса необходимы всегда, то не нужно требовать от потребителя каждый раз указывать их в параметре include. Нужно отдавать связанные сущности в ответе всегда.
HTTP status codes
API ДОЛЖНО использовать только следующие HTTP коды ответа:
- 200 OK во всех ситуациях, когда запрос не заканчивается ошибкой
- 201 Created если его генерирует ваш фрейморк автоматически. В противном случае РЕКОМЕНДУЕТСЯ использовать 200
- 401 Unauthorized
- 403 Forbidden
- 404 Not found при запросе несуществующего ресурса или экземпляра ресурса
- 400 Bad Request при любых других ошибках, причиной которых является клиент
- 500 Internal Server Error - при любой ошибке приложения, причиной которой является проблемы в самом приложении, а не в запросе.
В документации API у каждого эндпоинта ДОЛЖНЫ быть указаны все возможные коды ответа.
Версионирование API
Что делать если нужно внести изменения в API ломающие обратную совместимость?
Если все потребители известны и учесть в них изменение относительно просто и недолго - делаем сразу чтобы не создавать технический долг.
Если такой вариант недоступен, то используем принцип эволюции апи (почитать можно тут) накопившийся из-за этого технический долг со временем чистим.>
При масштабных изменениях, когда поддерживать эволюцию текущей версии апи становится практически невозможно, приходим к версионированию.
Версионирование используется глобальное, т.е клиент в один момент времени должен всегда использовать лишь одну из версий API.
Инкрементируем версию (/v1/ -> /v2/), для старой устанавливаем Sunset заголовок с датой выключения.
Идентификатор версии всегда ДОЛЖЕН присутствовать в url.
Файлы спецификации API (openapi 3.0), а также контроллеры реализующие непосредственно методы API СЛЕДУЕТ делать полностью независимыми у разных версий.
Документация API
Все эндпоинты API ДОЛЖНЫ быть задокументированы через Open Api Specification 3.0.
Документация ДОЛЖНА быть доступна через браузер используя Stoplight.
API СЛЕДУЕТ реализовывать используя Design-first подход.
Документацию всех версий API сервиса СЛЕДУЕТ держать в общем сваггере. Конкретную версию API выбираем в селекте Servers. Версии в нём СЛЕДУЕТ располагать в порядке убывания.
При написании OAS3 спецификаций СЛЕДУЕТ придерживаться требований описанных здесь.
Дополнительные требования к API
В каждом сервисе ДОЛЖНА присутствовать поддержка трассировки запросов, соответствующая стандарту opentracing. TraceId передается через http-заголовок.