Пишем 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.
Обновлено: | Опубликовано:

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

Subscribe to our Newsletter

Let us send you the best of what we've discovered in DevOps, Cloud and Kubernetes, as well us occasional event announcements.

We are also preparing some ways to learn together: weekly challenges, free courses and more. Subscribe now to be the first to get those.