Skip to main content

lib/env_guard.ex

defmodule EnvGuard do
  @external_resource "README.md"

  @moduledoc "README.md"
             |> File.read!()
             |> String.split("<!-- MDOC -->")
             |> Enum.fetch!(1)
  alias EnvGuard.Cast
  alias EnvGuard.Constraints
  alias EnvGuard.Env

  @type constraint :: EnvGuard.Types.constraint()
  @type type :: EnvGuard.Types.type()

  @doc """
  Fetches a required environment variable, casting it to `type` and checking
  `constraints`.

  Returns the cast value. Raises a `RuntimeError` if the variable is not set,
  cannot be cast to `type`, or violates one of the `constraints`.

  ## Examples

      secret_key_base = required("SECRET_KEY_BASE", :string, min_length: 64)
      pool_size = required("POOL_SIZE", :integer, min: 1, max: 100)

  """
  @spec required(String.t(), type, [constraint]) :: any()
  def required(env_name, type, constraints \\ []) do
    case test_var(env_name, type, constraints) do
      {:ok, value} ->
        value

      {:error, :env_var_not_set} ->
        raise "Environment variable #{env_name} is not set"

      {:error, :invalid_type, err} ->
        raise "Environment variable #{env_name} is not of type #{inspect(type)}. #{err}"

      {:error, :constraint_violation, err} ->
        raise "Environment variable #{env_name} does not meet constraints. #{err}"
    end
  end

  @doc """
  Fetches an optional environment variable, falling back to `default`.

  When the variable is set, it is cast to `type` and checked against
  `constraints`, and the cast value is returned. When it is not set — or it is
  set but fails casting or a constraint — `default` is returned instead. In the
  failure cases a warning is logged via `Logger` before falling back.

  ## Examples

      phx_server = optional("PHX_SERVER", :boolean, false)
      log_level = optional("LOG_LEVEL", {:enum, ["debug", "info", "warning"]}, "info")

  """
  @spec optional(String.t(), type, default, [constraint]) :: any() | default when default: var
  def optional(env_name, type, default, constraints \\ []) do
    case test_var(env_name, type, constraints) do
      {:ok, value} ->
        value

      {:error, :env_var_not_set} ->
        default

      {:error, :invalid_type, err} ->
        require Logger

        Logger.warning(
          "Environment variable #{env_name} is set but not of type #{inspect(type)}. #{err} Falling back to default."
        )

        default

      {:error, :constraint_violation, err} ->
        require Logger

        Logger.warning(
          "Environment variable #{env_name} is set but does not meet constraints. #{err} Falling back to default."
        )

        default
    end
  end

  # ---------------------------------------------------------------------------#
  #                                Helpers                                     #
  # ---------------------------------------------------------------------------#

  @spec test_var(String.t(), type, [constraint]) ::
          {:ok, any()} | {:error, atom()} | {:error, atom(), String.t()}
  defp test_var(name, type, constraints) do
    with {:ok, value} <- Env.fetch(name),
         {:ok, value} <- Cast.cast(value, type),
         {:ok, value} <- Constraints.check_constraints(value, type, constraints) do
      {:ok, value}
    else
      {:error, :constraint_violation, err} ->
        {:error, :constraint_violation, err}

      {:error, :cast_fail, err} ->
        {:error, :invalid_type, err}

      {:error, :env_var_not_set} ->
        {:error, :env_var_not_set}
    end
  end
end