whichdigital/active-rest-client

View on GitHub
lib/active_rest_client/request.rb

Summary

Maintainability
F
5 days
Test Coverage
require "cgi"
require "multi_json"
require 'crack'
require 'crack/xml'

module ActiveRestClient

  class Request
    attr_accessor :post_params, :get_params, :url, :path, :headers, :method, :object, :body, :forced_url, :original_url

    def initialize(method, object, params = {})
      @method                     = method
      @method[:options]           ||= {}
      @method[:options][:lazy]    ||= []
      @method[:options][:has_one] ||= {}
      @overridden_name             = @method[:options][:overridden_name]
      @object                     = object
      @response_delegate          = ActiveRestClient::RequestDelegator.new(nil)
      @params                     = params
      @headers                    = HeadersList.new
    end

    def object_is_class?
      !@object.respond_to?(:dirty?)
    end

    def class_name
      if object_is_class?
        @object.name
      else
        @object.class.name
      end
    end

    def original_object_class
      if object_is_class?
        @object
      else
        @object.class
      end
    end

    def base_url
      if object_is_class?
        @object.base_url
      else
        @object.class.base_url
      end
    end

    def using_api_auth?
      if object_is_class?
        @object.using_api_auth?
      else
        @object.class.using_api_auth?
      end
    end

    def api_auth_access_id
      if object_is_class?
        @object.api_auth_access_id
      else
        @object.class.api_auth_access_id
      end
    end

    def api_auth_secret_key
      if object_is_class?
        @object.api_auth_secret_key
      else
        @object.class.api_auth_secret_key
      end
    end

    def username
      if object_is_class?
        @object.username
      else
        @object.class.username
      end
    end

    def password
      if object_is_class?
        @object.password
      else
        @object.class.password
      end
    end

    def request_body_type
      if @method[:options][:request_body_type]
        @method[:options][:request_body_type]
      elsif object_is_class?
        @object.request_body_type
      else
        @object.class.request_body_type
      end
    end

    def verbose?
      if object_is_class?
        @object.verbose
      else
        @object.class.verbose
      end
    end

    def translator
      if object_is_class?
        @object.translator
      else
        @object.class.translator
      end
    end

    def proxy
      if object_is_class?
        @object.proxy
      else
        @object.class.proxy
      end
    rescue
      nil
    end

    def http_method
      @method[:method]
    end

    def call(explicit_parameters=nil)
      @instrumentation_name = "#{class_name}##{@method[:name]}"
      result = nil
      cached = nil
      ActiveSupport::Notifications.instrument("request_call.active_rest_client", :name => @instrumentation_name) do
        @explicit_parameters = explicit_parameters
        @body = nil
        prepare_params
        prepare_url
        if fake = @method[:options][:fake]
          if fake.respond_to?(:call)
            fake = fake.call(self)
          end
          ActiveRestClient::Logger.debug "  \033[1;4;32m#{ActiveRestClient::NAME}\033[0m #{@instrumentation_name} - Faked response found"
          content_type = @method[:options][:fake_content_type] || "application/json"
          return handle_response(OpenStruct.new(status:200, body:fake, response_headers:{"X-ARC-Faked-Response" => "true", "Content-Type" => content_type}))
        end
        if object_is_class?
          @object.send(:_filter_request, :before, @method[:name], self)
        else
          @object.class.send(:_filter_request, :before, @method[:name], self)
        end
        append_get_parameters
        prepare_request_body
        self.original_url = self.url
        cached = original_object_class.read_cached_response(self)
        if cached
          if cached.expires && cached.expires > Time.now
            ActiveRestClient::Logger.debug "  \033[1;4;32m#{ActiveRestClient::NAME}\033[0m #{@instrumentation_name} - Absolutely cached copy found"
            return handle_cached_response(cached)
          elsif cached.etag.to_s != "" #present? isn't working for some reason
            ActiveRestClient::Logger.debug "  \033[1;4;32m#{ActiveRestClient::NAME}\033[0m #{@instrumentation_name} - Etag cached copy found with etag #{cached.etag}"
            etag = cached.etag
          end
        end

        response = (
          if proxy
            proxy.handle(self) do |request|
              request.do_request(etag)
            end
          else
            do_request(etag)
          end
        )

        # This block is called immediately when this request is not inside a parallel request block.
        # Otherwise this callback is called after the parallel request block ends.
        response.on_complete do |response_env|
          if verbose?
            ActiveRestClient::Logger.debug "  Response"
            ActiveRestClient::Logger.debug "  << Status : #{response_env.status}"
            response_env.response_headers.each do |k,v|
              ActiveRestClient::Logger.debug "  << #{k} : #{v}"
            end
            ActiveRestClient::Logger.debug "  << Body:\n#{response_env.body}"
          end

          if object_is_class? && @object.record_response?
            @object.record_response(self.url, response_env)
          end
          if object_is_class?
            @object.send(:_filter_request, :after, @method[:name], response_env)
          else
            @object.class.send(:_filter_request, :after, @method[:name], response_env)
          end

          result = handle_response(response_env, cached)
          @response_delegate.__setobj__(result)
          original_object_class.write_cached_response(self, response_env, result)
        end

        # If this was not a parallel request just return the original result
        return result if response.finished?
        # Otherwise return the delegate which will get set later once the call back is completed
        return @response_delegate
      end
    end

    def prepare_params
      params = @params || @object._attributes rescue {}
      if params.is_a?(String) || params.is_a?(Fixnum)
        params = {id:params}
      end

      default_params = @method[:options][:defaults] || {}

      if @explicit_parameters
        params = @explicit_parameters
      end
      if http_method == :get
        @get_params = default_params.merge(params || {})
        @post_params = nil
      else
        @post_params = default_params.merge(params || {})
        @get_params = {}
      end
    end

    def prepare_url
      if @forced_url && @forced_url.present?
        @url = @forced_url
      else
        @url = @method[:url].dup
        matches = @url.scan(/(:[a-z_-]+)/)
        @get_params ||= {}
        @post_params ||= {}
        matches.each do |token|
          token = token.first[1,999]
          target = @get_params.delete(token.to_sym) || @post_params.delete(token.to_sym) || @get_params.delete(token.to_s) || @post_params.delete(token.to_s) || ""
          @url.gsub!(":#{token}", target.to_s)
        end
      end
    end

    def append_get_parameters
      if @get_params.any?
        @url += "?" + @get_params.to_query
      end
    end

    def prepare_request_body(params = nil)
      if request_body_type == :form_encoded
        @body ||= (params || @post_params || {}).to_query
        headers["Content-Type"] ||= "application/x-www-form-urlencoded"
      elsif request_body_type == :json
        @body ||= (params || @post_params || {}).to_json
        headers["Content-Type"] ||= "application/json; charset=utf-8"
      end
    end

    def do_request(etag)
      http_headers = {}
      http_headers["If-None-Match"] = etag if etag
      http_headers["Accept"] = "application/hal+json, application/json;q=0.5"
      headers.each do |key,value|
        value = value.join(",") if value.is_a?(Array)
        http_headers[key] = value
      end
      if @method[:options][:url] || @forced_url
        @url = @method[:options][:url] || @method[:url]
        @url = @forced_url if @forced_url
        if connection = ActiveRestClient::ConnectionManager.find_connection_for_url(@url)
          @url = @url.slice(connection.base_url.length, 255)
        else
          parts = @url.match(%r{^(https?://[a-z\d\.:-]+?)(/.*)}).to_a
          if (parts.empty?) # Not a full URL, so use hostname/protocol from existing base_url
            uri = URI.parse(base_url)
            @base_url = "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port != 80 && uri.port != 443}"
            @url = "#{base_url}#{@url}".gsub(@base_url, "")
          else
            _, @base_url, @url = parts
          end
          base_url.gsub!(%r{//(.)}, "//#{username}:#{password}@\\1") if username && !base_url[%r{//[^/]*:[^/]*@}]
          connection = ActiveRestClient::ConnectionManager.get_connection(@base_url)
        end
      else
        parts = @url.match(%r{^(https?://[a-z\d\.:-]+?)(/.*)}).to_a
        if (parts.empty?) # Not a full URL, so use hostname/protocol from existing base_url
          uri = URI.parse(base_url)
          @base_url = "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port != 80 && uri.port != 443}"
          @url = "#{base_url}#{@url}".gsub(@base_url, "")
          base_url = @base_url
        end
        base_url.gsub!(%r{//(.)}, "//#{username}:#{password}@\\1") if username && !base_url[%r{//[^/]*:[^/]*@}]
        connection = ActiveRestClient::ConnectionManager.get_connection(base_url)
      end
      ActiveRestClient::Logger.info "  \033[1;4;32m#{ActiveRestClient::NAME}\033[0m #{@instrumentation_name} - Requesting #{connection.base_url}#{@url}"

      if verbose?
        ActiveRestClient::Logger.debug "ActiveRestClient Verbose Log:"
        ActiveRestClient::Logger.debug "  Request"
        ActiveRestClient::Logger.debug "  >> #{http_method.upcase} #{@url} HTTP/1.1"
        http_headers.each do |k,v|
          ActiveRestClient::Logger.debug "  >> #{k} : #{v}"
        end
        ActiveRestClient::Logger.debug "  >> Body:\n#{@body}"
      end

      request_options = {:headers => http_headers}
      if using_api_auth?
        request_options[:api_auth] = {
          :api_auth_access_id => api_auth_access_id,
          :api_auth_secret_key => api_auth_secret_key
        }
      end

      case http_method
      when :get
        response = connection.get(@url, request_options)
      when :put
        response = connection.put(@url, @body, request_options)
      when :post
        response = connection.post(@url, @body, request_options)
      when :delete
        response = connection.delete(@url, request_options)
      else
        raise InvalidRequestException.new("Invalid method #{http_method}")
      end

      response
    end

    def handle_cached_response(cached)
      if cached.result.is_a? ActiveRestClient::ResultIterator
        cached.result
      else
        if object_is_class?
          cached.result
        else
          @object._copy_from(cached.result)
          @object
        end
      end
    end

    def handle_response(response, cached = nil)
      @response = response
      status = @response.status || 200

      if cached && response.status == 304
        ActiveRestClient::Logger.debug "  \033[1;4;32m#{ActiveRestClient::NAME}\033[0m #{@instrumentation_name}" +
          ' - Etag copy is the same as the server'
        return handle_cached_response(cached)
      end

      if (200..399).include?(status)
        if @method[:options][:plain]
          return @response = response.body
        elsif is_json_response? || is_xml_response?
          if @response.respond_to?(:proxied) && @response.proxied
            ActiveRestClient::Logger.debug "  \033[1;4;32m#{ActiveRestClient::NAME}\033[0m #{@instrumentation_name} - Response was proxied, unable to determine size"
          else
            ActiveRestClient::Logger.debug "  \033[1;4;32m#{ActiveRestClient::NAME}\033[0m #{@instrumentation_name} - Response received #{@response.body.size} bytes"
          end
          result = generate_new_object(ignore_xml_root: @method[:options][:ignore_xml_root])
        else
          raise ResponseParseException.new(status:status, body:@response.body)
        end
      else
        if is_json_response? || is_xml_response?
          error_response = generate_new_object(mutable: false, ignore_xml_root: @method[:options][:ignore_xml_root])
        else
          error_response = @response.body
        end
        if status == 400
          raise HTTPBadRequestClientException.new(status:status, result:error_response, url:@url)
        elsif status == 401
          raise HTTPUnauthorisedClientException.new(status:status, result:error_response, url:@url)
        elsif status == 403
          raise HTTPForbiddenClientException.new(status:status, result:error_response, url:@url)
        elsif status == 404
          raise HTTPNotFoundClientException.new(status:status, result:error_response, url:@url)
        elsif (400..499).include? status
          raise HTTPClientException.new(status:status, result:error_response, url:@url)
        elsif (500..599).include? status
          raise HTTPServerException.new(status:status, result:error_response, url:@url)
        elsif status == 0
          raise TimeoutException.new("Timed out getting #{response.url}")
        end
      end

      result
    end

    def new_object(attributes, name = nil)
      @method[:options][:has_many] ||= {}
      name = name.to_sym rescue nil
      if @method[:options][:has_many][name]
        overridden_name = name
        object = @method[:options][:has_many][name].new
      elsif @method[:options][:has_one][name]
        overridden_name = name
        object = @method[:options][:has_one][name].new
      else
        object = create_object_instance
      end

      if hal_response? && name.nil?
        attributes = handle_hal_links_embedded(object, attributes)
      end

      attributes.each do |k,v|
        k = k.to_sym
        overridden_name = select_name(k, overridden_name)
        if @method[:options][:lazy].include?(k)
          object._attributes[k] = ActiveRestClient::LazyAssociationLoader.new(overridden_name, v, self, overridden_name:(overridden_name))
        elsif v.is_a? Hash
          object._attributes[k] = new_object(v, overridden_name )
        elsif v.is_a? Array
          object._attributes[k] = ActiveRestClient::ResultIterator.new
          v.each do |item|
            if item.is_a? Hash
              object._attributes[k] << new_object(item, overridden_name)
            else
              object._attributes[k] << item
            end
          end
        else
          if v.to_s[/\d{4}\-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})/]
            object._attributes[k] = DateTime.parse(v)
          else
            object._attributes[k] = v
          end
        end
      end
      object.clean! unless object_is_class?

      object
    end

    def hal_response?
      _, content_type = @response.response_headers.detect{|k,v| k.downcase == "content-type"}
      faked_response = @response.response_headers.detect{|k,v| k.downcase == "x-arc-faked-response"}
      if content_type && content_type.respond_to?(:each)
        content_type.each do |ct|
          return true if ct[%r{application\/hal\+json}i]
          return true if ct[%r{application\/json}i]
        end
        faked_response
      elsif content_type && (content_type[%r{application\/hal\+json}i] || content_type[%r{application\/json}i]) || faked_response
        true
      else
        false
      end
    end

    def handle_hal_links_embedded(object, attributes)
      attributes["_links"] = attributes[:_links] if attributes[:_links]
      attributes["_embedded"] = attributes[:_embedded] if attributes[:_embedded]
      if attributes["_links"]
        attributes["_links"].each do |key, value|
          if value.is_a?(Array)
            object._attributes[key.to_sym] ||= ActiveRestClient::ResultIterator.new
            value.each do |element|
              begin
                embedded_version = attributes["_embedded"][key].detect{|embed| embed["_links"]["self"]["href"] == element["href"]}
                object._attributes[key.to_sym] << new_object(embedded_version, key)
              rescue NoMethodError
                object._attributes[key.to_sym] << ActiveRestClient::LazyAssociationLoader.new(key, element, self)
              end
            end
          else
            begin
              embedded_version = attributes["_embedded"][key]
              object._attributes[key.to_sym] = new_object(embedded_version, key)
            rescue NoMethodError
              object._attributes[key.to_sym] = ActiveRestClient::LazyAssociationLoader.new(key, value, self)
            end
          end
        end
        attributes.delete("_links")
        attributes.delete("_embedded")
      end

      attributes
    end

    private

    def create_object_instance
      return object_is_class? ? @object.new : @object.class.new
    end

    def select_name(name, parent_name)
      if @method[:options][:has_many][name] || @method[:options][:has_one][name]
        return name
      end

      parent_name || name
    end

    def is_json_response?
      @response.response_headers['Content-Type'].nil? || @response.response_headers['Content-Type'].include?('json')
    end

    def is_xml_response?
      @response.response_headers['Content-Type'].include?('xml')
    end

    def generate_new_object(options={})
      if @response.body.is_a?(Array) || @response.body.is_a?(Hash)
        body = @response.body
      elsif is_json_response?
        body = @response.body.blank? ? {} : MultiJson.load(@response.body)
      elsif is_xml_response?
        body = @response.body.blank? ? {} : Crack::XML.parse(@response.body)
        if options[:ignore_xml_root]
          body = body[options[:ignore_xml_root].to_s]
        end
      end
      body = begin
        @method[:name].nil? ? body : translator.send(@method[:name], body)
      rescue NoMethodError
        body
      end
      if body.is_a? Array
        result = ActiveRestClient::ResultIterator.new(@response)
        body.each do |json_object|
          result << new_object(json_object, @overridden_name)
        end
      else
        result = new_object(body, @overridden_name)
        result._status = @response.status
        result._headers = @response.response_headers
        result._etag = @response.response_headers['ETag']
        if !object_is_class? && options[:mutable] != false
          @object._copy_from(result)
          @object._clean!
          result = @object
        end
      end
      result
    end
  end

  class RequestException < StandardError ; end

  class InvalidRequestException < RequestException ; end
  class ResponseParseException < RequestException
    attr_accessor :status, :body
    def initialize(options)
      @status = options[:status]
      @body = options[:body]
    end
  end

  class HTTPException < RequestException
    attr_accessor :status, :result, :request_url
    def initialize(options)
      @status = options[:status]
      @result = options[:result]
      @request_url = options[:url]
    end
  end
  class HTTPClientException < HTTPException ; end
  class HTTPUnauthorisedClientException < HTTPClientException ; end
  class HTTPBadRequestClientException < HTTPClientException ; end
  class HTTPForbiddenClientException < HTTPClientException ; end
  class HTTPNotFoundClientException < HTTPClientException ; end
  class HTTPServerException < HTTPException ; end

end