Пишем API gem: финальное метапрограммирование
В предыдущей статье мы покрыли гем groovehq тестами и определились с оставшимся фронтом работ:
- Добавить немножко метапрограммирования
- Задокументировать и опубликовать гем
В этой статье я покажу простой и быстрый способ сократить 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. На этом серия подходит к концу. Вспомним всё, чему мы научились в этой серии:
- Писать гемы;
- Покрывать тестами код, в том числе работающий с внешним API;
- Работать с внешним API, и даже с Hypermedia API;
- Узнали, как можно применять метапрограммирование;
- Познакомились с несколькими хорошими гемами, которые тебе обязательно пригодятся.
Я постарался как мог, чтобы эта серия была лишь на 10% о создании гемов, и на 90% о разработке на Ruby в целом, и добавил как можно больше интересных вещей, гемов, приёмов и рассуждений. Надеюсь, ты многому научился благодаря этим постам! Пиши в комментариях, что думаешь об этой серии, что понравилось, чего не хватает и т.п.