Пишем 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

Комментарии (0)

© Habrahabr.ru