lib/configuration/flagsmith_configuration.ex

defmodule Flagsmith.Configuration do
  require Logger

  @default_url "https://edge.api.flagsmith.com/api/v1/"
  @environment_header "X-Environment-Key"

  @api_paths %{
    flags: "/flags/",
    identities: "/identities/",
    traits: "/traits/",
    analytics: "/analytics/flags/",
    environment: "/environment-document/"
  }

  @min_refresh_interval if(Mix.env() == :test, do: 1, else: 60_000)

  @enforce_keys [:environment_key]
  defstruct [
    :environment_key,
    :default_flag_handler,
    api_url: @default_url,
    custom_headers: [],
    request_timeout_milliseconds: 5000,
    enable_local_evaluation: false,
    environment_refresh_interval_milliseconds: 60_000,
    retries: 0,
    enable_analytics: false
  ]

  @default_keys [
    :api_url,
    :default_flag_handler,
    :custom_headers,
    :request_timeout_milliseconds,
    :enable_local_evaluation,
    :environment_refresh_interval_milliseconds,
    :retries,
    :enable_analytics
  ]

  @type t() :: %__MODULE__{
          environment_key: String.t(),
          default_flag_handler: function(),
          api_url: String.t(),
          custom_headers: list({String.t(), String.t()}),
          request_timeout_milliseconds: non_neg_integer(),
          enable_local_evaluation: boolean(),
          environment_refresh_interval_milliseconds: non_neg_integer(),
          retries: non_neg_integer(),
          enable_analytics: boolean()
        }

  @doc false
  def default_url(), do: @default_url

  @doc false
  def environment_header(), do: @environment_header

  @doc false
  @spec api_paths() :: map()
  def api_paths(), do: @api_paths

  @doc false
  @spec api_paths(what :: atom()) :: String.t() | no_return
  def api_paths(what), do: Map.fetch!(@api_paths, what)

  @doc false
  def build(opts \\ []) do
    with key <- get_environment_key(opts),
         config <- %__MODULE__{environment_key: key} do
      Enum.reduce(@default_keys, config, fn key, config_acc ->
        maybe_add_key(config_acc, key, opts)
      end)
      |> validate!()
      |> warn_if_unknown_opts(opts)
    end
  end

  defp validate!(%__MODULE__{} = config) do
    Enum.all?([:environment_key | @default_keys], fn k ->
      value = Map.fetch!(config, k)

      case validate(k, value) do
        true -> true
        {:error, msg} -> raise "#{k} #{msg}"
      end
    end)

    config
  end

  defp warn_if_unknown_opts(config, opts) do
    Enum.map(opts, fn {k, _} ->
      case is_map_key(config, k) do
        true ->
          :ok

        false ->
          Logger.warn("unknown option #{inspect(k)} passed as configuration to Flagsmith.Client")
      end
    end)

    config
  end

  defp validate(key, value) do
    case key do
      :environment_key ->
        is_binary(value) || {:error, "needs to be a String.t()"}

      :api_url ->
        is_binary(value) || {:error, "needs to be a String.t()"}

      :default_flag_handler ->
        is_function(value, 1) or is_nil(value) ||
          {:error, "needs to be a 1 arity function or nil"}

      :custom_headers ->
        (is_list(value) and
           Enum.all?(value, fn pair ->
             with true <- is_tuple(pair) and tuple_size(pair) == 2,
                  {k, v} <- pair do
               is_binary(k) and is_binary(v)
             else
               _ -> false
             end
           end)) ||
          {:error, "needs to be a list of 2 sized tuples, with both elements String.t()"}

      :request_timeout_milliseconds ->
        (is_integer(value) and value >= 5000) ||
          {:error, "needs to be an integer equal or bigger than 5000"}

      :enable_local_evaluation ->
        is_boolean(value) || {:error, "needs to be true or false"}

      :environment_refresh_interval_milliseconds ->
        (is_integer(value) and value >= @min_refresh_interval) ||
          {:error, "needs to be an integer equal or bigger than #{@min_refresh_interval}"}

      :retries ->
        (is_integer(value) and value >= 0) ||
          {:error, "needs to be an integer equal or bigger than 0"}

      :enable_analytics ->
        is_boolean(value) || {:error, "needs to be true or false"}
    end
  end

  defp get_environment_key(opts) do
    case Keyword.get(opts, :environment_key) do
      nil -> get!(:environment_key)
      key -> key
    end
  end

  defp maybe_add_key(config_acc, key, opts) do
    case Keyword.get(opts, key) do
      nil ->
        case get(key) do
          nil -> config_acc
          val -> Map.put(config_acc, key, val)
        end

      val ->
        Map.put(config_acc, key, val)
    end
  end

  defp get!(key) do
    Application.get_env(:flagsmith_engine, :configuration, [])
    |> Keyword.fetch!(key)
  end

  defp get(key) do
    Application.get_env(:flagsmith_engine, :configuration, [])
    |> Keyword.get(key)
  end
end