lib/typhoeus/remote.rb



module Typhoeus
  USER_AGENT = "Typhoeus - http://github.com/dbalatero/typhoeus/tree/master"
  
  def self.included(base)
    base.extend ClassMethods
  end

  class MockExpectedError < StandardError; end
    
  module ClassMethods
    def allow_net_connect
      @allow_net_connect = true if @allow_net_connect.nil?
      @allow_net_connect
    end

    def allow_net_connect=(value)
      @allow_net_connect = value
    end
    
    def mock(method, args = {})
      @remote_mocks ||= {}
      @remote_mocks[method] ||= {}
      args[:code]    ||= 200
      args[:body]    ||= ""
      args[:headers] ||= ""
      args[:time]    ||= 0
      url = args.delete(:url)
      url ||= :catch_all
      params = args.delete(:params)

      key = mock_key_for(url, params)

      @remote_mocks[method][key] = args
    end

    # Returns a key for a given URL and passed in 
    # set of Typhoeus options to be used to store/retrieve
    # a corresponding mock.
    def mock_key_for(url, params = nil)
      if url == :catch_all
        url
      else
        key = url
        if params and !params.empty?
          key += flatten_and_sort_hash(params).to_s
        end
        key
      end
    end

    def flatten_and_sort_hash(params)
      params = params.dup

      # Flatten any sub-hashes to a single string.
      params.keys.each do |key|
        if params[key].is_a?(Hash)
          params[key] = params[key].sort_by { |k, v| k.to_s.downcase }.to_s
        end
      end

      params.sort_by { |k, v| k.to_s.downcase }
    end
    
    def get_mock(method, url, options)
      return nil unless @remote_mocks
      if @remote_mocks.has_key? method
        extra_response_args = { :requested_http_method => method,
                                :requested_url => url,
                                :start_time => Time.now }
        mock_key = mock_key_for(url, options[:params])
        if @remote_mocks[method].has_key? mock_key
          get_mock_and_run_handlers(method,
                                    @remote_mocks[method][mock_key].merge(
                                      extra_response_args),
                                    options)
        elsif @remote_mocks[method].has_key? :catch_all
          get_mock_and_run_handlers(method,
                                    @remote_mocks[method][:catch_all].merge(
                                      extra_response_args),
                                    options)
        else
          nil
        end
      else
        nil
      end
    end

    def enforce_allow_net_connect!(http_verb, url, params = nil)
      if !allow_net_connect
        message = "Real HTTP connections are disabled. Unregistered request: " <<
                  "#{http_verb.to_s.upcase} #{url}\n" <<
                  "  Try: mock(:#{http_verb}, :url => \"#{url}\""
        if params
          message << ",\n            :params => #{params.inspect}"
        end

        message << ")"

        raise MockExpectedError, message
      end
    end

    def check_expected_headers!(response_args, options)
      missing_headers = {}

      response_args[:expected_headers].each do |key, value|
        if options[:headers].nil?
          missing_headers[key] = [value, nil]
        elsif ((options[:headers][key] && value != :anything) &&
           options[:headers][key] != value)

          missing_headers[key] = [value, options[:headers][key]]
        end
      end

      unless missing_headers.empty?
        raise headers_error_summary(response_args, options, missing_headers, 'expected')
      end
    end

    def check_unexpected_headers!(response_args, options)
      bad_headers = {}
      response_args[:unexpected_headers].each do |key, value|
        if (options[:headers][key] && value == :anything) ||
           (options[:headers][key] == value)
          bad_headers[key] = [value, options[:headers][key]]
        end
      end

      unless bad_headers.empty?
        raise headers_error_summary(response_args, options, bad_headers, 'did not expect')
      end
    end

    def headers_error_summary(response_args, options, missing_headers, lead_in)
      error = "#{lead_in} the following headers: #{response_args[:expected_headers].inspect}, but received: #{options[:headers].inspect}\n\n"
      error   << "Differences:\n"
      error   << "------------\n"
      missing_headers.each do |key, values|
        error << "  - #{key}: #{lead_in} #{values[0].inspect}, got #{values[1].inspect}\n"
      end

      error
    end
    private :headers_error_summary

    def get_mock_and_run_handlers(method, response_args, options)
      response = Response.new(response_args)
     
      if response_args.has_key? :expected_body
        raise "#{method} expected body of \"#{response_args[:expected_body]}\" but received #{options[:body]}" if response_args[:expected_body] != options[:body]
      end
      
      if response_args.has_key? :expected_headers
        check_expected_headers!(response_args, options)
      end

      if response_args.has_key? :unexpected_headers
        check_unexpected_headers!(response_args, options)
      end

      if response.code >= 200 && response.code < 300 && options.has_key?(:on_success)
        response = options[:on_success].call(response)
      elsif options.has_key?(:on_failure)
        response = options[:on_failure].call(response)
      end

      encode_nil_response(response)
    end
       
    [:get, :post, :put, :delete].each do |method|
      line = __LINE__ + 2  # get any errors on the correct line num
      code = <<-SRC
        def #{method.to_s}(url, options = {})
          mock_object = get_mock(:#{method.to_s}, url, options)
          unless mock_object.nil?
            decode_nil_response(mock_object)
          else
            enforce_allow_net_connect!(:#{method.to_s}, url, options[:params])
            remote_proxy_object(url, :#{method.to_s}, options)
          end
        end
      SRC
      module_eval(code, "./lib/typhoeus/remote.rb", line)
    end
    
    def remote_proxy_object(url, method, options)
      easy = Typhoeus.get_easy_object
      
      easy.url                   = url
      easy.method                = method
      easy.headers               = options[:headers] if options.has_key?(:headers)
      easy.headers["User-Agent"] = (options[:user_agent] || Typhoeus::USER_AGENT)
      easy.params                = options[:params] if options[:params]
      easy.request_body          = options[:body] if options[:body]
      easy.timeout               = options[:timeout] if options[:timeout]
      easy.set_headers
      
      proxy = Typhoeus::RemoteProxyObject.new(clear_memoized_proxy_objects, easy, options)
      set_memoized_proxy_object(method, url, options, proxy)
    end
    
    def remote_defaults(options)
      @remote_defaults ||= {}
      @remote_defaults.merge!(options) if options
      @remote_defaults
    end

    # If we get subclassed, make sure that child inherits the remote defaults
    # of the parent class.
    def inherited(child)
      child.__send__(:remote_defaults, @remote_defaults)
    end
    
    def call_remote_method(method_name, args)
      m = @remote_methods[method_name]
      
      base_uri = args.delete(:base_uri) || m.base_uri || ""

      if args.has_key? :path
        path = args.delete(:path)
      else
        path = m.interpolate_path_with_arguments(args)
      end
      path ||= ""
      
      http_method = m.http_method
      url         = base_uri + path
      options     = m.merge_options(args)
      
      # proxy_object = memoized_proxy_object(http_method, url, options)
      # return proxy_object unless proxy_object.nil?
      # 
      # if m.cache_responses?
      #   object = @cache.get(get_memcache_response_key(method_name, args))
      #   if object
      #     set_memoized_proxy_object(http_method, url, options, object)
      #     return object
      #   end
      # end

      proxy = memoized_proxy_object(http_method, url, options)
      unless proxy
        if m.cache_responses?
          options[:cache] = @cache
          options[:cache_key] = get_memcache_response_key(method_name, args)
          options[:cache_timeout] = m.cache_ttl
        end
        proxy = send(http_method, url, options)
      end
      proxy
    end
    
    def set_memoized_proxy_object(http_method, url, options, object)
      @memoized_proxy_objects ||= {}
      @memoized_proxy_objects["#{http_method}_#{url}_#{options.to_s}"] = object
    end
    
    def memoized_proxy_object(http_method, url, options)
      @memoized_proxy_objects ||= {}
      @memoized_proxy_objects["#{http_method}_#{url}_#{options.to_s}"]
    end
    
    def clear_memoized_proxy_objects
      lambda { @memoized_proxy_objects = {} }
    end

    def get_memcache_response_key(remote_method_name, args)
      result = "#{remote_method_name.to_s}-#{args.to_s}"
      (Digest::SHA2.new << result).to_s
    end
    
    def cache=(cache)
      @cache = cache
    end
    
    def define_remote_method(name, args = {})
      @remote_defaults  ||= {}
      args[:method]     ||= @remote_defaults[:method]
      args[:on_success] ||= @remote_defaults[:on_success]
      args[:on_failure] ||= @remote_defaults[:on_failure]
      args[:base_uri]   ||= @remote_defaults[:base_uri]
      args[:path]       ||= @remote_defaults[:path]
      m = RemoteMethod.new(args)

      @remote_methods ||= {}
      @remote_methods[name] = m

      class_eval <<-SRC
        def self.#{name.to_s}(args = {})
          call_remote_method(:#{name.to_s}, args)
        end
      SRC
    end

    private
    def encode_nil_response(response)
      response == nil ? :__nil__ : response
    end

    def decode_nil_response(response)
      response == :__nil__ ? nil : response
    end
  end # ClassMethods
end