Пишем API gem: что такое Hypermedia API и как с ним подружиться

Illustration of a construction worker in a safety vest dodging a falling package at a construction site, with a barricade, scattered tools, and a crane in the background. Illustration of a construction worker in a safety vest dodging a falling package at a construction site, with a barricade, scattered tools, and a crane in the background.

В предыдущей статье мы разметили основную структуру гема, определились с библиотекой для 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. А твои комментарии и ответы на них – чуть ниже. Наверняка у тебя уже накопились вопросы, на которые я буду рад ответить.