Пишем API gem: что такое Hypermedia API и как с ним подружиться
В предыдущей статье мы разметили основную структуру гема, определились с библиотекой для HTTP запросов и написали метод для работы с первой API точкой – получением списка всех тикетов. После этого я планировал начать добавлять все остальные точки, но столкнулся с проблемой, которую мы и будем решать в этой статье: поддержка Hypermedia API.
Hypermedia API
Hypermedia API – относительно новая и относительно спорная концепция. Основной её смысл заключается в том, что API приложения возвращает не только данные, но и ссылки на другие, связанные с оригинальным запросом, ресурсы. Рассмотрим это на примере GrooveHQ API. Вот в каком виде он возвращает json для тикета:
{
"ticket": {
"assigned_group": null,
"created_at": "2012-07-17T13:41:01Z",
"number": 1,
"priority": null,
"resolution_time": null,
"title": null,
"updated_at":"2012-07-17T13:41:01Z",
"href": "https://api.groovehq.com/v1/tickets/1",
"closed_by": "matt@groovehq.com",
"tags": ["important"],
"links": {
"assignee": {
"href": "https://api.groovehq.com/v1/agents/matt@groovehq.com"
},
"customer": {
"href": "https://api.groovehq.com/v1/customers/test@test.com"
},
"state": {
"href": "https://api.groovehq.com/v1/tickets/1/state"
},
"messages": {
"href": "https://api.groovehq.com/v1/tickets/1/messages"
}
}
}
}
Видишь ключ “links"? Вот это те самые ссылки на другие ресурсы. Разработчику не нужно идти в документацию, он может исследовать возможности API путём навигации по таким вот ссылкам. Эта идея задокументирована как Hypertext Application Language и для её JSON версии даже есть формальная спецификация.
Если вас интересует моё личное мнение, то я считаю эту идею как минимум странной. Изучать API не по документации, а путём просмотра ответов от самого API? Нет, спасибо.
Первым моим позывом было отбросить эти ссылки вообще. Но потом я осознал одно преимущество: при помощи этих ссылок можно чуть облегчить написание библиотеки, так как они позволяют легко строить цепочки запросов используя лишь несколько небольших абстракций поверх обычного запроса. Этим мы сегодня и займёмся.
В комментариях подсказали ещё один плюс – изменение адресов ресурсов при использовании HAL не ломает клиенты.
Пишем Resource и Relation
Как я сказал выше, использование HAL позволит строить цепочки запросов к ресурсам. Осталось только придумать, как это реализовать. В этот момент становится очевидным, что простыми JSON хэшами нам не обойтись: понадобятся новые классы, объекты которых будут возвращаться запросами к API.
Классов этих будет всего два: GrooveHQ::Resource
и GrooveHQ::Relation
.
GrooveHQ::Resource
– не более чем небольшая обёртка вокруг JSON данных. Помимо самих данных, объекты этого класса хранят API клиента (GrooveHQ::Client
) и коллекцию ссылок на другие ресурсы. Выглядит класс вот так:
# ./lib/groovehq/resource.rb
module GrooveHQ
class Resource
attr_reader :rels, :data
def initialize(client, data)
@client = client
@data = data
@rels = parse_rels
end
def parse_rels
@data["links"].each_with_object({}) do |(relation, value), result|
result[relation] = Relation.new(@client, value["href"])
end
end
end
end
Метод #parse_rels
берёт ссылки из json ответа и для каждой ссылки строит объект класса GrooveHQ::Relation
, который позволит там проводить последующие запросы к связанным ресурсам. Обрати внимание на each_with_object
– если раньше не видел этот метод, то посмотри документацию к нему и используй по необходимости.
GrooveHQ::Relation
так же хранит в себе объект API клиента плюс ссылку на сам ресурс. Таким образом, для реализации этого класса нам достаточно простого Struct и одного метода, который делает запрос при помощи клиента:
# ./lib/groovehq/relation.rb
module GrooveHQ
class Relation < Struct.new(:client, :href)
def get(options = {})
client.get href, options
end
end
end
Усложняем запросы к API
Теперь, когда обёртки (wrapper), упрощающие работу с Hypermedia API написаны, нам лишь нужно использовать их при проведении запросов к API. Для этого нам нужна ещё одна обёртка, вокруг методов HTTParty. Для этого я создал новый модуль GrooveHQ::Client::Connection
состоящий пока что из трёх методов: request
, parse_data
и get
. Рассмотрим их по-отдельности:
def request(http_method, path, options)
response = self.class.send(http_method, path, { query: options })
data = response.parsed_response
parse_data(data)
end
#request
принимает HTTP-метод, ссылку и опции, затем использует HTTParty для проведения запроса и отправляет полученный JSON в метод #parse_data
. Результат #parse_data
и будет результатом любого запроса:
def parse_data(data)
data = data.values.first
case data
when Hash then Resource.new(self, data)
when Array then data.map { |hash| parse_data({resource: hash}) }
when nil then nil
else data
end
end
В зависимости от типа данных мы либо сразу возвращаем объект класса Resource
, либо возвращаем массив таких объектов (путём рекурсивного вызыва #parse_data
), либо сразу возвращаем результат запроса.
Обрати внимание на data = data.values.first
и {resource: hash}
: дело в том, что GrooveHQ API всегда возвращает результат с корневым ключом, в том время как наш ресурс в этом корневом элементе не нуждается. Поэтому мы всегда отбрасываем корневой ключ и берём только сами данные. А чтобы это отбрасывание ничего не сломало при рекурсивном запросе, мы задаём свой корневой ключ в цикле.
Собираем всё вместе
Теперь нам остаётся только обновить метод #tickets
и проверить, что новый код работает с цепочками запросов любой длинны! Так как я уже подключил модуль GrooveHQ::Client::Connection
внутри класса GrooveHQ::Client
, то я могу использовать новый метод #get
внутри модуля GrooveHQ::Client::Tickets
:
module GrooveHQ
class Client
module Tickets
def ticket(ticket_number, options = {})
get("/tickets/#{ticket_number}", { query: options })
end
end
end
end
Теперь можно делать такие безумные вещи:
client = GrooveHQ::Client.new
puts client.ticket(1).rels["assignee"].get.rels["tickets"].get(page: 2).first.data["created_at”]
В этот момент гем действительно готов для добавления любых других API точек. Но ты, наверное, заметил один ужасный косяк: я всё ещё не написал ни единого теста! Согласен, уже пора. В следующей статье посмотрим, как покрывать гемы тестами и делать mock’и API запросов.
Коммит, кстати, здесь: 8056904. А твои комментарии и ответы на них – чуть ниже. Наверняка у тебя уже накопились вопросы, на которые я буду рад ответить.