lstejskal/adapi

View on GitHub
lib/adapi/api.rb

Summary

Maintainability
C
7 hrs
Test Coverage
# encoding: utf-8

# Basic adapi class, parent of all service classes

# TODO create universal Api.attributes method (instead of having the same method in all subclasses)
# TODO create universal Api.initialize method (some subclasses don't have to have their own initialize method)
# TODO move common methods into separate Common class or module

module Adapi
  class Api
    extend ActiveModel::Naming
    include ActiveModel::Validations
    include ActiveModel::Conversion

    LOGGER = Config.setup_logger

    API_EXCEPTIONS = [
      AdsCommon::Errors::ApiException, 
      AdsCommon::Errors::HttpError, 
      AdwordsApi::Errors::ApiException
    ]

    # these exceptions help to control program flow
    # during complex operations over campaigns and ad_groups
    # 
    class ApiError < Exception; end
    class CampaignError < Exception; end
    class AdGroupError < Exception; end

    attr_accessor :adwords, :service, :version, :params,
      :id, :status, :xsi_type

    # Returns hash of attributes for a model instance
    #
    # This is an implementation of ActiveRecord::Base#attributes method.
    # Children of API model customize this method for their own attributes.
    #
    def attributes
      { status: status, xsi_type: xsi_type }
    end

    def initialize(params = {})
      params.symbolize_keys!

      raise "Missing Service Name" unless params[:service_name]

      @adwords = params[:adwords_api_instance]

      # REFACTOR
      unless @adwords
        @adwords = AdwordsApi::Api.new(Adapi::Config.read)

        authentication_method = Adapi::Config.read[:authentication][:method].to_s.upcase

        case authentication_method
        when "CLIENTLOGIN", "OAUTH"
          warn "#{authentication_method} is nearly obsolete, please update to OAuth2"
        when "OAUTH2_JWT"
          raise "OAUTH2_JWT is not yet implemented, please use OAUTH2 instead"
        # authorize to oauth2
        when "OAUTH2"
          oauth2_token = Adapi::Config.read[:authentication][:oauth2_token]

          if oauth2_token.nil? || oauth2_token.class != Hash 
            raise "Missing or invalid OAuth2 token"
          end

          @adwords.authorize({:oauth2_verification_code => $token})
        end
      end

      @adwords.logger = LOGGER if LOGGER
      @version = API_VERSION
      @service = @adwords.service(params[:service_name].to_sym, @version)
      @params = params
    end

    def to_param
      self[:id]
    end

    def persisted?
      false
    end

    # FIXME hotfix, should be able to sort it out better through ActiveModel
    def [](k)
      self.send(k)
    end

    def []=(k,v)
      self.send("#{k}=", v)
    end

    alias :to_hash :attributes

    # detects whether the instance has been saved already
    #
    def new?
      self.id.blank?
    end

    def self.create(params = {})
      # FIXME deep symbolize_keys, probably through ActiveSupport
      params.symbolize_keys! if params.is_a?(Hash)

      api_instance = self.new(params)
      api_instance.create
      api_instance
    end

    # done mostly for campaign, probably won't work pretty much anywhere else
    # which can be easily fixed creating by self.update method for specific
    # class
    #
    def self.update(params = {})
      params.symbolize_keys!

      # PS: updating campaign without finding it is much faster
      api_instance = self.new()
      api_instance.id = params.delete(:id)
      api_instance.errors.add('id', 'is missing') unless api_instance.id
      
      api_instance.update(params)
      api_instance
    end

    # wrap AdWords add/update/destroy actions and deals with errors
    # PS: Keyword and Ad models have their own wrappers because of
    # PolicyViolations
    #
    def mutate(operation)      
      operation = [operation] unless operation.is_a?(Array)
      
      # fix to save space during specifyng operations
      operation = operation.map do |op|
        op[:operand].delete(:status) if op[:operand][:status].nil?
        op
      end
      
      begin

        response = @service.mutate(operation)

      rescue *API_EXCEPTIONS => e

        unless e.respond_to?(:errors)
          self.errors.add(:base, e.message)
          return false
        end

        e.errors.each do |error|
          if (error[:xsi_type] == 'PolicyViolationError') || (error[:api_error_type] == 'PolicyViolationError')
            # return exemptable PolicyViolations errors in custom format so we can request exemptions
            # see adwords-api gem example for details: handle_policy_violation_error.rb
            # so far, this applies only for keywords and ads
            if error[:is_exemptable]
              self.errors.add( :PolicyViolationError, error[:key].merge(
                :operation_index => AdwordsApi::Utils.operation_index_for_error(error)
              ) )
            end

            # besides PolicyViolations errors in custom format, return all errors also in regular format
            self.errors.add(:base, "violated %s policy: \"%s\" on \"%s\"" % [
              error[:is_exemptable] ? 'exemptable' : 'non-exemptable', 
              error[:key][:policy_name], 
              error[:key][:violating_text]
            ])
          else
            self.errors.add(:base, e.message)
          end
        end # of errors.each

        false
      end
      
      response
    end

    # Deals with campaign exceptions encountered during complex operations over AdWords API
    # 
    # Parameters:
    # store_errors (default: true) - add errors to self.error collection
    # raise_errors (default: false) - raises exception CampaignError (after optional saving errors)
    #
    def check_for_errors(adapi_instance, options = {})
      options.merge!( store_errors: true, raise_errors: false )

      # don't store errors in this case, because errors are already there
      # and loop in store_errors method would cause application to hang
      options[:store_errors] = false if (adapi_instance == self)

      unless adapi_instance.errors.empty?
        store_errors(adapi_instance, options[:prefix]) if options[:store_errors]

        if options[:raise_errors]
          exception_type = case adapi_instance.xsi_type
            when "Campaign" then CampaignError
            when "AdGroup" then AdGroupError
            else ApiError
          end

          raise exception_type
        end
      end
    end

    # Shortcut for pattern used in Campaign#update method 
    # When partial update fails, store errors in main campaign instance 
    #
    def store_errors(failed_instance, error_prefix = nil)
      raise "#{failed_instance.xsi_type}#store_errors: Invalid object instance" unless failed_instance.respond_to?(:errors)

      error_prefix ||= failed_instance.respond_to?(:xsi_type) ? failed_instance.xsi_type : nil

      failed_instance.errors.messages.each_pair do |k, v|
          k = "#{error_prefix}::#{k}" if error_prefix and (k != :base)

          Array(v).each do |x| 
            self.errors.add(k, x)
          end
      end
    end

    # convert number to micro units (unit * one million)
    #
    def self.to_micro_units(x)
      (x.to_f * 1e6).to_i
    end

  end
end