Пишем Ruby gem для Yandex Direct API
Очень хотелось изучить Ruby получше, а рабочего проекта не было. И я попробовал написать gem для работы с Yandex Direct API.
Причин было несколько. Среди них: Yandex Direct API очень типичен для Яндекса и современных REST-сервисов вообще. Если разобраться и преодолеть типичные ошибки, то можно легко и быстро написать аналоги для прочих API Яндекса (и не только). И ещё: у всех аналогов, которые мне удалось найти, были проблемы с поддержкой версий Директа: одни были заточены под 4, другие под новую 5, и поддержке units я нигде не нашёл.
Метапрограммирование — великая вещь
Основная идея gem-а — раз в языке вроде Ruby или Python можно создавать новые методы и JSON-подобные объекты на лету, то методы интерфейс для доступа к REST-сервису могут повторять функции самого Rest-сервиса. Чтобы можно было писать так:
request = {
"SelectionCriteria" => {
"Types" => ["TEXT_CAMPAIGN"]
},
"FieldNames" => ["Id", "Name"],
"TextCampaignFieldNames" => ["BiddingStrategy"]
}
options = { token: Token }
@direct = Ya::API::Direct::Client.new(options)
json = direct.campaigns.get(request)
А вместо того, чтобы писать справку, отсылать пользователей к мануалам по указанному API.
Методы из старых версий вызывать, например, так:
json = direct.v4.GetCampaignsList
На тот случай, если вам не интересно читать, а хочется попробовать — готовый gem можно взять отсюда:
- ya-api-direct на RubyGems
- ya-api-direct на GitHub
О получении omniauth-token из rails можно узнать из примера по twitter. А названия методов и процедура регистрации очень подробно расписана в документации от Яндекса.
Если интересны подробности — они дальше.
Начинаем разработку
Разумеется, в статье описан самый базовый опыт и самые простые вещи. Но она может быть полезна начинающим (вроде меня), как памятка по созданию типового gem-а. Собирать информацию по статьям, конечно, интересно, —, но долго.
Наконец, может быть, что кому-то из читателей действительно надо по быстрому добавить поддержку Yandex Direct API в свой проект.
А ещё она будет полезна мне — в плане фидбека.
Проверочный скрипт
Для начала зарегистрируемся в Yandex Direct, создадим там тестовое приложение и получим для него временный Token.
Потом откроем справку по Yandex Direct API и поучимся вызывать методы. Как-нибудь так:
Для версии 5:
require "net/http"
require "openssl"
require "json"
Token = "TOKEN" # Сюда пишем тестовый TOKEN.
def send_api_request_v5(request_data)
url = "https://%{api}.direct.yandex.com/json/v5/%{service}" % request_data
uri = URI.parse(url)
request = Net::HTTP::Post.new(uri.path, initheader = {
'Client-Login' => request_data[:login],
'Accept-Language' => "ru",
'Authorization' => "Bearer #{Token}"
})
request.body = {
"method" => request_data[:method],
"params" => request_data[:params]
}.to_json
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
response = http.request(request)
if response.kind_of? Net::HTTPSuccess
JSON.parse response.body
else
raise response.inspect
end
end
p send_api_request_v5 api: "api-sandbox", login: "alexteut", service: "campaigns", method: "get", params: {
"SelectionCriteria" => {
"Types" => ["TEXT_CAMPAIGN"]
},
"FieldNames" => ["Id", "Name"],
"TextCampaignFieldNames" => ["BiddingStrategy"]
}
Для версии 4 Live (Token подходит к обоим):
require "net/http"
require "openssl"
require "json"
Token = "TOKEN" # Сюда пишем тестовый TOKEN.
def send_api_request_v4(request_data)
url = "https://%{api}.direct.yandex.com/%{version}/json/" % request_data
uri = URI.parse(url)
request = Net::HTTP::Post.new(uri.path)
request.body = {
"method" => request_data[:method],
"param" => request_data[:params],
"locale" => "ru",
"token" => Token
}.to_json
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
response = http.request(request)
if response.kind_of? Net::HTTPSuccess
JSON.parse(response.body)
else
raise response.inspect
end
end
p send_api_request_v4 api: "api-sandbox", login: "alexteut", version: "live/v4", method: "GetCampaignsList", params: []
Эти скрипты уже годятся для отладки и быстрых тестовых запросов.
Но, как учит нас (мифический) человеко-месяц, скрипт для себя и библиотека для других — это два разных класса приложений. И чтобы передалать один в другой, предстоит попотеть.
Создаём gem
Для начала надо было определиться с названием — простым и не занятым. И пришёл к выводу, что ya-api-direct — это то, что надо.
Во-первых, сама структура логична — и если появится, к примеру, ещё и ya-api-weather, то будет ясно, к чему он относится. Во-вторых, у меня всё-таки не официальный продукт от Яндекса, чтобы использовать торговую марку как префикс. К тому же, это намёк на ya.ru, где бережно хранится прежний лаконичный дизайн.
Создавать руками все папки немного лениво. Пусть за нас это сделает bundler:
bundle gem ya-api-direct
В качестве средства для UnitTest я указал minitest. Потом будет ясно, почему.
Теперь у нас есть папка, и в ней готовый для сборки gem. Его единственный недостаток в том, что он совершенно пуст.
Но сейчас мы это исправим.
Пишем тесты
UnitTest-ы невероятно полезны для выявления хитро спрятаных багов. Почти каждый программист, который всё-таки сподобился их написать и исправил попутно пару десятков багов, что затаились в исходниках, обещает себе, что будет их теперь писать всегда. Но всё равно не пишет.
В некоторых проектах (наверное, их пишут особенно неленивые программисты) есть одновременно и test и spec-тесты. Но в последних версиях minitest вдруг научился spec-интерфейсу, и я решил обойтись и одними spec-ами.
Так как интерфейс у нас онлайновый и, к тому же, за каждый запрос с нас списываются баллы, мы подделаем ответы от Yandex Direct API. Для этого нам потребуются хитрый gem webmock.
Добавляем в gems
group :test do
gem 'rspec', '>= 2.14'
gem 'rubocop', '>= 0.37'
gem 'webmock'
end
Обновляем, переименовываем папку test в spec. Так как я торопился, то тесты написал только для внешних интерфейсов.
require 'ya/api/direct'
require 'minitest/autorun'
require 'webmock/minitest'
describe Ya::API::Direct::Client do
Token = "TOKEN" # Не трогаем, т.к. API всё равно ненастоящий.
before do
@units = {
just_used: 10,
units_left: 20828,
units_limit: 64000
}
units_header = {"Units" => "%{just_used}/%{units_left}/%{units_limit}" % @units }
@campaigns_get_body = {
# Тут взятый из справки Yandex Direct API пример результата запроса
}
# Тут другие инициализации
stub_request(:post, "https://api-sandbox.direct.yandex.ru/json/v5/campaigns")
.with(
headers: {'Accept'=>'*/*', 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Accept-Language'=>'en', 'Authorization'=>'Bearer TOKEN', 'Client-Login'=>'', 'User-Agent'=>'Ruby'},
body: {"method" => "get", "params"=> {}}.to_json)
.to_return(:status => 200,
body: @campaigns_get_body.to_json,
headers: units_header)
# Дальше инициализируем другие запросы
@clientV4 = Ya::API::Direct::Client.new(token: Token, api: :v4)
@clientV5 = Ya::API::Direct::Client.new(token: Token)
end
webmock подменяет методы стандартных библиотек для работы с HTTP, чтобы при запросах с определёнными телом и заголовками возвращался соответствующий ответ.
Если вы ошиблись настройке, это не страшно. Когда вы попытаетесь отправить запрос, которого нет в фильтре, то webmock сообщит об ошибке и даже подскажет, как написать стаб правильно.
И пишем spec-и:
describe "when does a request" do
it "works well with version 4" do
assert @clientV4.v4.GetCampaignsList == @campaigns_get_body
end
it "works well with version 5" do
assert @clientV5.campaigns.get == @campaigns_get_body
end
end
# и все остальные
Rake
Rake реализован настолько гибко и просто, что чуть ли не в каждой библиотеке он устроен по-своему. Поэтому я просто велел ему запускать все файлы, которые назваются spec_*.rb и лежат в директории spec:
require "bundler/gem_tasks"
require "rake/testtask"
task :spec do
Dir.glob('./spec/**/spec_*.rb').each { |file| require file}
end
task test: [:spec]
task default: [:spec]
Теперь наши spec-и можно вызывать так:
rake test
Или даже:
rake
Правда, тестировать ему пока нечего.
Пишем gem
Сначала заполяем с информацией о gem-е (без этого bundle откажется запускаться). Потом пишем в gemspec, какие сторонние библиотеки будем использовать.
gem 'jruby-openssl', platforms: :jruby
gem 'rake'
gem 'yard'
group :test do
gem 'rspec', '>= 2.14'
gem 'rubocop', '>= 0.37'
gem 'webmock'
gem 'yardstick'
end
Делаем
bundle install
и отправляемся в lib создавать файлы.
Файлы у нас будут такие:
- client.rb — внешний интерфейс
- direct_service_base.rb — базовый сервис для работы с API
- direct_service_v4.rb — сервис для работы с API 4 и 4 Live
- direct_service_v5.rb — сервис для работы с API 5
- gateway.rb — пересылает и обрабатывает сетевые запросыю=
- url_helper.rb — всякие статические функции, которым не место в gateway.rb
- constants.rb — список доступных методов Yandex DIrect API
- exception.rb — исключение, чтобы ошибки API показывать
- version.rb — служебный файл с настройками версии
Контроллеры для разных версий
Для начала создадим файл с константами, в который и запишем все функции из API.
contants.rb
module Ya
module API
module Direct
API_V5 = {
"Campaigns" => [
"add", "update", "delete", "suspend", "resume", "archive", "unarchive", "get"
],
# и т.д.
}
API_V4 = [
"GetBalance",
# и т.д.
]
API_V4_LIVE = [
"CreateOrUpdateCampaign",
# и т.д.
]
end
end
end
Теперь создадим базовый сервис-обёртку, от которого мы унаследуем сервис для версий 4 и 5.
direct_service_base.rb
module Ya::API::Direct
class DirectServiceBase
attr_reader :method_items, :version
def initialize(client, methods_data)
@client = client
@method_items = methods_data
init_methods
end
protected
def init_methods
@method_items.each do |method|
self.class.send :define_method, method do |params = {}|
result = exec_request(method, params || {})
callback_by_result result
result[:data]
end
end
end
def exec_request(method, request_body)
client.gateway.request method, request_body, @version
end
def callback_by_result(result={})
end
end
end
В конструкторе он получает исходный клиент и список методов. А потом создаёт их внутри себя через : define_method.
А почему нам не обойтись методом respond_to_missing? (как до сих пор делают многие gem-ы)? Потому что он медленней и не такой удобный. И без того небыстрый интерпретатор попадает в него после исключения и проверки в is_respond_to_missing?… К тому же, созданные таким образом методы попадают в результаты вызова methods, а это удобно для отладки.
Теперь создадим сервис для версий 4 и 4 Live.
direct_service_v4.rb
require "ya/api/direct/constants"
require "ya/api/direct/direct_service_base"
module Ya::API::Direct
class DirectServiceV4 < DirectServiceBase
def initialize(client, methods_data, version = :v4)
super(client, methods_data)
@version = version
end
def exec_request(method, request_body = {})
@client.gateway.request method, request_body, nil, (API_V4_LIVE.include?(method) ? :v4live : @version)
end
end
end
В версии 5 сервер не просто отвечает на запросы пользователя, но ещё и сообщает, сколько баллов потрачено на последнем запросе, сколько осталось и сколько их было в текущей сессии всего. Наш сервис должен уметь их разбирать (но мы пока не написали, как он это сделает). Но мы заранее укажем, что он должен обновлять поля в основном клиентском классе.
direct_service_v5.rb
require "ya/api/direct/direct_service_base"
module Ya::API::Direct
class DirectServiceV5 < DirectServiceBase
attr_reader :service, :service_url
def initialize(client, service, methods_data)
super(client, methods_data)
@service = service
@service_url = service.downcase
@version = :v5
end
def exec_request(method, request_body={})
@client.gateway.request method, request_body, @service_url, @version
end
def callback_by_result(result={})
if result.has_key? :units_data
@client.update_units_data result[:units_data]
end
end
end
end
Кстати, вы заметили, что за вызов запроса отвечает какой-то загадочный gateway?
Gateway и UrlHelper
Класс Gateway обеспечивает запросы. В него переехала большая часть кода из нашего скрипта.
gateway.rb
require "net/http"
require "openssl"
require "json"
require "ya/api/direct/constants"
require "ya/api/direct/url_helper"
module Ya::API::Direct
class Gateway
# конструктор тоже есть
def request(method, params, service = "", version = nil)
ver = version || (service.nil? ? :v4 : :v5)
url = UrlHelper.direct_api_url @config[:mode], ver, service
header = generate_header ver
body = generate_body method, params, ver
uri = URI.parse url
request = Net::HTTP::Post.new(uri.path, initheader = header)
request.body = body.to_json
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = @config[:ssl] ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
response = http.request(request)
if response.kind_of? Net::HTTPSuccess
UrlHelper.parse_data response, ver
else
raise response.inspect
end
end
# а чуть ниже объявлены generate_header и generate_body
# они есть в исходниках, поэтому обрезаны
end
end
Стандартый Net: HTTP задействован, потому что прост как грабли. Вполне можно посылать запросы и из faraday. На ней и так работает OmniAuth (про который я расскажу ниже), так что лишними gem-ами приложение не обрастёт.
Наконец, UrlHelper заполняем статичными функциями, которые генерируют URL, разбирают данные и парсят Units (что несложно):
require "json"
require "ya/api/direct/exception"
module Ya::API::Direct
RegExUnits = Regexp.new /(\d+)\/(\d+)\/(\d+)/
class UrlHelper
def self.direct_api_url(mode = :sandbox, version = :v5, service = "")
format = :json
protocol = "https"
api_prefixes = {
sandbox: "api-sandbox",
production: "api"
}
api_prefix = api_prefixes[mode || :sandbox]
site = "%{api}.direct.yandex.ru" % {api: api_prefix}
api_urls = {
v4: {
json: '%{protocol}://%{site}/v4/json',
soap: '%{protocol}://%{site}/v4/soap',
wsdl: '%{protocol}://%{site}/v4/wsdl',
},
v4live: {
json: '%{protocol}://%{site}/live/v4/json',
soap: '%{protocol}://%{site}/live/v4/soap',
wsdl: '%{protocol}://%{site}/live/v4/wsdl',
},
v5: {
json: '%{protocol}://%{site}/json/v5/%{service}',
soap: '%{protocol}://%{site}/v5/%{service}',
wsdl: '%{protocol}://%{site}/v5/%{service}?wsdl',
}
}
api_urls[version][format] % {
protocol: protocol,
site: site,
service: service
}
end
def self.extract_response_units(response_header)
matched = RegExUnits.match response_header["Units"]
matched.nil? ? {} :
{
just_used: matched[1].to_i,
units_left: matched[2].to_i,
units_limit: matched[3].to_i
}
end
private
def self.parse_data(response, ver)
response_body = JSON.parse(response.body)
validate_response! response_body
result = { data: response_body }
if [:v5].include? ver
result.merge!({ units_data: self.extract_response_units(response) })
end
result
end
def self.validate_response!(response_body)
if response_body.has_key? 'error'
response_error = response_body['error']
raise Exception.new(response_error['error_detail'], response_error['error_string'], response_error['error_code'])
end
end
end
end
Если сервер вернул ошибку, мы кидаем Exception с её текстом.
Код выглядит самоочевидным и это весьма хорошо. Самоочевидный код легче поддерживать.
Client
Теперь нам нужно написать сам класс клиента, с которым взаимодействуют внешние интерфейсы. Так как большая часть функционала уже переехала во внутренние классы, то он будет очень коротким.
require "ya/api/direct/constants"
require "ya/api/direct/gateway"
require "ya/api/direct/direct_service_v4"
require "ya/api/direct/direct_service_v5"
require "ya/api/direct/exception"
require 'time'
module Ya::API::Direct
AllowedAPIVersions = [:v5, :v4]
class Client
attr_reader :cache_timestamp, :units_data, :gateway,
:v4, :v5
def initialize(config = {})
@config = {
token: nil,
app_id: nil,
login: '',
locale: 'en',
mode: :sandbox,
format: :json,
cache: true,
api: :v5,
ssl: true
}.merge(config)
@units_data = {
just_used: nil,
units_left: nil,
units_limit: nil
}
raise "Token can't be empty" if @config[:token].nil?
raise "Allowed Yandex Direct API versions are #{AllowedVersions}" unless AllowedAPIVersions.include? @config[:api]
@gateway = Ya::API::Direct::Gateway.new @config
init_v4
init_v5
start_cache! if @config[:cache]
yield self if block_given?
end
def update_units_data(units_data = {})
@units_data.merge! units_data
end
def start_cache!
case @config[:api]
when :v4
result = @gateway.request("GetChanges", {}, nil, :v4live)
timestamp = result[:data]['data']['Timestamp']
when :v5
result = @gateway.request("checkDictionaries", {}, "changes", :v5)
timestamp = result[:data]['result']['Timestamp']
update_units_data result[:units_data]
end
@cache_timestamp = Time.parse(timestamp)
@cache_timestamp
end
private
def init_v4
@v4 = DirectServiceV4.new self, (API_V4 + API_V4_LIVE)
end
def init_v5
@v5 = {}
API_V5.each do |service, methods|
service_item = DirectServiceV5.new(self, service, methods)
service_key = service_item.service_url
@v5[service_key] = service_item
self.class.send :define_method, service_key do @v5[service_key] end
end
end
end
end
Методы версии 4 записываются в свойство v4, методы версии 5, сгруппированные по отдельным сервисам, становятся методами класса клиента через уже знакомую нам конструкцию. Теперь, когда мы вызываем client.campaigns.get Ruby сначала выполнит client.campaigns (), а потом вызовет у полученного сервиса метод get.
Последняя срока конструктора нужна, чтобы класс можно было использовать в конструкции do… end.
Сразу после инициализации же выполняет (если это указано в настройках) start_cache!, чтобы послать API команду на включение кэширования. Версия в настройках влияет только на это, из экземпляра класса можно вызывать методы обоих версий. Полученная дата будет доступна в свойстве cache_timestamp.
А в свойстве units_data будут лежать последние сведения по Units.
Также в проекте есть класс с настройками версии и исключения. С ними всё настолько понятно, что даже и сказать нечего. Класс с настройками версий и вовсе сгенерирован bundle вместе с проектом.
Ну, а файле direct.rb нужно указать те классы, которые должны быть видны пользователю снаружи. В нашем случае это только класс клиента. Плюс версия и исключение (он они совсем служебные).
Компилируем и заливаем
Чтобы cкомпилировать gem, можно следовать мануалу с RubyGems.org (там ничего сложного). Или применить Mountable Engine из Rails.
А потом загружаем на rubygems — вдруг этот gem может быть полезен не только нам.
Как получить token из Ruby on Rails
Войти из Rails в Yandec API и получить токен — дело очень простое для любого разработчика… если не в первый раз.
Как мы уже узнали, для доступа к Direct API требуется токен. Из справки от Яндекса следует, что перед нами — старый добрый OAuth2, которым пользуется куча сервисов, включая Twitter и Facebook.
Для Ruby есть классический gem omniauth, от которого и наследуют реализации OAuth2 для различных сервисов. Уже реализован и omniauth-yandex. С ним мы и попытаемся разобраться.
Создадим новое rails приложение (добавлять в рабочие проекты будем после того, как научимся). Добавляем в Gemfile:
gem "omniauth-yandex"
И делаем bundle install.
А потом пользуемся любым мануалом по установке Omniauth-аутенфикации для rails. Вот пример для twitter. Переводить и пересказывать его, я думаю, ене стоит — статья и так получилась огромная.
У меня описанный в статье пример заработал. Единственной поправкой было то, что я не стал писать в таблицу User дополнительные индексы, потому что их не поддерживает SQLite.
Правда, в статье не указано, где скрывается token. Но это совсем не секрет. В SessionController его можно будет получить через
request.env['omniauth.auth'].credentials.token
Только не забывайте — каждая такая аутенфикация генерирует token заново. И если вы потом попытаетесь использовать скрипты с прямым указанием token, то сервер будет говорить, что старый уже не подходит. Надо вернуться в настройки приложения Яндекса, снова указать отладочный callback URL (__https://oauth.yandex.ru/verification_code__), а затем заново сгенерировать token.
А ещё лучше — создать для статичного токена отдельное приложение, чтобы отлаживать не мешал.
Ссылки
- ya-api-direct на RubyGems
ya-api-direct на GitHub
- Cправки по Yandex OAuth
- Справка по Yandex Direct API
- Онлайн-курс по Yandex Direct API
Настройка omniauth-twitter
- omniauth-yandex
- webmock