Пишем 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(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: "YOUR LOGIN HERE", 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", 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.


    А ещё лучше — создать для статичного токена отдельное приложение, чтобы отлаживать не мешал.


    Ссылки


    Поделиться публикацией
    Ой, у вас баннер убежал!

    Ну, и что?
    Реклама
    Комментарии 0

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое