lib/httparty/request.rb



# frozen_string_literal: true

require 'erb'

module HTTParty
  class Request #:nodoc:
    SupportedHTTPMethods = [
      Net::HTTP::Get,
      Net::HTTP::Post,
      Net::HTTP::Patch,
      Net::HTTP::Put,
      Net::HTTP::Delete,
      Net::HTTP::Head,
      Net::HTTP::Options,
      Net::HTTP::Move,
      Net::HTTP::Copy,
      Net::HTTP::Mkcol,
      Net::HTTP::Lock,
      Net::HTTP::Unlock,
    ]

    SupportedURISchemes  = ['http', 'https', 'webcal', nil]

    NON_RAILS_QUERY_STRING_NORMALIZER = proc do |query|
      Array(query).sort_by { |a| a[0].to_s }.map do |key, value|
        if value.nil?
          key.to_s
        elsif value.respond_to?(:to_ary)
          value.to_ary.map {|v| "#{key}=#{ERB::Util.url_encode(v.to_s)}"}
        else
          HashConversions.to_params(key => value)
        end
      end.flatten.join('&')
    end

    JSON_API_QUERY_STRING_NORMALIZER = proc do |query|
      Array(query).sort_by { |a| a[0].to_s }.map do |key, value|
        if value.nil?
          key.to_s
        elsif value.respond_to?(:to_ary)
          values = value.to_ary.map{|v| ERB::Util.url_encode(v.to_s)}
          "#{key}=#{values.join(',')}"
        else
          HashConversions.to_params(key => value)
        end
      end.flatten.join('&')
    end

    def self._load(data)
      http_method, path, options, last_response, last_uri, raw_request = Marshal.load(data)
      instance = new(http_method, path, options)
      instance.last_response = last_response
      instance.last_uri = last_uri
      instance.instance_variable_set("@raw_request", raw_request)
      instance
    end

    attr_accessor :http_method, :options, :last_response, :redirect, :last_uri
    attr_reader :path

    def initialize(http_method, path, o = {})
      @changed_hosts = false
      @credentials_sent = false

      self.http_method = http_method
      self.options = {
        limit: o.delete(:no_follow) ? 1 : 5,
        assume_utf16_is_big_endian: true,
        default_params: {},
        follow_redirects: true,
        parser: Parser,
        uri_adapter: URI,
        connection_adapter: ConnectionAdapter
      }.merge(o)
      self.path = path
      set_basic_auth_from_uri
    end

    def path=(uri)
      uri_adapter = options[:uri_adapter]

      @path = if uri.is_a?(uri_adapter)
        uri
      elsif String.try_convert(uri)
        uri_adapter.parse(uri).normalize
      else
        raise ArgumentError,
          "bad argument (expected #{uri_adapter} object or URI string)"
      end
    end

    def request_uri(uri)
      if uri.respond_to? :request_uri
        uri.request_uri
      else
        uri.path
      end
    end

    def uri
      if redirect && path.relative? && path.path[0] != '/'
        last_uri_host = @last_uri.path.gsub(/[^\/]+$/, '')

        path.path = "/#{path.path}" if last_uri_host[-1] != '/'
        path.path = "#{last_uri_host}#{path.path}"
      end

      if path.relative? && path.host
        new_uri = options[:uri_adapter].parse("#{@last_uri.scheme}:#{path}").normalize
      elsif path.relative?
        new_uri = options[:uri_adapter].parse("#{base_uri}#{path}").normalize
      else
        new_uri = path.clone
      end

      # avoid double query string on redirects [#12]
      unless redirect
        new_uri.query = query_string(new_uri)
      end

      unless SupportedURISchemes.include? new_uri.scheme
        raise UnsupportedURIScheme, "'#{new_uri}' Must be HTTP, HTTPS or Generic"
      end

      @last_uri = new_uri
    end

    def base_uri
      if redirect
        base_uri = "#{@last_uri.scheme}://#{@last_uri.host}"
        base_uri = "#{base_uri}:#{@last_uri.port}" if @last_uri.port != 80
        base_uri
      else
        options[:base_uri] && HTTParty.normalize_base_uri(options[:base_uri])
      end
    end

    def format
      options[:format] || (format_from_mimetype(last_response['content-type']) if last_response)
    end

    def parser
      options[:parser]
    end

    def connection_adapter
      options[:connection_adapter]
    end

    def perform(&block)
      validate
      setup_raw_request
      chunked_body = nil
      current_http = http

      begin
        self.last_response = current_http.request(@raw_request) do |http_response|
          if block
            chunks = []

            http_response.read_body do |fragment|
              encoded_fragment = encode_text(fragment, http_response['content-type'])
              chunks << encoded_fragment if !options[:stream_body]
              block.call ResponseFragment.new(encoded_fragment, http_response, current_http)
            end

            chunked_body = chunks.join
          end
        end

        handle_host_redirection if response_redirects?
        result = handle_unauthorized
        result ||= handle_response(chunked_body, &block)
        result
      rescue *COMMON_NETWORK_ERRORS => e
        raise options[:foul] ? HTTParty::NetworkError.new("#{e.class}: #{e.message}") : e
      end
    end

    def handle_unauthorized(&block)
      return unless digest_auth? && response_unauthorized? && response_has_digest_auth_challenge?
      return if @credentials_sent
      @credentials_sent = true
      perform(&block)
    end

    def raw_body
      @raw_request.body
    end

    def _dump(_level)
      opts = options.dup
      opts.delete(:logger)
      opts.delete(:parser) if opts[:parser] && opts[:parser].is_a?(Proc)
      Marshal.dump([http_method, path, opts, last_response, @last_uri, @raw_request])
    end

    private

    def http
      connection_adapter.call(uri, options)
    end

    def credentials
      (options[:basic_auth] || options[:digest_auth]).to_hash
    end

    def username
      credentials[:username]
    end

    def password
      credentials[:password]
    end

    def normalize_query(query)
      if query_string_normalizer
        query_string_normalizer.call(query)
      else
        HashConversions.to_params(query)
      end
    end

    def query_string_normalizer
      options[:query_string_normalizer]
    end

    def setup_raw_request
      if options[:headers].respond_to?(:to_hash)
        headers_hash = options[:headers].to_hash
      else
        headers_hash = nil
      end

      @raw_request = http_method.new(request_uri(uri), headers_hash)
      @raw_request.body_stream = options[:body_stream] if options[:body_stream]

      if options[:body]
        body = Body.new(
          options[:body],
          query_string_normalizer: query_string_normalizer,
          force_multipart: options[:multipart]
        )

        if body.multipart?
          content_type = "multipart/form-data; boundary=#{body.boundary}"
          @raw_request['Content-Type'] = content_type
        end
        @raw_request.body = body.call
      end

      @raw_request.instance_variable_set(:@decode_content, decompress_content?)

      if options[:basic_auth] && send_authorization_header?
        @raw_request.basic_auth(username, password)
        @credentials_sent = true
      end
      setup_digest_auth if digest_auth? && response_unauthorized? && response_has_digest_auth_challenge?
    end

    def digest_auth?
      !!options[:digest_auth]
    end

    def decompress_content?
      !options[:skip_decompression]
    end

    def response_unauthorized?
      !!last_response && last_response.code == '401'
    end

    def response_has_digest_auth_challenge?
      !last_response['www-authenticate'].nil? && last_response['www-authenticate'].length > 0
    end

    def setup_digest_auth
      @raw_request.digest_auth(username, password, last_response)
    end

    def query_string(uri)
      query_string_parts = []
      query_string_parts << uri.query unless uri.query.nil?

      if options[:query].respond_to?(:to_hash)
        query_string_parts << normalize_query(options[:default_params].merge(options[:query].to_hash))
      else
        query_string_parts << normalize_query(options[:default_params]) unless options[:default_params].empty?
        query_string_parts << options[:query] unless options[:query].nil?
      end

      query_string_parts.reject!(&:empty?) unless query_string_parts == ['']
      query_string_parts.size > 0 ? query_string_parts.join('&') : nil
    end

    def assume_utf16_is_big_endian
      options[:assume_utf16_is_big_endian]
    end

    def handle_response(raw_body, &block)
      if response_redirects?
        handle_redirection(&block)
      else
        raw_body ||= last_response.body

        body = decompress(raw_body, last_response['content-encoding']) unless raw_body.nil?

        unless body.nil?
          body = encode_text(body, last_response['content-type'])

          if decompress_content?
            last_response.delete('content-encoding')
            raw_body = body
          end
        end

        Response.new(self, last_response, lambda { parse_response(body) }, body: raw_body)
      end
    end

    def handle_redirection(&block)
      options[:limit] -= 1
      if options[:logger]
        logger = HTTParty::Logger.build(options[:logger], options[:log_level], options[:log_format])
        logger.format(self, last_response)
      end
      self.path       = last_response['location']
      self.redirect   = true
      if last_response.class == Net::HTTPSeeOther
        unless options[:maintain_method_across_redirects] && options[:resend_on_redirect]
          self.http_method = Net::HTTP::Get
        end
      elsif last_response.code != '307' && last_response.code != '308'
        unless options[:maintain_method_across_redirects]
          self.http_method = Net::HTTP::Get
        end
      end
      if http_method == Net::HTTP::Get
        clear_body
      end
      capture_cookies(last_response)
      perform(&block)
    end

    def handle_host_redirection
      check_duplicate_location_header
      redirect_path = options[:uri_adapter].parse(last_response['location']).normalize
      return if redirect_path.relative? || path.host == redirect_path.host || uri.host == redirect_path.host
      @changed_hosts = true
    end

    def check_duplicate_location_header
      location = last_response.get_fields('location')
      if location.is_a?(Array) && location.count > 1
        raise DuplicateLocationHeader.new(last_response)
      end
    end

    def send_authorization_header?
      !@changed_hosts
    end

    def response_redirects?
      case last_response
      when Net::HTTPNotModified # 304
        false
      when Net::HTTPRedirection
        options[:follow_redirects] && last_response.key?('location')
      end
    end

    def parse_response(body)
      parser.call(body, format)
    end

    # Some Web Application Firewalls reject incoming GET requests that have a body
    # if we redirect, and the resulting verb is GET then we will clear the body that
    # may be left behind from the initiating request
    def clear_body
      options[:body] = nil
      @raw_request.body = nil
    end

    def capture_cookies(response)
      return unless response['Set-Cookie']
      cookies_hash = HTTParty::CookieHash.new
      cookies_hash.add_cookies(options[:headers].to_hash['Cookie']) if options[:headers] && options[:headers].to_hash['Cookie']
      response.get_fields('Set-Cookie').each { |cookie| cookies_hash.add_cookies(cookie) }

      options[:headers] ||= {}
      options[:headers]['Cookie'] = cookies_hash.to_cookie_string
    end

    # Uses the HTTP Content-Type header to determine the format of the
    # response It compares the MIME type returned to the types stored in the
    # SupportedFormats hash
    def format_from_mimetype(mimetype)
      if mimetype && parser.respond_to?(:format_from_mimetype)
        parser.format_from_mimetype(mimetype)
      end
    end

    def validate
      raise HTTParty::RedirectionTooDeep.new(last_response), 'HTTP redirects too deep' if options[:limit].to_i <= 0
      raise ArgumentError, 'only get, post, patch, put, delete, head, and options methods are supported' unless SupportedHTTPMethods.include?(http_method)
      raise ArgumentError, ':headers must be a hash' if options[:headers] && !options[:headers].respond_to?(:to_hash)
      raise ArgumentError, 'only one authentication method, :basic_auth or :digest_auth may be used at a time' if options[:basic_auth] && options[:digest_auth]
      raise ArgumentError, ':basic_auth must be a hash' if options[:basic_auth] && !options[:basic_auth].respond_to?(:to_hash)
      raise ArgumentError, ':digest_auth must be a hash' if options[:digest_auth] && !options[:digest_auth].respond_to?(:to_hash)
      raise ArgumentError, ':query must be hash if using HTTP Post' if post? && !options[:query].nil? && !options[:query].respond_to?(:to_hash)
    end

    def post?
      Net::HTTP::Post == http_method
    end

    def set_basic_auth_from_uri
      if path.userinfo
        username, password = path.userinfo.split(':')
        options[:basic_auth] = {username: username, password: password}
        @credentials_sent = true
      end
    end

    def decompress(body, encoding)
      Decompressor.new(body, encoding).decompress
    end

    def encode_text(text, content_type)
      TextEncoder.new(
        text,
        content_type: content_type,
        assume_utf16_is_big_endian: assume_utf16_is_big_endian
      ).call
    end
  end
end