Пишемо 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 можна взяти звідси:
Про отримання 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(запит)

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(запит)

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 все одно несправжній.

do before
@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'=>'Рубін'},
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 Ja
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-s)? Тому що він повільніше і не такий зручний. І без того нешвидкий інтерпретатор потрапляє в нього після виключення і перевірки 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(запит)
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.ua" % {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 if self 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 потрібно вказати ті класи, які повинні бути користувачеві видно зовні. У нашому випадку це тільки клас клієнта. Плюс версія і виняток (він вони зовсім службові).
Компілюємо і заливаємо
Щоб скомпілювати 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.
А ще краще — створити для статичного токена окремий додаток, щоб налагоджувати не заважав.
Посилання
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.