Skip to main content

lib/access_grid/http_client/req.ex

defmodule AccessGrid.HttpClient.Req do
  @moduledoc """
  HTTP client implementation using Req.
  """

  @behaviour AccessGrid.HttpClient.Behaviour

  alias AccessGrid.HttpFailure
  alias AccessGrid.HttpResponse

  @default_receive_timeout 30_000

  @doc """
  Callback implementation for `c:AccessGrid.HttpClient.Behaviour.delete/2`
  """
  @impl true
  def delete(url, opts \\ %{}) do
    url
    |> build_request(opts)
    |> Req.delete()
    |> handle_response()
  end

  @doc """
  Callback implementation for `c:AccessGrid.HttpClient.Behaviour.get/2`
  """
  @impl true
  def get(url, opts \\ %{}) do
    url
    |> build_request(opts)
    |> Req.get()
    |> handle_response()
  end

  @doc """
  Callback implementation for `c:AccessGrid.HttpClient.Behaviour.head/2`
  """
  @impl true
  def head(url, opts \\ %{}) do
    url
    |> build_request(opts)
    |> Req.head()
    |> handle_response()
  end

  @doc """
  Callback implementation for `c:AccessGrid.HttpClient.Behaviour.patch/2`
  """
  @impl true
  def patch(url, opts \\ %{}) do
    url
    |> build_request(opts)
    |> Req.patch()
    |> handle_response()
  end

  @doc """
  Callback implementation for `c:AccessGrid.HttpClient.Behaviour.post/2`
  """
  @impl true
  def post(url, opts \\ %{}) do
    url
    |> build_request(opts)
    |> Req.post()
    |> handle_response()
  end

  @doc """
  Callback implementation for `c:AccessGrid.HttpClient.Behaviour.put/2`
  """
  @impl true
  def put(url, opts \\ %{}) do
    url
    |> build_request(opts)
    |> Req.put()
    |> handle_response()
  end

  # # #

  defp build_request(url, opts) do
    req_opts =
      [url: url, decode_body: false, receive_timeout: @default_receive_timeout]
      |> maybe_add(:headers, opts[:headers])
      |> maybe_add(:params, opts[:params])
      |> maybe_add(:receive_timeout, opts[:timeout])
      |> maybe_add(:retry, opts[:retry])
      |> maybe_add(:redirect, opts[:redirect])
      |> add_body(opts[:body], opts[:body_format])
      |> maybe_add_test_plug()

    Req.new(req_opts)
  end

  defp maybe_add(req_opts, _key, nil), do: req_opts
  defp maybe_add(req_opts, key, value), do: Keyword.put(req_opts, key, value)

  # Hook for Req.Test (test env only). The :req_plug config is unset in dev/prod
  # so this is a no-op outside of tests. See config/test.exs.
  defp maybe_add_test_plug(req_opts) do
    case Application.get_env(:accessgrid, :req_plug) do
      nil -> req_opts
      plug -> Keyword.put(req_opts, :plug, plug)
    end
  end

  defp add_body(req_opts, nil, _format), do: req_opts
  defp add_body(req_opts, body, :raw), do: Keyword.put(req_opts, :body, body)
  defp add_body(req_opts, body, _format), do: Keyword.put(req_opts, :json, body)

  # # #

  defp handle_response({:ok, %Req.Response{status: status} = response})
       when status >= 200 and status < 300 do
    {:ok, build_http_response(response)}
  end

  defp handle_response({:ok, %Req.Response{} = response}) do
    {:error, build_http_failure_from_response(response)}
  end

  defp handle_response({:error, %Req.TransportError{reason: reason} = error}) do
    {:error,
     %HttpFailure{
       reason: reason,
       message: Exception.message(error),
       original: error
     }}
  end

  defp handle_response({:error, error}) do
    {:error,
     %HttpFailure{
       reason: :unknown,
       message: inspect(error),
       original: error
     }}
  end

  # # #

  defp build_http_response(%Req.Response{} = response) do
    %HttpResponse{
      body_decoded: decode_body(response.body, get_content_type(response)),
      body_raw: response.body,
      content_type: get_content_type(response),
      headers: normalize_headers(response.headers),
      status: response.status
    }
  end

  defp build_http_failure_from_response(%Req.Response{} = response) do
    content_type = get_content_type(response)

    %HttpFailure{
      body_decoded: decode_body(response.body, content_type),
      body_raw: response.body,
      content_type: content_type,
      message: nil,
      original: response,
      reason: status_to_reason(response.status),
      status: response.status
    }
  end

  # # #

  defp get_content_type(%Req.Response{headers: headers}) do
    case Map.get(headers, "content-type") do
      [content_type | _] -> content_type
      _ -> nil
    end
  end

  defp decode_body(body, content_type) when is_binary(body) do
    if json_content_type?(content_type) do
      case Jason.decode(body) do
        {:ok, decoded} -> decoded
        {:error, _} -> body
      end
    else
      body
    end
  end

  defp decode_body(body, _content_type), do: body

  defp json_content_type?(nil), do: false
  defp json_content_type?(content_type), do: String.contains?(content_type, "json")

  defp normalize_headers(headers) do
    Enum.flat_map(headers, fn {key, values} ->
      values
      |> List.wrap()
      |> Enum.map(&{key, &1})
    end)
  end

  # # #

  defp status_to_reason(301), do: :redirect
  defp status_to_reason(302), do: :redirect
  defp status_to_reason(303), do: :redirect
  defp status_to_reason(304), do: :not_modified
  defp status_to_reason(307), do: :redirect
  defp status_to_reason(308), do: :redirect
  defp status_to_reason(status) when status >= 300 and status < 400, do: :redirect

  defp status_to_reason(400), do: :bad_request
  defp status_to_reason(401), do: :unauthorized
  defp status_to_reason(403), do: :forbidden
  defp status_to_reason(404), do: :not_found
  defp status_to_reason(408), do: :request_timeout
  defp status_to_reason(409), do: :conflict
  defp status_to_reason(422), do: :unprocessable_entity
  defp status_to_reason(429), do: :too_many_requests
  defp status_to_reason(status) when status >= 400 and status < 500, do: :client_error

  defp status_to_reason(500), do: :internal_server_error
  defp status_to_reason(502), do: :bad_gateway
  defp status_to_reason(503), do: :service_unavailable
  defp status_to_reason(504), do: :gateway_timeout
  defp status_to_reason(status) when status >= 500 and status < 600, do: :server_error

  defp status_to_reason(_status), do: :unexpected_status
end