lib/util/httpc.ex

# Copyright(c) 2015-2023 ACCESS CO., LTD. All rights reserved.

use Croma

defmodule Antikythera.Httpc do
  @default_max_body 10 * 1024 * 1024
  # To fetch log files created by `GearLog.Writer` this must be larger than the max log file size.
  @maximum_max_body 150 * 1024 * 1024
  defun default_max_body() :: pos_integer, do: @default_max_body
  defun maximum_max_body() :: pos_integer, do: @maximum_max_body

  @max_retry_attempts 3

  @moduledoc """
  HTTP client library.

  This is a wrapper around [`hackney`](https://github.com/benoitc/hackney), an HTTP client library.
  `Httpc` supports the following features:

  - gzip-compression is tranparently handled
  - headers are represented by maps instead of lists
  - header names are always lower case
  - TCP connections are automatically re-established when closed by server due to keepalive timeout

  ## Body format

  The `body` argument in `post/4`, `put/4`, `patch/4`, `request/5` takes either:

  - `binary` - Sends raw data
  - `iolist` - Sends raw data list (for more details see [documentation for built-in types](https://hexdocs.pm/elixir/typespecs.html#built-in-types))
  - `{:form, [{key, value}]}` - Sends key-value data as x-www-form-urlencoded
  - `{:json, map}` - Converts map into JSON and sends as application/json
  - `{:file, path}` - Sends given file contents

  ## Options

  - `:timeout` - Timeout to establish a connection, in milliseconds. Default is `8000`.
  - `:recv_timeout` - Timeout used when receiving a response. Default is `5000`.
  - `:params` - An enumerable of 2-tuples that will be URL-encoded and appended to the URL as query string parameters.
  - `:cookie` - An enumerable of name-value pairs of cookies. `Httpc` automatically URL-encodes the given names/values for you.
  - `:basic_auth` - A pair of `{username, password}` tuple to be used for HTTP basic authentication.
  - `:proxy` - A proxy to be used for the request; it can be a regular URL or a `{host, port}` tuple.
  - `:proxy_auth` - Proxy authentication `{username, password}` tuple.
  - `:ssl` - SSL options supported by the `ssl` erlang module.
  - `:skip_ssl_verification` - Whether to verify server's SSL certificate or not. Defaults to `false`.
    Specify `skip_ssl_verification: true` when accessing insecure server with HTTPS.
  - `:max_body` - Maximum content-length of the response body (compressed size if it's compressed).
    Defaults to `#{@default_max_body}` (#{div(@default_max_body, 1024 * 1024)}MB)
    and must not exceed #{div(@maximum_max_body, 1024 * 1024)}MB.
    Responses having body larger than the specified size will be rejected with `{:error, :response_too_large}`.
  - `:follow_redirect` - A boolean that causes redirects to be followed. Defaults to `false`.
  - `:max_redirect` - An integer denoting the maximum number of redirects to follow if `follow_redirect: true` is given.
  - `:skip_body_decompression` - By default gzip-compressed body is automatically decompressed (i.e. defaults to `false`).
    Pass `skip_body_decompression: true` if compressed body is what you need.
  """

  require AntikytheraCore.Logger, as: L

  alias Croma.Result, as: R
  alias Antikythera.{MapUtil, Url}
  alias Antikythera.Http.{Status, Method, Headers, SetCookie, SetCookiesMap}

  defmodule ReqBody do
    @moduledoc """
    Type for `Antikythera.Httpc`'s request body.
    """

    @type json_obj :: %{(atom | String.t()) => any}
    @type t :: binary | iolist | {:form, [{term, term}]} | {:json, json_obj} | {:file, Path.t()}

    defun valid?(t :: term) :: boolean do
      b when is_binary(b) -> true
      io when is_list(io) -> true
      {:form, l} when is_list(l) -> true
      {:json, m} when is_map(m) -> true
      {:file, b} when is_binary(b) -> true
      _otherwise -> false
    end

    def convert_body_and_headers_by_body_type(body, headers) do
      case body do
        {:json, map} ->
          Poison.encode(map)
          |> R.map(fn json ->
            {json, Map.put(headers, "content-type", "application/json")}
          end)

        other_body ->
          # {:form, l} and {:file, b} can be left as-is because hackney handles this internally
          {:ok, {other_body, headers}}
      end
    end
  end

  defmodule Response do
    @moduledoc """
    A struct to represent an HTTP response.

    Response headers are converted to a `Antikythera.Http.Headers.t` and all header names are lower-cased.
    `set-cookie` response headers are handled separately and stored in `cookies` field as a `Antikythera.Http.SetCookiesMap.t`.
    """

    use Croma.Struct,
      recursive_new?: true,
      fields: [
        status: Status.Int,
        body: Croma.Binary,
        headers: Headers,
        cookies: SetCookiesMap
      ]
  end

  defun request(
          method :: v[Method.t()],
          url :: v[Url.t()],
          body :: v[ReqBody.t()],
          headers :: v[Headers.t()] \\ %{},
          options :: Keyword.t() \\ []
        ) :: R.t(Response.t()) do
    downcased_headers = Map.new(headers, fn {k, v} -> {String.downcase(k), v} end)
    headers_with_encoding = Map.put_new(downcased_headers, "accept-encoding", "gzip")
    options_map = normalize_options(options)

    uri =
      with %URI{path: nil} = uri <- URI.parse(url) do
        L.info("URL with empty path detected: url=#{url}")
        uri
      end

    url_with_params =
      case options_map[:params] do
        nil ->
          url

        params ->
          query_string =
            case uri.query do
              nil -> URI.encode_query(params)
              qs -> qs <> "&" <> URI.encode_query(params)
            end

          %URI{uri | query: query_string} |> URI.to_string()
      end

    hackney_opts = hackney_options(options_map)

    ReqBody.convert_body_and_headers_by_body_type(body, headers_with_encoding)
    |> R.map_error(fn e ->
      {:invalid, e.value}
    end)
    |> R.bind(fn {hackney_body, headers} ->
      request_impl(method, url_with_params, headers, hackney_body, hackney_opts, options_map)
    end)
  end

  defun request!(
          method :: Method.t(),
          url :: Url.t(),
          body :: ReqBody.t(),
          headers :: Headers.t() \\ %{},
          options :: Keyword.t() \\ []
        ) :: Response.t() do
    request(method, url, body, headers, options) |> R.get!()
  end

  Enum.each([:get, :delete, :options, :head], fn method ->
    defun unquote(method)(
            url :: Url.t(),
            headers :: Headers.t() \\ %{},
            options :: Keyword.t() \\ []
          ) :: R.t(Response.t()) do
      request(unquote(method), url, "", headers, options)
    end

    # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom
    defun unquote(:"#{method}!")(
            url :: Url.t(),
            headers :: Headers.t() \\ %{},
            options :: Keyword.t() \\ []
          ) :: Response.t() do
      request!(unquote(method), url, "", headers, options)
    end
  end)

  Enum.each([:post, :put, :patch], fn method ->
    defun unquote(method)(
            url :: Url.t(),
            body :: ReqBody.t(),
            headers :: Headers.t(),
            options :: Keyword.t() \\ []
          ) :: R.t(Response.t()) do
      request(unquote(method), url, body, headers, options)
    end

    # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom
    defun unquote(:"#{method}!")(
            url :: Url.t(),
            body :: ReqBody.t(),
            headers :: Headers.t(),
            options :: Keyword.t() \\ []
          ) :: Response.t() do
      request!(unquote(method), url, body, headers, options)
    end
  end)

  defp request_impl(method, url_with_params, headers, body, hackney_opts, options_map) do
    send_request_with_retry(
      method,
      url_with_params,
      Map.to_list(headers),
      body,
      hackney_opts,
      options_map,
      0
    )
  end

  defp send_request_with_retry(
         method,
         url,
         headers_list,
         body,
         hackney_opts,
         options_map,
         attempts
       ) do
    case send_request(method, url, headers_list, body, hackney_opts, options_map) do
      {:ok, _} = ok ->
        ok

      # connection is closed on server side
      {:error, :closed} ->
        L.info("{:error, :closed} returned by hackney: attempts=#{attempts} url=#{url}")
        attempts2 = attempts + 1

        if attempts2 < @max_retry_attempts do
          # Since hackney's socket pool may have not yet cleaned up the closed socket, we should wait for a moment
          :timer.sleep(10)

          send_request_with_retry(
            method,
            url,
            headers_list,
            body,
            hackney_opts,
            options_map,
            attempts2
          )
        else
          {:error, :closed}
        end

      {:error, _} = error ->
        error
    end
  end

  defp send_request(method, url, headers_list, body, hackney_opts, options_map) do
    case :hackney.request(method, url, headers_list, body, hackney_opts) do
      # HEAD method
      {:ok, resp_status, resp_headers} ->
        make_response(resp_status, resp_headers, "", options_map)

      {:ok, resp_status, resp_headers, resp_body} ->
        make_response(resp_status, resp_headers, resp_body, options_map)

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp make_response(status, headers_list, body1, options_map) do
    if byte_size(body1) <= options_map[:max_body] do
      headers_grouped =
        Enum.group_by(headers_list, fn {k, _} -> String.downcase(k) end, &elem(&1, 1))

      {body2, headers_map, cookies_map} =
        make_response_impl(headers_grouped, body1, options_map[:skip_body_decompression])

      {:ok, %Response{status: status, body: body2, headers: headers_map, cookies: cookies_map}}
    else
      # The returned body might be truncated and thus we can't reliably uncompress the body if it's compressed.
      # In this case we give up returning partial information and simply return an error.
      {:error, :response_too_large}
    end
  end

  defp make_response_impl(headers_grouped1, body1, skip_body_decompression) do
    {cookie_strings, headers_grouped2} = Map.pop(headers_grouped1, "set-cookie", [])
    headers_map1 = MapUtil.map_values(headers_grouped2, fn {_, vs} -> Enum.join(vs, ", ") end)

    {body2, headers_map2} =
      if body1 != "" and !skip_body_decompression and headers_map1["content-encoding"] == "gzip" do
        decompress_body(body1, headers_map1)
      else
        {body1, headers_map1}
      end

    cookies_map = Map.new(cookie_strings, &SetCookie.parse!/1)
    {body2, headers_map2, cookies_map}
  end

  defp decompress_body(body, headers_map) do
    uncompressed = :zlib.gunzip(body)
    content_length = Integer.to_string(byte_size(uncompressed))

    new_headers_map =
      headers_map |> Map.delete("content-encoding") |> Map.put("content-length", content_length)

    {uncompressed, new_headers_map}
  end

  defp normalize_options(options) do
    options_map = Map.new(options)

    case options_map[:max_body] do
      nil -> Map.put(options_map, :max_body, @default_max_body)
      max when max in 0..@maximum_max_body -> options_map
    end
  end

  defp hackney_options(options_map) do
    max_body = Map.fetch!(options_map, :max_body)
    base_opts = [{:path_encode_fun, &encode_path/1}, {:max_body, max_body}, {:with_body, true}]

    Enum.reduce(options_map, base_opts, fn {k, v}, opts ->
      case convert_option(k, v) do
        nil -> opts
        opt -> [opt | opts]
      end
    end)
  end

  defunp convert_option(name, value) :: any do
    :timeout, value ->
      {:connect_timeout, value}

    :recv_timeout, value ->
      {:recv_timeout, value}

    # :params are used in URL, not a hackney option
    :cookie, cs ->
      {:cookie, Enum.map(cs, fn {n, v} -> {URI.encode_www_form(n), URI.encode_www_form(v)} end)}

    :basic_auth, {_u, _p} = t ->
      {:basic_auth, t}

    :proxy, proxy ->
      {:proxy, proxy}

    :proxy_auth, {_u, _p} = t ->
      {:proxy_auth, t}

    :ssl, ssl ->
      {:ssl_options, ssl}

    :skip_ssl_verification, true ->
      :insecure

    # :max_body is treated differently as it has the default value
    :follow_redirect, true ->
      {:follow_redirect, true}

    :max_redirect, max ->
      {:max_redirect, max}

    # :skip_body_decompression is used in processing response body, not here
    _, _ ->
      nil
  end

  defunpt encode_path(path :: String.t()) :: String.t() do
    encode_path_impl(path, "")
  end

  defp hex(n) when n <= 9, do: n + ?0
  defp hex(n), do: n + ?A - 10

  defmacrop is_hex(c) do
    quote do
      unquote(c) in ?0..?9 or unquote(c) in ?A..?F or unquote(c) in ?a..?f
    end
  end

  defunp encode_path_impl(path :: String.t(), acc :: String.t()) :: String.t() do
    "", acc ->
      acc

    <<?%, a::8, b::8, rest::binary>>, acc when is_hex(a) and is_hex(b) ->
      encode_path_impl(rest, <<acc::binary, ?%, a, b>>)

    <<c::8, rest::binary>>, acc ->
      import Bitwise

      case URI.char_unescaped?(c) do
        true -> encode_path_impl(rest, <<acc::binary, c>>)
        false -> encode_path_impl(rest, <<acc::binary, ?%, hex(bsr(c, 4)), hex(band(c, 15))>>)
      end
  end
end

defmodule Antikythera.Httpc.Mockable do
  @moduledoc """
  Just wrapping `Httpc` without any modification.
  Can be mocked with `:meck.expect(Httpc.Mockable, :request, ...)` without interfering other Httpc action.
  """

  defdelegate request(method, url, body, headers, options), to: Antikythera.Httpc
  defdelegate request!(method, url, body, headers, options), to: Antikythera.Httpc

  Enum.each([:get, :delete, :options, :head], fn method ->
    defdelegate unquote(method)(url, headers, options), to: Antikythera.Httpc
    # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom
    defdelegate unquote(:"#{method}!")(url, headers, options), to: Antikythera.Httpc
  end)

  Enum.each([:post, :put, :patch], fn method ->
    defdelegate unquote(method)(url, body, headers, options), to: Antikythera.Httpc
    # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom
    defdelegate unquote(:"#{method}!")(url, body, headers, options), to: Antikythera.Httpc
  end)
end