Hypermedia — то без чего ваше API не совсем REST
Всем привет! Меня зовут Дмитрий Павлов, в компании Align Technology мы с коллегами занимаемся разработкой Web API для взаимодействия внутренних систем и интеграции нашей компании со сторонними вендорами. Об идеях создания API для веба, а точнее RESTful API я хотел бы рассказать в этой статье.
В последние годы тема Web API стала очень популярна, многие компании занимаются созданием подобных интерфейсов, как открытых, так и для внутреннего пользования. В описании Web API практически всегда можно встретить акроним REST, но что же это обозначает этот термин и правильно ли его используют?
Большинство разработчиков, особенно в России, понимает под REST программный интерфейс, работающий с использованием протокола HTTP[S] при условии соблюдения следующих свойств:
-
Сервер не хранит состояние клиента: никаких сессий, все что требуется для выполнения запроса клиент передаёт с самим запросом.
-
Человекочитаемые URL, в которых ресурсы идентифицируются отдельно. Никаких больше
/index.php?productId=1
, вместо этого используем/products/1
-
Более широкое использование HTTP методов: не ограничиваемся GET и POST, добавляем PUT и DELETE. В некоторых API можно встретить еще и PATCH.
- В качестве формата передачи данных используется JSON.
Алгоритм по которому такой API используется обычно стандартен. Для начала нужно пойти на сайт с документацией, найти страничку со списком URL-шаблонов для доступа к ресурсам. Обычно она выглядит так:
Список ресурсов для API "Рецепты печенек"
--- /recipes/cookies - список рецептов печенек,
GET на данный URL возвращает список доступных рецептов
[
{
"name" : "Овсяное печенье с шоколадом",
"rating" : 5,
"shortDescription" : "...."
}
]
POST на данный URL возволит вам создать новый рецепт. В качестве тела запроса ожидается json вида как
{
"name" : "Малиновое печенье",
"shortDescription" : "...."
......
}
--- /recipes/cookies/:name - рецепт печеньки с именем ${name}
{
"name" : "Овсяное печенье с шоколадом",
"rating" : 5,
"shortDescription" : "....",
"description" : "...."
"ingredients" : [
{
"name" : "Овсянка",
.....
},
{
"name" : "Масло",
.....
},
{
"name" : "Шоколад",
.....
}
],
"cookingSteps" : [
....
]
}
// остальные ресурсы гипотетического API с перечсилением HTTP методов и URL шаблонов
и изучив её выполнять запросы к ресурсам (что обычно выражается в написании клиента, который по заданным форматам URL’ов подставляет параметры и обрабатывает ответы).
Примеров таких API на просторах сети предостаточно, до недавнего времени у Яндекса многие API (раз, два) заявленные как REST работали по этой схеме.
Если мы обратимся к первоисточникам, т.е. к диссертации Роя Филдинга (на которую очень часто ссылаются, но гораздо реже читают), мы увидим, что API, созданные таким способом, не могут называться REST, поскольку они нарушают некоторые из принципов, описанных в диссертации, самый главный из которых — использование hypermedia как средства управления состоянием (Hypermedia As The Engine Of Application State, HATEOAS), косвенно затрагивая вопросы само описываемых сообщений (self-descriptive messages).
Суть HATEOAS состоит в подходе к описанию ресурсов нашего API. Вместо простого перечисления набора ресурсов, со списком всех возможных операций, которые клиент может вызвать, руководствуясь некоторой внутренней логикой, мы проводим инверсию контроля — теперь за состояние ресурса отвечает сервер и он диктует клиенту, какие операции над ресурсом можно совершить в текущий момент. Эта информация должна присутствовать в самом представлении ресурса, который получает клиент. Таким образом, представление ресурса само себя описывает в достаточной степени, чтобы клиент понял, что с ним можно делать.
Применение такого подхода обычно означает, что клиент знает некоторый конечный набор «точек входа» (можете считать их аналогами стартовых страниц на сайтах), с которых он начинает свое взаимодействие с API, используя предоставленную в представлении ресурса информацию для навигации к другим ресурсам и совершения действий.
Для достижения этой задачи как раз и используются гиперссылки (hypermedia):
- Все ресурсы адресуемы при помощи ссылок, причем ссылки на другие ресурсы присутствуют внутри самих сообщений для их связи между собой. Клиент вместо ориентации на формат URI руководствуется идентификаторами, по которым он выбирает ссылки, располагающиеся прямо в представлении ресурса. Если ранее мы указывали в документации что нужно взять некоторый ID и на его основе построить специальный URL, тем самым делая наборы URL частью нашего API, то теперь детали формирования URL являются просто особенностями реализации сервера и клиента не волнуют. В конце концов, клиенту важно получить доступ к ресурсу, а не генерировать URL по шаблонам из документации.
- Доступные операции над ресурсом тоже представимы в виде ссылок.
Отсутствие ссылки как на связанные ресурсы, так и на доступные действия означает, что данная операция недоступна в текущем состоянии ресурса.
Пример переработки API «Рецепты печенек» в Hypermedia представление
Возвращаясь к примеру с нашим API о каталоге рецептов печенек, преобразуем его в Hypermedia-вид.
Как вы помните, у нас был список рецептов и ресурс, подробно описывающий конкретный рецепт с перечислением ингредиентов и шагов по приготовлению. Вот как они будут выглядеть с использованием hypermedia подхода:
//список рецептов
{
"links": {
"self" "/recipes/cookies"
}
"items": [
{
"name": "Овсяное печенье с шоколадом",
"rating": 5,
"shortDescription": "...."
"links": {
"self": "/recipes/cookies/Овсяное печенье с шоколадом"
}
}
]
}
//конкретный рецепт
{
"links": {
"self": "/recipes/cookies/Овсяное печенье с шоколадом"
}
"name": "Овсяное печенье с шоколадом",
"rating": 5,
"shortDescription": "....",
"description": "...."
"ingredients": [
{
"name": "Овсянка",
.....
},
{
"name": "Масло",
.....
},
{
"name": "Шоколад",
.....
}
],
"cookingSteps": [
....
]
}
Значительное отличие от оригинальной версии заключается в появлении объекта links
внутри каждого ресурса. Ключи этого объекта представляют собой relation’ы (те самые идентификаторы), а значения — ссылки. В результате наши ресурсы не требуют дополнительной информации (вне самого ресурса) о том как же перейти из каталога рецептов к детальному описанию, ссылка встроена в представление ресурса.
Данные подход позволяет легко расширять функциональность нашего API. Предположим, что мы для каждого рецепта хотим предоставить клиенту набор рекомендаций, представимый в виде списка рецептов. Сделать это очень легко, достаточно добавить в наш объект links новый ключ:
"links" : {
"self" : "/recipes/cookies/Овсяное печенье с шоколадом",
"http://acme.com/recipes/rels/you-can-also-like" : "/recipes/cookies?related_to=Овсяное+печенье+с+шоколадом"
}
Аналогично, совершенно не составит труда добавить идентификацию ингредиентов как отдельных ресурсов, если в этом возникнет необходимость.
Содержание URI не играет никакой роли, ведь теперь элементом API является relation, и мы без каких либо изменений на клиенте можем поменять ссылку на /recipes/related-to/Овсяное печенье с шоколадом
или на /recipes/234892skfj45sdlkfjdsa12
Hypermedia на службе перемен
Hypermedia используется не только для навигации, но и для совершения действий, достаточно лишь определить, что некоторые relation отвечают за совершение определенных операций над ресурсами, а также обозначить семантику и детали этих операций.
Для наглядности рассмотрим пример с нашим API, добавив hypermedia-контрол для создания нового рецепта.
//список рецептов
{
"links" : {
"self" : "/recipes/cookies",
"http://acme.com/recipes/rels/add-recipe" : "/recipes/cookies"
}
"items" : [
.....
]
}
Мы лишь добавили ссылку со специальным relation’ом. Основное правило заключается в том, что клиент игнорирует неизвестные ему отношения: «старые» клиенты, не знающие как добавлять новый рецепт, будут работать как раньше, а для тех кто поддерживает создание, это будет сигналом, что есть возможность добавления нового рецепта путем отправки запроса на URI, который указан в отношении http://acme.com/recipes/rels/add-recipe
.
Данный подход позволяет нам не описывать статический набор операций и условия их выполнения в документации, а непосредственно серверу контролировать какие операции клиент может совершать над ресурсом в данный момент времени, а какие — нет. Добавление новых действий тоже не представляет сложности: мы просто объявляем новый relation, и начинаем включать его в представление ресурса, которое формирует сервер.
Разумеется, предоставление ссылок не снимает с сервера ответсвенности за корректное оперирование HTTP методами и соблюдения их семантики :).
А как же relation?
У вас к данному моменту наверняка возник вопрос: какой смысл затевать все это, если для эффективной работы клиент все равно должен понимать смысл relation’ов? По ним-то документация должна иметься.
В самом деле, для эффективной работы клиент действительно должен понимать, что значит каждое отношение. Основная идея, стоящая за заменой интерпретации URI на работу с relation’ами, состоит в большей долговечности последних. URI является деталью реализации и может меняться со временем или от сервера к серверу. Relation же представляет собой семантическое описание связи и не завязан на детали хранения.
Предположим, я хочу сделать совместимый API для хранения рецептов, но из-за особенностей хранения хочу идентифицировать каждый рецепт по UUID, а не по названию. В случае с оригинальным API сделать это невозможно, а для hypermedia API это совершенно незаметно для клиента.
В результате появляется возможность создания более универсальных клиентов менее подверженных изменениям в случае модификаций на сервере.
Hypermedia types или почему application/json нам не подходит
Решив воспользоваться преимуществами Hypermedia подхода, мы модифицировали наш API указанным выше способом, и теперь у нас ресурсы связаны друг с другом по ссылке. С первого взгляда может показаться, что с нашим API все в порядке, но перед тем как заявить, что у нас Hypermedia API, посмотрим на заголовок Content-Type, возвращаемый нами в ответах. Если там стоит application/json
или даже text/plain
, то нам еще предстоит потрудиться.
Ресурсы глазами машины
Глядя на получившиеся у нас ресурсы, человек сразу выделяют ссылки, что создает впечатление о корректном формате нашего сообщения. Мы делаем вывод об этом, анализируя содержимое сообщения, тогда так стандарт предписывает смотреть на Content-Type заголовок ответа.
Рассмотрим следующий ответ сервера:
200 OK
Content-Type: text/plain
world
Нам очевидно, что в ответе содержится xml-документ, но Content-Type предписывает воспринимать содержимое как простой текст, поэтому то, что он похож на xml-документ может быть просто совпадением или частным случаем. Именно поэтому верный Content-Type так важен.
Давайте разбираться, чем же для нашей задачи не подойдет application/json
? Дело в том, что стандарт, описывающий этот тип, не предусматривает никакого места или механизма для определения ссылок в нем. И даже если сформированное нами сообщения содержит ссылки, то машина не может отличить их от строки, в которой содержится текст по формату напоминающий ссылку. Нам же нужно однозначно определить, где в сообщении ссылка, а где нет, поэтому нам нужен другой тип.
Vendor specific типы
Одним из способов решить проблему корректности Content-Type’а — использовать свой собственный. В документации мы явно укажем, где у нас в сообщении расположены ссылки. Если клиент получил от сервера ответ с нашем личным Content-Type’ом, ему не нужно будет динамически угадывать, что ссылка, а что нет, если конечно он понимает наш Content-Type. Стоит отметить, что зачастую документация с описанием типа содержит не только подробности самого формата (т.е. где расположены ссылки, а где свойства), но и другую информацию:
- семантическое описание свойств, т.е. что они обозначают с точки зрения бизнес логики;
- детали взаимодействия клиента с сервером, такие как HTTP методы необходимые для отправки запроса.
Такие типы называются vendor specific, поскольку часто создаются под конкретную задачу и конкретной организацией. Их нет необходимости регистрировать в IANA. Рекомендуется давать им название вида application/vnd.${vendor}+${base_format}
, где ${vendor}
— это перевернутый домен компании, ${base_format}
— тип который мы взяли за основу. Если компания имеет домен acme.com и для представления наших ресурсов мы используем json, то для нашего API рецептов название типа будет выглядеть как application/vnd.com.acme.recipes+json
.
Hypermedia типы общего назначения
На первый взгляд, vendor specific типы решают возникшую проблему со ссылками, но у них есть и свои проблемы:
- типы не совместимы между собой, поэтому клиентам приходится поддерживать много разных реализаций, в случае если они взаимодействуют не с одним API, т.е. требуется поддержка отдельной библиотеки для разбора формата каждого типа, выделения в нем свойств, ссылок и прочего;
- создание vendor specific типа под каждую задачу ведет к очень заметному росту их общего числа.
В качестве альтернативы не заставил себя ждать новый подход, который принесли типы общего назначения. Если подумать, то все что нам нужно от формата сообщений — это спецификация, отвечающая на вопросы:
- как найти свойства наших ресурсов,
- как найти hypermedia контролы внутри ресурса.
Именно эта задача и решается: тип общего назначения не пытается подстроиться под конкретную доменную область, ими можно описать большинство ресурсов, с которыми мы имеем дело.
Важной особенностью всех типов общего назначения является то, что они не ставят задачу семантического описания документа, т.е. они не говорят, что же это за ресурс — описание рецепта или комментарий в блоге, это не их задача. Они отвечают больше за детали формата, оставляя семантическую спецификацию за рамками. Предполагается, что семантика будет заключена в так называемом профиле — отдельном документе, описывающим семантику свойств и отношений (relations).
На данный момент существует уже достаточно большое количество подобных форматов, поэтому перечислим лишь некоторые из них:
application/hal+json
— один из первых появившихся и наиболее популярный формат в наши дни;application/vnd.siren+json
;application/mason+json
.
В описании всех таких форматов вы найдете, как и куда помещать свойства ресурса, в каком виде оформлять ссылки на другие ресурсы.
Различаются они форматом и возможностями, которые содержатся в самом типе.
Разница в форматах или принципиальность создателей
Большинство типов общего назначения отличается незначительными деталями, например, как форматировать ссылки. Так, в HAL ссылки выглядят следующим образом:
"_links" : {
"self" : ....
"relToResource": .....
}
Тогда как Siren представляет их так:
"links" : [
{"rel" : ["self"], "href" : "...."},
{"rel" : ["relToResource"], "href" : "...."}
]
Основное отличие здесь в представлении relation значений. Создатель HAL стремился сделать формат более лаконичным, в то время как создатель Siren — более полным: relation у ссылки действительно может быть сложным (поэтому в Siren это массив значений), но это не всегда используется (поэтому в HAL это скаляр, да еще и ключ в объекте).
Такие вот разные взгляды и привели к созданию разных форматов, об одном формате договориться не смогли.
Различие в возможностях
Не будем здесь перечислять все различия в форматах, обозначим только основные, на примере уже упомянутых типов:
- В HAL не существует понятия действий как отдельной сущности, только ссылки и метод, который нужно использовать для отправки запроса тоже не включен в сообщение. В Siren и Mason в наличии инструменты по описанию форм: сообщение содержит параметры, которые требуется для ввода клиентом, метод, которым нужно все это отправить и Content-Type.
- HAL имеет отдельное понятие формы поиска — безопасного, идемпотентного действия для получения информации с сервера. Siren и Mason, как уже говорилось выше, обладает возможностью описать любое действие.
- HAL и Siren не содержат в спецификации деталей по описанию ошибок, это остаётся на усмотрение пользователя формата (можно использовать
application/vnd.error+json
), тогда как в Mason этот аспект включен в формат.
Generic vs vendor specific
Какой же вариант предпочтительнее: специально созданный тип или один из существующих вариантов? Как обычно бывает с подобными вопросами, однозначного ответа на него нет, все зависит от обстоятельств использования, поэтому попробуем выделить преимущества и недостатки каждого из них.
Одно из главных преимуществ hypermedia-типа общего назначения — экономия времени вам и клиентам вашего API. Вот за счет чего она достигается:
- Формат сообщений уже придуман за вас. Вы можете сосредоточиться на решении вашей проблемы создавая API в уже установленных ограничениях выбранного формата.
- Для создания и разбора сообщений уже написаны библиотеки под разные языки программирования. Согласитесь, удобно подключить готовую библиотеку и работать с высокоуровневыми понятиями вида ссылка, действие, свойства объекта, а не с деталями путей в JSON.
- Для многих generic-типов существуют так называемые браузеры. Это простые клиенты, которые понимают элементы определенного типа, и динамически формируют веб интерфейс, позволяя вам сразу демонстрировать созданный API, не потратив и минуты на создание клиента.
- Вашим клиентам потребуется только изучать документацию по вашему API, т.е. они смогут сосредоточиться только на бизнес-смысле, не тратя время на чтение деталей формата.
В то же время часть преимуществ такого подхода могут для кого-то выглядеть недостатками. В силу того, что тип не завязан под какую-либо доменную область и задачу, представление ресурсов получается более «раздутым», по сравнению со специальным типом, который мы могли бы создать.
В итоге для большинства задач можно рекомендовать использовать один из имеющихся Hypermedia-форматов общего назначения по умолчанию и делать выбор в пользу vendor-форматов в сложных или специфических случаях (если вы, конечно, не ставите целью vendor lock-in).
Описанный подход не является очередной серебряной пулей, призванной решить все проблемы при разработке API.
Можно отметить, что концепция точек входа способна привести к росту числа запросов, чтобы «добраться» до нужного ресурса и что включая ссылки мы делаем сообщение более объемным по сравнению с голыми данными.
На эти недостатки можно возразить, что эти проблемы решаются продуманной структурой ресурсов (кто мешает сделать операции поиска ресурса в точке входа для быстрой навигации?), кэшированием, которое тоже отмечено Филдингом как важная компонента этого архитектурного подхода, и банальным включением компрессии на веб серверах.
Основной плюс REST-подхода (здесь я имею ввиду полноценный REST) в гибкости и расширяемости, который он предоставляет, позволяя нам добавлять новые возможности или просто менять организацию ресурсов у себя на сервере без нарушения работы существующих клиентов.
Даже если вы решите не использовать hypermedia в вашем API, теперь вы знаете, что без нее REST — это не REST, а просто Web API. Это не делает API плохим или хорошим, я просто констатирую факт. Главное не забывать, что API мы делаем не ради самого API, а для решения задач, стоящих перед нами :).
Примеры на просторах сети
- API Яндекс Диска — пример hypermedia API, использующий HAL.
- Github — использует свой формат (на основе JSON), который тем не менее является hypermedia.
- Paypal тоже использует свой формат сообщений.
- Foxycart — пример API имеющего представление в нескольких форматах — HAL и Siren. Также имеет замечательное
представление документации (к сожалению, технология генерирования таких документов не opensource)