.cr
Crystal
(text/x-crystal)
require "http"
require "http/client"
require "openssl"

module Fetch
  private def self.fetch(
    method : String,
    url : String,
    query : URI::Params? = URI::Params.new,
    headers : HTTP::Headers? = HTTP::Headers.new,
    body : String? = nil,
    max_hops : Int32? = 10,
  ) : HTTP::Client::Response
    context = OpenSSL::SSL::Context::Client.new
    context.verify_mode = OpenSSL::SSL::VerifyMode::NONE # re-enable in prod
    uri = URI.parse(url)
    uri.query_params = query unless query.nil?
    body = "" if body.nil?
    headers_hash = HTTP::Headers.new if headers.nil?
    headers_hash = headers.clone unless headers.nil?
    hops = 0
    max_hops = max_hops.nil? ? 10 : max_hops
    loop do
      raise "too many redirects" if hops >= (max_hops.to_i || 10)
      hops += 1
      begin
        headers ||= HTTP::Headers.new
        headers_hash ||= HTTP::Headers.new
        headers_hash.each { |k, v| headers[k] = v }
        headers["Host"] = uri.host.not_nil! unless uri.host.nil?
        headers["Content-Type"] = "application/json" if method == "POST"
        resp = case method.upcase
        when "POST"
          HTTP::Client.post(uri.to_s, headers: headers, body: body, tls: context)
        when "PUT"
          HTTP::Client.put(uri.to_s, headers: headers, body: body, tls: context)
        when "DELETE"
          HTTP::Client.delete(uri.to_s, headers: headers, tls: context)
        when "OPTIONS"
          HTTP::Client.options(uri.to_s, headers: headers, tls: context)
        when "HEAD"
          HTTP::Client.head(uri.to_s, headers: headers, tls: context)
        else
          HTTP::Client.get(uri.to_s, headers: headers, tls: context)
        end
        # pp uri.to_s
        # pp headers
        # pp body
        # puts "[redirect #{hops}] #{method} #{uri.to_s} -> #{resp.status_code} Location=#{resp.headers["Location"]?}"
        # pp resp
        # pp resp.headers.to_h
        # not a redirect -> return
        if resp && !(300..399).includes?(resp.status_code)
          return resp
        end
        # redirect handling
        loc = resp.headers["Location"]? || resp.headers["location"]? || ""
        raise "redirect with no Location header" if loc.empty?
        loc = self.normalize_location(uri, loc)
        new_uri = URI.parse(loc)
        if self.same_origin?(self.canonical_uri(new_uri), self.canonical_uri(uri))
          # treat as final to avoid loop
          return resp
        end
        # decide method change: RFC practice -> 303 always GET;
        # many clients also convert 301/302 for non-GET/HEAD to GET
        if resp.status_code == 303 || (
          (resp.status_code == 301 || resp.status_code == 302) &&
          method != "GET" && method != "HEAD"
        )
          method = "GET"
          body = nil
          # drop content headers
          headers_hash.delete("Content-Length")
          headers_hash.delete("Content-Type")
        end
        # drop Authorization on cross-origin redirects
        unless same_origin?(uri, new_uri)
          headers_hash.delete("Authorization")
        end
        uri = new_uri
        return resp
      rescue ex : Exception
        puts ex.message
        puts ex.backtrace
        raise ex
      end
    end
  end

  private def self.normalize_location(base : URI, loc : String) : String
    begin
      # absolute URI
      URI.parse(loc).to_s
    rescue
      # relative -> build a new URI by resolving path/query against base
      # ensure base.path ends with '/' if needed for relative resolution
      base_path = base.path.empty? ? "/" : base.path
      # If loc starts with '/', it's absolute path on same origin
      resolved_path = if loc.starts_with?("/")
        loc
      else
        # join base path directory with relative segment
        dir = base_path.ends_with?("/") ? base_path : File.dirname(base_path)
        File.join(dir, loc)
      end

      # construct new URI preserving scheme, host, port
      uri = URI.new("#{base.scheme}://#{base.host}#{base.port && base.port != 80 && base.port != 443 ? ":#{base.port}" : ""}#{resolved_path}")
      uri.to_s
    end
  end

  private def self.same_origin?(a : URI, b : URI) : Bool
    a.scheme == b.scheme && a.host == b.host && a.port == b.port
  end

  private def self.canonical(u : URI) : String
    scheme = u.scheme.not_nil!.downcase
    host = u.host.not_nil!.downcase
    port = u.port
    if (scheme == "http" && port == 80) || (scheme == "https" && port == 443)
      port = nil
    end
    path = u.path.empty? ? "/" : File.expand_path(u.path, "/")
    "#{scheme}://#{host}#{port ? ":#{port}" : ""}#{path}#{u.query ? "?#{u.query}" : ""}"
  end

  private def self.canonical_uri(u : URI) : URI
    URI.parse(self.canonical(u))
  end

  {% for method in %w(GET POST PUT DELETE OPTIONS HEAD) %}
  def self.{{method.id.downcase}}(
    url : String,
    query : URI::Params? = URI::Params.new,
    headers : HTTP::Headers? = HTTP::Headers.new,
    body : String? = nil,
    max_redirects : Int32? = 10,
  )
    self.fetch(
      method: {{method.downcase}},
      url: url,
      query: query,
      headers: headers,
      body: body,
      max_redirects: max_redirects,
    )
  end

  def self.{{method.id.downcase}}(
    url : String,
    args : NamedTuple(
      query: URI::Params,
      headers: HTTP::Headers,
      body: String,
      max_redirects: Int32,
    ),
  )
    self.fetch(
      method: {{method.downcase}},
      url: url,
      query: args[:query],
      headers: args[:headers],
      body: args[:body],
      max_redirects: args[:max_redirects],
    )
  end

  def self.{{method.id.downcase}}(url : String)
    self.fetch({{method.downcase}}, url, nil, nil, nil, nil)
  end

  def self.{{method.id.downcase}}(
    url : String,
    args : NamedTuple(
      query: URI::Params,
    ),
  )
    self.fetch({{method.downcase}}, url, args[:query], nil, nil, nil)
  end

  def self.{{method.id.downcase}}(
    url : String,
    args : NamedTuple(
      headers: HTTP::Headers,
    ),
  )
    self.fetch({{method.downcase}}, url, nil, args[:headers], nil, nil)
  end


  def self.{{method.id.downcase}}(
    url : String,
    args : NamedTuple(
      query: URI::Params,
      headers: HTTP::Headers,
    ),
  )
    self.fetch({{method.downcase}}, url, args[:query], args[:headers], nil, nil)
  end

  def self.{{method.id.downcase}}(
    url : String,
    args : NamedTuple(
      query: URI::Params,
      body: String,
    ),
  )
    self.fetch({{method.downcase}}, url, args[:query], nil, args[:body], nil)
  end

  def self.{{method.id.downcase}}(
    url : String,
    args : NamedTuple(
      query: URI::Params,
      headers: HTTP::Headers,
      body: String,
    ),
  )
    self.fetch({{method.downcase}}, url, args[:query], args[:headers], args[:body], nil)
  end

  def self.{{method.id.downcase}}(
    url : String,
    args : NamedTuple(
      headers: HTTP::Headers,
      body: String,
    ),
  )
    self.fetch({{method.downcase}}, url, nil, args[:headers], args[:body], nil)
  end

  def self.{{method.id.downcase}}(
    url : String,
    args : NamedTuple(
      query: URI::Params,
      headers: HTTP::Headers,
      body: String,
      max_redirects: Int32,
    ),
  )
    self.fetch({{method.downcase}}, url, args[:query], args[:headers], args[:body], args[:max_redirects])
  end

  def self.{{method.id.downcase}}(
    url : String,
    args : NamedTuple(
      query: Hash(String, String),
      headers: Hash(String, String),
      body: Hash(String, String),
    ),
  )
    query = URI::Params.build do |p|
      args[:query].each do |k, v|
        p[k] = v
      end
    end
    headers = HTTP::Headers.new
    args[:headers].each do |k, v|
      headers[k] = v
    end
    body = args[:body].to_json.to_s
    self.fetch({{method.downcase}}, url, query, headers, body, nil)
  end

  def self.{{method.id.downcase}}(
    url : String,
    args : NamedTuple(
      headers: Hash(String, String),
      body: Hash(String, String),
    ),
  )
    headers = HTTP::Headers.new
    args[:headers].each do |k, v|
      headers[k] = v
    end
    body = args[:body].to_json.to_s
    self.fetch({{method.downcase}}, url, nil, headers, body, nil)
  end

  def self.{{method.id.downcase}}(
    url : String,
    args : NamedTuple(
      query: Hash(String, String),
      body: Hash(String, String),
    ),
  )
    query = URI::Params.build do |p|
      args[:query].each do |k, v|
        p[k] = v
      end
    end
    body = args[:body].to_json.to_s
    self.fetch({{method.downcase}}, url, query, nil, body, nil)
  end

  def self.{{method.id.downcase}}(
    url : String,
    args : NamedTuple(
      query: Hash(String, String),
      headers: Hash(String, String),
    ),
  )
    query = URI::Params.build do |p|
      args[:query].each do |k, v|
        p[k] = v
      end
    end
    headers = HTTP::Headers.new
    args[:headers].each do |k, v|
      headers[k] = v
    end
    self.fetch({{method.downcase}}, url, query, headers, nil, nil)
  end

  def self.{{method.id.downcase}}(
    url : String,
    args : NamedTuple(
      query: Hash(String, String),
    ),
  )
    query = URI::Params.build do |p|
      args[:query].each do |k, v|
        p[k] = v
      end
    end
    self.fetch({{method.downcase}}, url, query, nil, nil, nil)
  end

  def self.{{method.id.downcase}}(
    url : String,
    args : NamedTuple(
      headers: Hash(String, String),
    ),
  )
    headers = HTTP::Headers.new
    args[:headers].each do |k, v|
      headers[k] = v
    end
    self.fetch({{method.downcase}}, url, nil, headers, nil, nil)
  end

  def self.{{method.id.downcase}}(
    url : String,
    args : NamedTuple(
      body: Hash(String, String),
    ),
  )
    body = args[:body].to_json.to_s
    self.fetch({{method.downcase}}, url, nil, nil, body, nil)
  end
  {% end %}
end

GitFOSS • v0.2.0 (#7b50ebe) • MIT License