lib/config_helpers.ex

defmodule ConfigHelpers do
  @moduledoc """
  Module to be imported by `runtime.exs` to allow accessing the `get_env` function.

  Note that functions of this module are not supposed to be called by regular application code since they use
  `Config.config_env()` which is only possible while reading config files.
  """
  require Config

  @accepted_true_values ~w(true True TRUE yes YES t T 1)

  @doc """
  3-arity clause needed to support single default value for all envs together with options list. See `get_env/2` for
  documentation.
  """
  def get_env(key, default, opts) when not is_list(default) and is_list(opts) do
    get_env(key, [default: default] ++ opts)
  end

  @doc """
  Read configuration from an environment variables while providing defaults for some envs.

  Defaults can be given as keyword list or single value (equivalent to `default: <value>`). If the given `key` is
  not set in the environment (and not empty, see `allow_empty`), default keys are checked in the following order:

  1. Current env, e.g. `:dev`, `:test` or `:prod`
  2. `:non_prod`, if current env is `:dev` or `:test`
  3. `:default`

  If no value can be found, `ConfigHelpers.EnvError` is raised.

  The following options can be added to the defaults list:

  * `as:` Typecast values to `:integer` or `:boolean`
    * For `:boolean`, the following values are considered truthy: `#{inspect(@accepted_true_values)}`
    * For `:integer`, passing a value that fails conversion using `String.to_integer/1` causes `ArgumentError` to be
      raised.
  * `allow_empty:` Whether to accept empty strings as being set. By default, empty strings don't count as being set.
  """
  def get_env(key, defaults \\ [])

  def get_env(key, default) when not is_list(default) do
    get_env(key, default: default)
  end

  def get_env(key, defaults) do
    case fetch_env(key) do
      {:ok, value} ->
        if value != "" or Keyword.get(defaults, :allow_empty, false),
          do: value,
          else: find_default(key, defaults)

      :disabled ->
        nil

      :error ->
        find_default(key, defaults)
    end
    |> maybe_cast(defaults, key)
  end

  defp fetch_env(key) do
    # Check if the env var is disabled first
    case System.fetch_env("DISABLED_#{key}") do
      {:ok, value} when value in @accepted_true_values ->
        :disabled

      _ ->
        System.fetch_env(key)
    end
  end

  defp find_default(key, defaults) do
    env = target_env()

    with :error <- Keyword.fetch(defaults, env),
         :error <- maybe_fetch_non_prod(defaults, env),
         :error <- Keyword.fetch(defaults, :default) do
      raise __MODULE__.EnvError, var: key, env: env
    else
      {:ok, value} ->
        value
    end
  end

  defp target_env() do
    Config.config_env()
  end

  defp maybe_fetch_non_prod(_defaults, :prod), do: :error
  defp maybe_fetch_non_prod(defaults, _env), do: Keyword.fetch(defaults, :non_prod)

  defp maybe_cast(value, options, key) do
    case Keyword.fetch(options, :as) do
      {:ok, :integer} ->
        cast_to_integer(value, key)

      {:ok, :boolean} ->
        cast_to_boolean(value)

      {:ok, other} ->
        raise "unexpected type specified for 'get_env(..., as:)': #{inspect(other)}"

      :error ->
        try_infer_cast(value, options, key)
    end
  end

  defp try_infer_cast(value, options, key) do
    options
    # Pick first defaults entry, avoid picking an option.
    |> Enum.find(fn {key, _value} -> key in ~w(dev test prod non_prod default)a end)
    |> case do
      {_key, default} when is_integer(default) ->
        cast_to_integer(value, key)

      {_key, default} when is_boolean(default) ->
        cast_to_boolean(value)

      _ ->
        value
    end
  end

  defp cast_to_integer(value, _key) when is_integer(value), do: value

  defp cast_to_integer(value, key) when is_binary(value) do
    String.to_integer(value)
  rescue
    ArgumentError ->
      reraise %ArgumentError{
                message:
                  "unable to convert #{inspect(value)} to integer for env variable #{inspect(key)}"
              },
              __STACKTRACE__
  end

  defp cast_to_boolean(value) when is_boolean(value), do: value

  defp cast_to_boolean(value) when is_binary(value), do: value in @accepted_true_values
end