Skip to main content

lib/squidie/tools/http.ex

defmodule Squidie.Tools.HTTP do
  @moduledoc """
  HTTP tool adapter backed by Req.
  """

  @behaviour Squidie.Tools.Adapter

  alias Squidie.Tools.Error
  alias Squidie.Tools.Result

  @type request :: %{
          required(:method) => atom(),
          required(:url) => String.t(),
          optional(:body) => term(),
          optional(:headers) => keyword() | [{String.t(), String.t()}],
          optional(:json) => term(),
          optional(:params) => map(),
          optional(:timeout) => pos_integer()
        }

  @impl Squidie.Tools.Adapter
  @spec invoke(request(), map(), keyword()) :: {:ok, Result.t()} | {:error, Error.t()}
  def invoke(request, _context, _opts) when is_map(request) do
    with :ok <- validate_request(request) do
      request
      |> build_req_options()
      |> Req.request()
      |> normalize_response(request)
    end
  end

  def invoke(_request, _context, _opts) do
    {:error,
     Error.new(
       adapter: __MODULE__,
       kind: :invalid_request,
       message: "HTTP tool requests must be maps",
       details: %{reason: :expected_map},
       retryable?: false
     )}
  end

  @spec validate_request(map()) :: :ok | {:error, Error.t()}
  defp validate_request(%{method: method, url: url})
       when is_atom(method) and is_binary(url) and byte_size(url) > 0,
       do: :ok

  defp validate_request(request) do
    {:error,
     Error.new(
       adapter: __MODULE__,
       kind: :invalid_request,
       message: "HTTP tool requests require an atom :method and binary :url",
       details: %{request: request},
       retryable?: false
     )}
  end

  @spec build_req_options(request()) :: keyword()
  defp build_req_options(request) do
    Enum.reduce(
      request,
      [method: request.method, redirect: false, retry: false, url: request.url],
      fn
        {:headers, headers}, opts ->
          Keyword.put(opts, :headers, headers)

        {:params, params}, opts ->
          Keyword.put(opts, :params, params)

        {:json, json}, opts ->
          Keyword.put(opts, :json, json)

        {:body, body}, opts ->
          Keyword.put(opts, :body, body)

        {:timeout, timeout}, opts when is_integer(timeout) and timeout > 0 ->
          opts
          |> Keyword.put(:receive_timeout, timeout)
          |> Keyword.put(:connect_options, timeout: timeout)

        {_key, _value}, opts ->
          opts
      end
    )
  end

  @spec normalize_response({:ok, Req.Response.t()} | {:error, term()}, request()) ::
          {:ok, Result.t()} | {:error, Error.t()}
  defp normalize_response({:ok, %Req.Response{} = response}, request) do
    if response.status in 200..399 do
      {:ok,
       %Result{
         adapter: __MODULE__,
         payload: Req.Response.to_map(response),
         metadata: %{method: request.method, url: request.url}
       }}
    else
      {:error,
       Error.new(
         adapter: __MODULE__,
         kind: :http,
         message: "HTTP request failed with status #{response.status}",
         details:
           response
           |> Req.Response.to_map()
           |> Map.put(:method, request.method)
           |> Map.put(:url, request.url),
         retryable?: retryable_status?(response.status)
       )}
    end
  end

  defp normalize_response({:error, %Req.TransportError{reason: reason}}, request) do
    kind = if timeout_reason?(reason), do: :timeout, else: :transport

    {:error,
     Error.new(
       adapter: __MODULE__,
       kind: kind,
       message: Req.TransportError.message(%Req.TransportError{reason: reason}),
       details: %{reason: inspect(reason), method: request.method, url: request.url},
       retryable?: true
     )}
  end

  defp normalize_response({:error, reason}, request) do
    {:error,
     Error.new(
       adapter: __MODULE__,
       kind: :transport,
       message: "HTTP request failed",
       details: %{reason: inspect(reason), method: request.method, url: request.url},
       retryable?: true
     )}
  end

  @spec timeout_reason?(term()) :: boolean()
  defp timeout_reason?(reason) do
    reason in [:timeout, :connect_timeout]
  end

  defp retryable_status?(status) when status in [408, 429], do: true
  defp retryable_status?(status), do: status >= 500
end