lib/http/client.rb



# frozen_string_literal: true

require "forwardable"

require "http/form_data"
require "http/options"
require "http/feature"
require "http/headers"
require "http/connection"
require "http/redirector"
require "http/uri"

module HTTP
  # Clients make requests and receive responses
  class Client
    extend Forwardable
    include Chainable

    HTTP_OR_HTTPS_RE = %r{^https?://}i.freeze

    def initialize(default_options = {})
      @default_options = HTTP::Options.new(default_options)
      @connection = nil
      @state = :clean
    end

    # Make an HTTP request
    def request(verb, uri, opts = {})
      opts = @default_options.merge(opts)
      req = build_request(verb, uri, opts)
      res = perform(req, opts)
      return res unless opts.follow

      Redirector.new(opts.follow).perform(req, res) do |request|
        perform(wrap_request(request, opts), opts)
      end
    end

    # Prepare an HTTP request
    def build_request(verb, uri, opts = {})
      opts    = @default_options.merge(opts)
      uri     = make_request_uri(uri, opts)
      headers = make_request_headers(opts)
      body    = make_request_body(opts, headers)

      req = HTTP::Request.new(
        :verb           => verb,
        :uri            => uri,
        :uri_normalizer => opts.feature(:normalize_uri)&.normalizer,
        :proxy          => opts.proxy,
        :headers        => headers,
        :body           => body
      )

      wrap_request(req, opts)
    end

    # @!method persistent?
    #   @see Options#persistent?
    #   @return [Boolean] whenever client is persistent
    def_delegator :default_options, :persistent?

    # Perform a single (no follow) HTTP request
    def perform(req, options)
      verify_connection!(req.uri)

      @state = :dirty

      begin
        @connection ||= HTTP::Connection.new(req, options)

        unless @connection.failed_proxy_connect?
          @connection.send_request(req)
          @connection.read_headers!
        end
      rescue Error => e
        options.features.each_value do |feature|
          feature.on_error(req, e)
        end
        raise
      end
      res = build_response(req, options)

      res = options.features.inject(res) do |response, (_name, feature)|
        feature.wrap_response(response)
      end

      @connection.finish_response if req.verb == :head
      @state = :clean

      res
    rescue
      close
      raise
    end

    def close
      @connection&.close
      @connection = nil
      @state = :clean
    end

    private

    def wrap_request(req, opts)
      opts.features.inject(req) do |request, (_name, feature)|
        feature.wrap_request(request)
      end
    end

    def build_response(req, options)
      Response.new(
        :status        => @connection.status_code,
        :version       => @connection.http_version,
        :headers       => @connection.headers,
        :proxy_headers => @connection.proxy_response_headers,
        :connection    => @connection,
        :encoding      => options.encoding,
        :request       => req
      )
    end

    # Verify our request isn't going to be made against another URI
    def verify_connection!(uri)
      if default_options.persistent? && uri.origin != default_options.persistent
        raise StateError, "Persistence is enabled for #{default_options.persistent}, but we got #{uri.origin}"
      end

      # We re-create the connection object because we want to let prior requests
      # lazily load the body as long as possible, and this mimics prior functionality.
      return close if @connection && (!@connection.keep_alive? || @connection.expired?)

      # If we get into a bad state (eg, Timeout.timeout ensure being killed)
      # close the connection to prevent potential for mixed responses.
      return close if @state == :dirty
    end

    # Merges query params if needed
    #
    # @param [#to_s] uri
    # @return [URI]
    def make_request_uri(uri, opts)
      uri = uri.to_s

      uri = "#{default_options.persistent}#{uri}" if default_options.persistent? && uri !~ HTTP_OR_HTTPS_RE

      uri = HTTP::URI.parse uri

      uri.query_values = uri.query_values(Array).to_a.concat(opts.params.to_a) if opts.params && !opts.params.empty?

      # Some proxies (seen on WEBRick) fail if URL has
      # empty path (e.g. `http://example.com`) while it's RFC-complaint:
      # http://tools.ietf.org/html/rfc1738#section-3.1
      uri.path = "/" if uri.path.empty?

      uri
    end

    # Creates request headers with cookies (if any) merged in
    def make_request_headers(opts)
      headers = opts.headers

      # Tell the server to keep the conn open
      headers[Headers::CONNECTION] = default_options.persistent? ? Connection::KEEP_ALIVE : Connection::CLOSE

      cookies = opts.cookies.values

      unless cookies.empty?
        cookies = opts.headers.get(Headers::COOKIE).concat(cookies).join("; ")
        headers[Headers::COOKIE] = cookies
      end

      headers
    end

    # Create the request body object to send
    def make_request_body(opts, headers)
      case
      when opts.body
        opts.body
      when opts.form
        form = make_form_data(opts.form)
        headers[Headers::CONTENT_TYPE] ||= form.content_type
        form
      when opts.json
        body = MimeType[:json].encode opts.json
        headers[Headers::CONTENT_TYPE] ||= "application/json; charset=#{body.encoding.name.downcase}"
        body
      end
    end

    def make_form_data(form)
      return form if form.is_a? HTTP::FormData::Multipart
      return form if form.is_a? HTTP::FormData::Urlencoded

      HTTP::FormData.create(form)
    end
  end
end