lib/http/uri.rb



# frozen_string_literal: true

require "addressable/uri"

module HTTP
  class URI
    extend Forwardable

    def_delegators :@uri, :scheme, :normalized_scheme, :scheme=
    def_delegators :@uri, :user, :normalized_user, :user=
    def_delegators :@uri, :password, :normalized_password, :password=
    def_delegators :@uri, :authority, :normalized_authority, :authority=
    def_delegators :@uri, :origin, :origin=
    def_delegators :@uri, :normalized_port, :port=
    def_delegators :@uri, :path, :normalized_path, :path=
    def_delegators :@uri, :query, :normalized_query, :query=
    def_delegators :@uri, :query_values, :query_values=
    def_delegators :@uri, :request_uri, :request_uri=
    def_delegators :@uri, :fragment, :normalized_fragment, :fragment=
    def_delegators :@uri, :omit, :join, :normalize

    # Host, either a domain name or IP address. If the host is an IPv6 address, it will be returned
    # without brackets surrounding it.
    #
    # @return [String] The host of the URI
    attr_reader :host

    # Normalized host, either a domain name or IP address. If the host is an IPv6 address, it will
    # be returned without brackets surrounding it.
    #
    # @return [String] The normalized host of the URI
    attr_reader :normalized_host

    # @private
    HTTP_SCHEME = "http"

    # @private
    HTTPS_SCHEME = "https"

    # @private
    PERCENT_ENCODE = /[^\x21-\x7E]+/.freeze

    # @private
    NORMALIZER = lambda do |uri|
      uri = HTTP::URI.parse uri

      HTTP::URI.new(
        :scheme    => uri.normalized_scheme,
        :authority => uri.normalized_authority,
        :path      => uri.path.empty? ? "/" : percent_encode(Addressable::URI.normalize_path(uri.path)),
        :query     => percent_encode(uri.query),
        :fragment  => uri.normalized_fragment
      )
    end

    # Parse the given URI string, returning an HTTP::URI object
    #
    # @param [HTTP::URI, String, #to_str] uri to parse
    #
    # @return [HTTP::URI] new URI instance
    def self.parse(uri)
      return uri if uri.is_a?(self)

      new(Addressable::URI.parse(uri))
    end

    # Encodes key/value pairs as application/x-www-form-urlencoded
    #
    # @param [#to_hash, #to_ary] form_values to encode
    # @param [TrueClass, FalseClass] sort should key/value pairs be sorted first?
    #
    # @return [String] encoded value
    def self.form_encode(form_values, sort = false)
      Addressable::URI.form_encode(form_values, sort)
    end

    # Percent-encode all characters matching a regular expression.
    #
    # @param [String] string raw string
    #
    # @return [String] encoded value
    #
    # @private
    def self.percent_encode(string)
      string&.gsub(PERCENT_ENCODE) do |substr|
        substr.encode(Encoding::UTF_8).bytes.map { |c| format("%%%02X", c) }.join
      end
    end

    # Creates an HTTP::URI instance from the given options
    #
    # @param [Hash, Addressable::URI] options_or_uri
    #
    # @option options_or_uri [String, #to_str] :scheme URI scheme
    # @option options_or_uri [String, #to_str] :user for basic authentication
    # @option options_or_uri [String, #to_str] :password for basic authentication
    # @option options_or_uri [String, #to_str] :host name component
    # @option options_or_uri [String, #to_str] :port network port to connect to
    # @option options_or_uri [String, #to_str] :path component to request
    # @option options_or_uri [String, #to_str] :query component distinct from path
    # @option options_or_uri [String, #to_str] :fragment component at the end of the URI
    #
    # @return [HTTP::URI] new URI instance
    def initialize(options_or_uri = {})
      case options_or_uri
      when Hash
        @uri = Addressable::URI.new(options_or_uri)
      when Addressable::URI
        @uri = options_or_uri
      else
        raise TypeError, "expected Hash for options, got #{options_or_uri.class}"
      end

      @host = process_ipv6_brackets(@uri.host)
      @normalized_host = process_ipv6_brackets(@uri.normalized_host)
    end

    # Are these URI objects equal? Normalizes both URIs prior to comparison
    #
    # @param [Object] other URI to compare this one with
    #
    # @return [TrueClass, FalseClass] are the URIs equivalent (after normalization)?
    def ==(other)
      other.is_a?(URI) && normalize.to_s == other.normalize.to_s
    end

    # Are these URI objects equal? Does NOT normalizes both URIs prior to comparison
    #
    # @param [Object] other URI to compare this one with
    #
    # @return [TrueClass, FalseClass] are the URIs equivalent?
    def eql?(other)
      other.is_a?(URI) && to_s == other.to_s
    end

    # Hash value based off the normalized form of a URI
    #
    # @return [Integer] A hash of the URI
    def hash
      @hash ||= to_s.hash * -1
    end

    # Sets the host component for the URI.
    #
    # @param [String, #to_str] new_host The new host component.
    # @return [void]
    def host=(new_host)
      @uri.host = process_ipv6_brackets(new_host, :brackets => true)

      @host = process_ipv6_brackets(@uri.host)
      @normalized_host = process_ipv6_brackets(@uri.normalized_host)
    end

    # Port number, either as specified or the default if unspecified
    #
    # @return [Integer] port number
    def port
      @uri.port || @uri.default_port
    end

    # @return [True] if URI is HTTP
    # @return [False] otherwise
    def http?
      HTTP_SCHEME == scheme
    end

    # @return [True] if URI is HTTPS
    # @return [False] otherwise
    def https?
      HTTPS_SCHEME == scheme
    end

    # @return [Object] duplicated URI
    def dup
      self.class.new @uri.dup
    end

    # Convert an HTTP::URI to a String
    #
    # @return [String] URI serialized as a String
    def to_s
      @uri.to_s
    end
    alias to_str to_s

    # @return [String] human-readable representation of URI
    def inspect
      format("#<%s:0x%014x URI:%s>", self.class.name, object_id << 1, to_s)
    end

    private

    # Process a URI host, adding or removing surrounding brackets if the host is an IPv6 address.
    #
    # @param [Boolean] brackets When true, brackets will be added to IPv6 addresses if missing. When
    #   false, they will be removed if present.
    #
    # @return [String] Host with IPv6 address brackets added or removed
    def process_ipv6_brackets(raw_host, brackets: false)
      ip = IPAddr.new(raw_host)

      if ip.ipv6?
        brackets ? "[#{ip}]" : ip.to_s
      else
        raw_host
      end
    rescue IPAddr::Error
      raw_host
    end
  end
end