twitterdev/twitter-ruby-ads-sdk

View on GitHub
lib/twitter-ads/http/request.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# frozen_string_literal: true
# Copyright (C) 2019 Twitter, Inc.

module TwitterAds

  # Generic container for API requests.
  class Request

    attr_reader :client, :method, :resource, :options

    HTTP_METHOD = {
      get:    Net::HTTP::Get,
      post:   Net::HTTP::Post,
      put:    Net::HTTP::Put,
      delete: Net::HTTP::Delete
    }.freeze

    DEFAULT_DOMAIN = 'https://ads-api.twitter.com'
    SANDBOX_DOMAIN = 'https://ads-api-sandbox.twitter.com'

    private_constant :DEFAULT_DOMAIN, :SANDBOX_DOMAIN, :HTTP_METHOD

    # Creates a new Request object instance.
    #
    # @example
    #   request = Request.new(client, :get, "/#{TwitterAds::API_VERSION}/accounts")
    #
    # @param client [Client] The Client object instance.
    # @param method [Symbol] The HTTP method to be used.
    # @param resource [String] The resource path for the request.
    #
    # @param opts [Hash] An optional Hash of extended options.
    # @option opts [String] :domain Forced override for default domain to use for the request. This
    #   value will also override :sandbox mode on the client.
    #
    # @since 0.1.0
    #
    # @return [Request] The Request object instance.
    def initialize(client, method, resource, opts = {})
      @client   = client
      @method   = method
      @resource = resource
      @options  = opts
      self
    end

    # Executes the current Request object.
    #
    # @example
    #   request = Request.new(client, :get, "/#{TwitterAds::API_VERSION}/accounts")
    #   request.perform
    #
    # @since  0.1.0
    #
    # @return [Response] The Response object instance generated by the Request.
    def perform
      handle_error(oauth_request)
    end

    private

    def domain
      @domain ||= begin
        @options[:domain] || (@client.options[:sandbox] ? SANDBOX_DOMAIN : DEFAULT_DOMAIN)
      end
    end

    def oauth_request
      request  = http_request
      consumer = OAuth::Consumer.new(@client.consumer_key, @client.consumer_secret, site: domain)
      token    = OAuth::AccessToken.new(consumer, @client.access_token, @client.access_token_secret)
      request.oauth!(consumer.http, consumer, token)

      handle_rate_limit = @client.options.fetch(:handle_rate_limit, false)
      retry_max         = @client.options.fetch(:retry_max, 0)
      retry_delay       = @client.options.fetch(:retry_delay, 1500)
      retry_on_status   = @client.options.fetch(:retry_on_status, [500, 503])
      retry_count       = 0
      retry_after       = nil

      write_log(request) if @client.options[:trace]
      while retry_count <= retry_max
        response = consumer.http.request(request)
        status_code = response.code.to_i
        break if status_code >= 200 && status_code < 300

        if handle_rate_limit && retry_after.nil?
          rate_limit_reset = response.fetch('x-account-rate-limit-reset', nil) ||
                             response.fetch('x-rate-limit-reset', nil)
          if status_code == 429
            retry_after = rate_limit_reset.to_i - Time.now.to_i
            @client.logger.warn('Request reached Rate Limit: resume in %d seconds' % retry_after)
            sleep(retry_after + 5)
            next
          end
        end

        if retry_max.positive?
          break unless retry_on_status.include?(status_code)
          sleep(retry_delay / 1000)
        end

        retry_count += 1
      end
      write_log(response) if @client.options[:trace]

      Response.new(response.code, response.each {}, response.body)
    end

    def escape_params(input)
      input.map do |key, value|
        "#{CGI.escape key.to_s}=#{CGI.escape value.to_s}"
      end.join('&')
    end

    def http_request
      request_url = @resource

      if @options[:params] && !@options[:params].empty?
        request_url += "?#{escape_params(@options[:params])}"
      end

      request      = HTTP_METHOD[@method].new(request_url)
      request.body = @options[:body] if @options[:body]

      @options[:headers]&.each { |header, value| request[header] = value }
      request['user-agent'] = user_agent

      request
    end

    def user_agent
      "twitter-ads version: #{TwitterAds::VERSION} " \
      "platform: #{RUBY_ENGINE} #{RUBY_VERSION} (#{RUBY_PLATFORM})"
    end

    def write_log(object)
      if object.respond_to?(:code)
        @client.logger.info("Status: #{object.code} #{object.message}")
      else
        @client.logger.info("Send: #{object.method} #{domain}#{@resource} #{@options[:params]}")
      end

      object.each { |header| @client.logger.info("Header: #{header}: #{object[header]}") }

      # suppresses body content for non-Ads API domains (eg. upload.twitter.com)
      unless object.body&.empty?
        if @domain == SANDBOX_DOMAIN || @domain == DEFAULT_DOMAIN
          @client.logger.info("Body: #{object.body}")
        else
          @client.logger.info('Body: **OMITTED**')
        end
      end
    end

    def handle_error(response)
      raise TwitterAds::Error.from_response(response) unless response.code < 400
      response
    end

  end

end