lib/salemove/http_client.ex

defmodule Salemove.HttpClient do
  @moduledoc """
  Generic HTTP client built on top of Tesla and used to build specific JSON API HTTP clients
  with ability to configure them during runtime, using, for example, environment variables.

  ## Example

      defmodule Github do
        use Salemove.HttpClient, base_url: "https://api.github.com/"

        def user_repos(login, opts \\ []) do
          get("/user/" <> login <> "/repos", opts)
        end
      end

      Github.user_repos("take-five")

  ## Configuration options

  There are number of available configuration options:

    * `:base_url` - Base URL of service (including schema, i.e. `https://api.github.com/`)
    * `:adapter` - HTTP Adapter module, defaults to `Tesla.Adapter.Hackney`
    * `:adapter_options` - adapter specific options, see documentation for concrete adapter
    * `:json` - JSON encoding/decoding options. If omitted, default options are used - see `Tesla.Middleware.JSON`.
      If set to `false`, request body is sent as application/x-www-form-urlencoded not JSON.
    * `:retry` - Retry few times in case of connection refused error. See `Tesla.Middleware.Retry`.
    * `:stats` - StatsD instrumenting options. The `:tesla_statsd` dependency
      must be included in your service to use this option. See `Tesla.StatsD`
      for more details.
    * `:username` - along with `:password` option adds basic authentication to all requests.
      See `Tesla.Middleware.BasicAuth`.
    * `:password` - see `:username`.
    * `:log` - Logging options, see `Salemove.HttpClient.Middleware.Logger`
    * `:proxy` - HTTP(S) proxy URL

  HTTP client can be configured at runtime and at compile time via configuration files. Note,
  that you can use `{:system, env_name}` tuples to configure the client

  ### Configuration via request options

  You can pass additional `Keyword` argument to request functions:

      Github.user_repos("take-five", adapter: Tesla.Mock, base_url: "http://mocked-gh/")

  ### Configuration via config files

  In `config/config.exs`:

      config :salemove_http_client,
        adapter: Tesla.Mock,
        base_url: "http://mocked-gh/"
  """

  @http_verbs ~w(head get delete trace options post put patch)a

  defmacro __using__(defaults_options \\ []) do
    friendly_api = Enum.map(@http_verbs, &generate_http_verb/1)

    quote do
      alias Salemove.HttpClient

      @type url :: HttpClient.url()
      @type body :: HttpClient.body()
      @type options :: HttpClient.options()
      @type response :: HttpClient.response()

      @doc false
      @spec request(options) :: response
      def request(options) do
        unquote(defaults_options)
        |> Keyword.merge(options)
        |> HttpClient.perform_request()
      end

      unquote(friendly_api)
    end
  end

  @hardcoded_defaults [
    adapter: Tesla.Adapter.Hackney,
    adapter_options: [
      connect_timeout: 1500,
      recv_timeout: 4500
    ],
    retry: false
  ]
  @application_defaults Keyword.merge(@hardcoded_defaults, Application.get_all_env(:salemove_http_client) || [])

  use Tesla,
    # don't generate GET/POST/... functions for HttpClient module
    only: [],
    # don't generate specs as they don't work well with Tesla.Middleware.Tuples
    docs: false

  adapter Salemove.HttpClient.Adapter

  alias Salemove.HttpClient.Decoder

  @typedoc "Client or request-specific options"
  @type options :: Keyword.t()
  @type response :: Decoder.on_decode()
  @type url :: Tesla.Env.url()
  @type body :: Tesla.Env.body()

  @doc false
  @spec perform_request(options) :: response
  def perform_request(options) do
    options = Confex.Resolver.resolve!(options)

    options
    |> build_client()
    |> request(options)
    |> Decoder.decode()
  end

  defp build_client(options) do
    @application_defaults
    |> Keyword.merge(options, &deep_merge/3)
    |> Confex.Resolver.resolve!()
    |> build_stack()
    |> Tesla.client()
  end

  defp build_stack(options) do
    encode_json_enabled = Keyword.get(options, :json, true)

    stats_enabled = Keyword.get(options, :stats, false)
    if stats_enabled, do: Code.ensure_loaded!(Tesla.StatsD)

    []
    |> push_middleware(Salemove.HttpClient.Middleware.MapHeaders)
    # Retry has to come before observability middlewares for all attempts to be
    # recorded separately.
    |> push_middleware({Tesla.Middleware.Retry, options[:retry]}, if: options[:retry])
    # BaseUrl has to come before observability and proxy middlewares. This
    # allows them to work with the full URL.
    |> push_middleware({Tesla.Middleware.BaseUrl, Keyword.fetch!(options, :base_url)})
    |> push_middleware(Tesla.Middleware.KeepRequest)
    |> push_middleware(Tesla.Middleware.Telemetry)
    |> push_middleware(Tesla.Middleware.OpenTelemetry, if: opentelemetry_enabled?(options))
    |> push_middleware({Tesla.StatsD, options[:stats]}, if: stats_enabled)
    |> push_middleware(Tesla.Middleware.PathParams)
    |> push_middleware({Salemove.HttpClient.Middleware.Proxy, options})
    |> push_middleware(Tesla.Middleware.FormUrlencoded, if: !encode_json_enabled)
    |> push_middleware({Tesla.Middleware.EncodeJson, options[:json]}, if: encode_json_enabled)
    |> push_middleware({Tesla.Middleware.DecodeJson, options[:json]})
    |> push_middleware(
      {Tesla.Middleware.BasicAuth, options},
      if: options[:username] && options[:password]
    )
    |> push_middleware({Salemove.HttpClient.Middleware.Logger, options[:log]})
    |> Enum.reverse()
  end

  if Code.ensure_loaded?(Tesla.Middleware.OpenTelemetry) do
    defp opentelemetry_enabled?(options) do
      Keyword.get(options, :opentelemetry, true)
    end
  else
    defp opentelemetry_enabled?(_), do: false
  end

  defp push_middleware(stack, middleware, [if: condition] \\ [if: true]) do
    if condition do
      [middleware | stack]
    else
      stack
    end
  end

  defp generate_http_verb(verb) when verb in [:post, :put, :patch] do
    quote do
      @doc """
      Perform a #{unquote(verb |> to_string() |> String.upcase())} request.
      See `HttpClient.request/2` for available options.
      """
      @spec unquote(verb)(url, body, options) :: response
      def unquote(verb)(url, body, options \\ []) do
        options
        |> Keyword.merge(url: url, body: body, method: unquote(verb))
        |> request()
      end
    end
  end

  defp generate_http_verb(verb) do
    quote do
      @doc """
      Perform a #{unquote(verb |> to_string() |> String.upcase())} request.
      See `HttpClient.request/2` for available options.
      """
      @spec unquote(verb)(url, options) :: response
      def unquote(verb)(url, options \\ []) do
        options
        |> Keyword.merge(url: url, method: unquote(verb))
        |> request()
      end
    end
  end

  defp deep_merge(_key, value1, value2) when is_list(value1) and is_list(value2) do
    if Keyword.keyword?(value1) and Keyword.keyword?(value2) do
      Keyword.merge(value1, value2, &deep_merge/3)
    else
      value2
    end
  end

  defp deep_merge(_key, _value1, value2) do
    value2
  end
end