Пишем API gem: финальное метапрограммирование

Illustration of a joyous person with a scarf jumping next to a large, sparkling diamond with a shovel stuck in the dirt, suggesting a successful treasure hunt. Illustration of a joyous person with a scarf jumping next to a large, sparkling diamond with a shovel stuck in the dirt, suggesting a successful treasure hunt.

В предыдущей статье мы покрыли гем groovehq тестами и определились с оставшимся фронтом работ:

  1. Добавить немножко метапрограммирования
  2. Задокументировать и опубликовать гем

В этой статье я покажу простой и быстрый способ сократить client.ticket(74).data[:summary] до client.ticket(74).summary, тем самым сделав использование библиотеки более интуитивным и удобным.

OpenStruct для умных хэшей

В Ruby есть замечательный класс OpenStruct, очень похожий на Hash, но дополнительно определяющий геттеры для ключей. Простой пример выглядит так:

ticket = OpenStruct.new(number: 2, summary: "blah")
ticket.number # => 2
ticket.summary # => "blah"

Немного пораскинув мозгами, я решил, что OpenStruct идеально подходит для атрибута data класса GrooveHQ::Resource. Изменённый конструктор этого класса получился следующим:

# ./lib/groovehq/resource.rb
# ...
    def initialize(client, data)
      data = {} unless data.is_a?(Hash)

      @client = client

      data = data.with_indifferent_access

      links = data.delete(:links) { Hash.new }
      links[:self] = data.delete(:href) if data.has_key?(:href)

       # Теперь @data это не просто хэш, а объект класса OpenStruct
      @data = OpenStruct.new(data.with_indifferent_access)

      @rels = parse_links(links).with_indifferent_access
    end
# ...

Это приблизило нас к конечной цели, теперь можно делать client.ticket(74).data.summary. Но как избавиться от промежуточного data? При помощи method_missing!

method_missing – лучший друг метапрограммиста

method_missing – это метод, который Ruby вызывает, когда ты пытаешься вызвать на объекте метод, которого не существует. По-умолчанию он вывалит тебе исключение, но ты можешь переопределить этот метод так, чтобы он делал какие-нибудь интересные вещи. Я использую его, чтобы объекты класса GrooveHQ::Resource при вызове методов типа summary, number и прочих ключей, полученных от API, сначала попытались вызвать этот метод на атрибуте @data, а уже потом вываливали исключение. Звучит сложно? Проще один раз увидеть:

# ./lib/groovehq/resource.rb
# ...
    def method_missing(method_sym, *arguments, &block)
      # Кажется, метода не существует. Но может он существует у @data?
      if @data.respond_to?(method_sym) 
        # И правда существует! Тогда вызовем его на @data и вернём результат
        @data.send(method_sym)
      else
        # А если не существует, то выбросим исключение
        super
      end
    end

Теперь можно делать client.ticket(74).summary. Я сам удивился, насколько просто решилась задача с добавлением геттеров для API-ресурсов. Коммит здесь: ad39e1d.

Коллекции ресурсов

Осталась ещё одна проблема: обращение к коллекциям ресурсов. Например, к массиву tickets: сейчас по-прежнему нужно писать: client.tickets(page: 2).collection.first.number. Хотелось бы работать с результатами таких запросов как с массивами напрямую, без промежуточного collection. Для этого нам придётся ознакомиться с Enumerable.

Модуль Enumerable предоставляет методы для работы с коллекциями – first, last, map и т.п (см список по ссылке на документацию выше). Единственное условие его использования – наличие у класса метода each. Проще всего будет добавить этот модуль в класс GrooveHQ::Resource и реализовать метод each следующим образом:

    def each(&block)
      collection.each { |item| yield item }
    end

Но тут возникает проблема: зачем простым ресурсам весь этот багаж методов для работы с коллекцией? Не пора ли отделить Ресурс от Коллекции Ресурсов? Напомню, что сейчас Коллекция Ресурсов это тот же самый ресурс, только в качестве данных у него добавлен ключ collection:

# ./lib/groovehq/client/connection.rb
# ...
        when Array then Resource.new(self,
          {
            collection: data.map { |hash| parse_data({resource: hash}) },
            meta: original_data["meta"],
            links: {
              next: {
                href: original_data["meta"]["pagination"]["next_page"]
              },
              prev: {
                href: original_data["meta"]["pagination"]["prev_page"]
              }
            }
          })
# ...

Пораскинув мозгами ещё чуть-чуть, я решаю добавить класс GrooveHQ::ResourceCollection. Выглядит он так, см. комменты в коде:

# ./lib/groovehq/resource_collection.rb
module GrooveHQ

  class ResourceCollection < Resource
    include Enumerable

    def initialize(client, data)
      data = {} unless data.is_a?(Hash)
      data = data.with_indifferent_access

      @client = client

      meta_data = data.delete(:meta) { Hash.new }

      # Собираем сам массив. Array() позволяет убедиться, что даже если data.values.first == nil,
      # то мы всё равно передаём в map массив.
      collection = Array(data.values.first).map do |item|
        Resource.new(client, item)
      end

      links = {}

      # Строим ссылки на следующие страницы
      if meta_data.has_key?("pagination")
        links = {
          next: {
            "href" => meta_data["pagination"]["next_page"]
          },
          prev: {
            "href" => meta_data["pagination"]["prev_page"]
          }
        }
      end

      @data = OpenStruct.new(meta: meta_data, collection: collection)
      @rels = parse_links(links).with_indifferent_access
    end

    def each(&block)
      collection.each { |item| yield item }
    end

  end

end

Теперь гем позволяет использовать такие конструкции: client.tickets(page: 2).map { |ticket| ticket.number }. Покрыв новый класс тестами, я делаю ещё один коммит.

Подходим к концу

Я обещал две статьи, но, кажется, материала на ещё одну статью и не хватит. После решения последней задачи с ResourceCollection я написал краткую документацию к гему и опубликовал её на rubygems: https://rubygems.org/gems/groovehq. На этом серия подходит к концу. Вспомним всё, чему мы научились в этой серии:

  1. Писать гемы;
  2. Покрывать тестами код, в том числе работающий с внешним API;
  3. Работать с внешним API, и даже с Hypermedia API;
  4. Узнали, как можно применять метапрограммирование;
  5. Познакомились с несколькими хорошими гемами, которые тебе обязательно пригодятся.

Я постарался как мог, чтобы эта серия была лишь на 10% о создании гемов, и на 90% о разработке на Ruby в целом, и добавил как можно больше интересных вещей, гемов, приёмов и рассуждений. Надеюсь, ты многому научился благодаря этим постам! Пиши в комментариях, что думаешь об этой серии, что понравилось, чего не хватает и т.п.