lib/consul/connection.ex

defmodule Consul.Connection do
  @moduledoc """
  Handle Tesla connections for Consul.
  """

  @type t :: Tesla.Env.client()

  @default_scheme "http://"
  @consulex_version Mix.Project.config() |> Keyword.get(:version, "")

  @retry_defaults [
    delay: 50,
    max_retries: 5,
    max_delay: 4_000,
    should_retry: &Consul.Connection.match_errors/1
  ]

  @doc """
  Builds a base URL based on a given server spec.
  """
  def base_url(<<"http://", _::binary>> = server_spec),
    do: server_spec

  def base_url(<<"https://", _::binary>> = server_spec),
    do: server_spec

  def base_url(server_spec) do
    if Regex.match?(~r{^[^:/]+(:[0-9]+)?}, server_spec) do
      @default_scheme <> server_spec
    else
      raise ArgumentError,
            "expected :server_spec to be a valid URL or server spec, got: #{inspect(server_spec)}"
    end
  end

  @doc """
  Builds a Tesla client.

  ## Options

    * `adapter` - specify custom Tesla adapter, if not set will use Tesla's
      default one
    * `retry` - options for `Tesla.Middleware.Retry` middleware
    * `timeout` - when given, will include `Tesla.Middleware.Timeout`
      middleware configured with given value
    * `token` - when given, will include `x-consul-token` header with given
      value in the requests
    * `wait` - when given, will include `Tesla.Middleware.ConsulWatch`
      middleware configured with given value in milliseconds

  When using `wait` option, make sure its value is larger than the timeout
  used by the underlying adapter (specified in options or default one).
  Otherwise, the request may be timing out before wait period passes.

  Note that using `timeout` option is not always sufficient to achieve this
  as some adapters (e.g. `Tesla.Adapter.Finch`) are not compatible with
  `Tesla.Middleware.Timeout` middleware that is used when `timeout` option
  is specified.
  """
  def new(base_url, opts \\ []) do
    middleware = [
      {Tesla.Middleware.BaseUrl, base_url},
      Tesla.Middleware.DecompressResponse,
      Tesla.Middleware.FollowRedirects
    ]

    adapter = Keyword.get(opts, :adapter)

    opts =
      Keyword.update(opts, :retry, @retry_defaults, fn retry ->
        Keyword.merge(@retry_defaults, retry)
      end)

    middleware =
      Enum.reduce(opts, middleware, fn opt, middleware ->
        plug_middleware(opt, middleware)
      end)

    Tesla.client(middleware, adapter)
  end

  defp plug_middleware({:timeout, timeout}, middleware) do
    middleware ++ [{Tesla.Middleware.Timeout, timeout: timeout}]
  end

  defp plug_middleware({:token, token}, middleware) do
    middleware ++ [{Tesla.Middleware.Headers, [{"x-consul-token", token}]}]
  end

  defp plug_middleware({:wait, wait}, middleware) do
    middleware ++ [{Tesla.Middleware.ConsulWatch, wait: wait}]
  end

  defp plug_middleware({:retry, retry}, middleware) do
    middleware ++ [{Tesla.Middleware.Retry, retry}]
  end

  defp plug_middleware(_opt, middleware) do
    middleware
  end

  @doc """
  Converts a Consul.Request struct into a keyword list to send via
  Tesla.
  """
  @spec build_request(Consul.Request.t()) :: keyword()
  def build_request(request) do
    [url: request.url, method: request.method]
    |> build_query(request.query)
    |> build_headers(request.header)
    |> build_body(request.body)
  end

  defp build_query(output, []), do: output

  defp build_query(output, query_params) do
    Keyword.put(output, :query, query_params)
  end

  defp build_headers(output, header_params) do
    api_client =
      Enum.join(
        [
          "elixir/#{System.version()}",
          "consulex/#{@consulex_version}"
        ],
        " "
      )

    headers = [{"x-api-client", api_client} | header_params]
    Keyword.put(output, :headers, headers)
  end

  # If no body or file fields and the request is a POST, set an empty body
  defp build_body(output, []) do
    method = Keyword.fetch!(output, :method)
    set_default_body(output, method)
  end

  defp build_body(output, body: main_body) do
    Keyword.put(output, :body, main_body)
  end

  @required_body_methods [:post, :patch, :put, :delete]

  defp set_default_body(output, method) when method in @required_body_methods do
    Keyword.put(output, :body, "")
  end

  defp set_default_body(output, _) do
    output
  end

  @doc """
  Execute a request on this connection

  ## Returns

    * `{:ok, Tesla.Env.t}` - If the call was successful
    * `{:error, reason}` - If the call failed
  """
  @spec execute(Tesla.Client.t(), Consul.Request.t()) :: {:ok, Tesla.Env.t()}
  def execute(connection, request) do
    request
    |> build_request()
    |> (&Tesla.request(connection, &1)).()
  end

  def match_errors({:ok, %{status: status}}) when status >= 400, do: true
  def match_errors({:ok, _}), do: false
  def match_errors({:error, _}), do: true
end