Current section

Files

Jump to
confex lib confex.ex
Raw

lib/confex.ex

defmodule Confex do
@moduledoc """
Confex simplifies reading configuration at run-time with adapter-based system for resolvers.
# Configuration tuples
Whenever there is a configuration that should be resolved at run-time you need to replace its value in `config.exs`
by Confex configuration type. Common structure:
```elixir
@type fetch_statement :: {adapter :: atom() | module(), value_type :: value_type, key :: any(), default :: any()}
| {value_type :: value_type, key :: any()}
| {key :: any(), default :: any()}
| {key :: any()}
```
If `value_type` is set, Confex will automatically cast its value. Otherwise, default type of `:string` is used.
| Confex Type | Elixir Type | Description |
| ----------- | ----------------- | ----------- |
| `:string` | `String.t` | Default. |
| `:integer` | `Integer.t` | Parse Integer value in string. |
| `:float` | `Float.t` | Parse Float value in string. |
| `:boolean` | `true` or `false` | Cast 'true', '1', 'yes' to `true`; 'false', '0', 'no' to `false`. |
| `:atom` | `atom()` | Cast string to atom. |
| `:module` | `module()` | Cast string to module name. |
| `:list` | `List.t` | Cast comma-separated string (`1,2,3`) to list (`[1, 2, 3]`). |
| `:charlist` | `charlist()` | Cast string to charlist. |
# Custom type casting
You can use your own casting function by replacing type with `{module, function, arguments}` tuple,
Confex will call that function with `apply(module, function, [value] ++ arguments)`.
This function returns either `{:ok, value}` or `{:error, reason :: String.t}` tuple.
# Adapters
* `:system` - read configuration from system environment;
* `:system_file` - read file path from system environment and read configuration from this file.
You can create adapter by implementing `Confex.Adapter` behaviour with your own logic.
# Examples
* `var` - any bare values will be left as-is;
* `{:system, "ENV_NAME", default}` - read string from "ENV_NAME" environment variable or return `default` \
if it's not set or has empty value;
* `{:system, "ENV_NAME"}` - same as above, with default value `nil`;
* `{:system, :integer, "ENV_NAME", default}` - read string from "ENV_NAME" environment variable and cast it \
to integer or return `default` if it's not set or has empty value;
* `{:system, :integer, "ENV_NAME"}` - same as `{:system, :integer, "ENV_NAME", nil}`;
* `{{:via, MyAdapter}, :string, "ENV_NAME", default}` - read value by key "ENV_NAME" via adapter `MyAdapter` \
or return `default` if it's not set or has empty value;
* `{{:via, MyAdapter}, :string, "ENV_NAME"}` - same as above, with default value `nil`;
* `{:system, {MyApp.MyType, :cast, [:foo]}, "ENV_NAME"}` - `MyApp.MyType.cast(value, :foo)` call would be made \
to resolve environment variable value.
"""
alias Confex.Resolver
@typep app :: Application.app()
@typep key :: Application.key()
@typep value :: Application.value()
@type configuration_tuple ::
{value_type :: Confex.Type.t(), key :: any(), default :: any()}
| {value_type :: Confex.Type.t(), key :: any()}
| {key :: any(), default :: any()}
| {key :: any()}
@doc """
Returns the value for key in app’s environment in a tuple.
This function mimics `Application.fetch_env/2` function.
If the configuration parameter does not exist or can not be parsed, the function returns :error.
## Example
iex> :ok = System.put_env("MY_TEST_ENV", "foo")
...> Application.put_env(:myapp, :test_var, {:system, "MY_TEST_ENV"})
...> {:ok, "foo"} = #{__MODULE__}.fetch_env(:myapp, :test_var)
{:ok, "foo"}
iex> :ok = System.delete_env("MY_TEST_ENV")
...> Application.put_env(:myapp, :test_var, {:system, :integer, "MY_TEST_ENV", "bar"})
...> {:ok, "bar"} = #{__MODULE__}.fetch_env(:myapp, :test_var)
{:ok, "bar"}
iex> :ok = System.delete_env("MY_TEST_ENV")
...> Application.put_env(:myapp, :test_var, {:system, :integer, "MY_TEST_ENV"})
...> :error = #{__MODULE__}.fetch_env(:myapp, :test_var)
:error
iex> :ok = System.put_env("MY_TEST_ENV", "foo")
...> Application.put_env(:myapp, :test_var, {:system, :integer, "MY_TEST_ENV"})
...> :error = #{__MODULE__}.fetch_env(:myapp, :test_var)
:error
iex> Application.put_env(:myapp, :test_var, 1)
...> {:ok, 1} = #{__MODULE__}.fetch_env(:myapp, :test_var)
{:ok, 1}
"""
@spec fetch_env(app :: app(), key :: key()) :: {:ok, value()} | :error
def fetch_env(app, key) do
with {:ok, config} <- Application.fetch_env(app, key),
{:ok, config} <- Resolver.resolve(config) do
{:ok, config}
else
:error -> :error
{:error, _reason} -> :error
end
end
@doc """
Returns the value for key in app’s environment.
This function mimics `Application.fetch_env!/2` function.
If the configuration parameter does not exist or can not be parsed, raises `ArgumentError`.
## Example
iex> :ok = System.put_env("MY_TEST_ENV", "foo")
...> Application.put_env(:myapp, :test_var, {:system, "MY_TEST_ENV"})
...> "foo" = #{__MODULE__}.fetch_env!(:myapp, :test_var)
"foo"
iex> :ok = System.delete_env("MY_TEST_ENV")
...> Application.put_env(:myapp, :test_var, {:system, :integer, "MY_TEST_ENV", "bar"})
...> "bar" = #{__MODULE__}.fetch_env!(:myapp, :test_var)
"bar"
iex> :ok = System.delete_env("MY_TEST_ENV")
...> Application.put_env(:myapp, :test_var, {:system, :integer, "MY_TEST_ENV"})
...> #{__MODULE__}.fetch_env!(:myapp, :test_var)
** (ArgumentError) can't fetch value for key `:test_var` of application `:myapp`, can not resolve key MY_TEST_ENV value via adapter Elixir.Confex.Adapters.SystemEnvironment
iex> :ok = System.put_env("MY_TEST_ENV", "foo")
...> Application.put_env(:myapp, :test_var, {:system, :integer, "MY_TEST_ENV"})
...> #{__MODULE__}.fetch_env!(:myapp, :test_var)
** (ArgumentError) can't fetch value for key `:test_var` of application `:myapp`, can not cast "foo" to Integer
iex> Application.put_env(:myapp, :test_var, 1)
...> 1 = #{__MODULE__}.fetch_env!(:myapp, :test_var)
1
"""
@spec fetch_env!(app :: app(), key :: key()) :: value() | no_return
def fetch_env!(app, key) do
config = Application.fetch_env!(app, key)
case Resolver.resolve(config) do
{:ok, config} ->
config
{:error, {_reason, message}} ->
raise ArgumentError, "can't fetch value for key `#{inspect(key)}` of application `#{inspect(app)}`, #{message}"
end
end
@doc """
Returns the value for key in app’s environment.
This function mimics `Application.get_env/2` function.
If the configuration parameter does not exist or can not be parsed, returns default value or `nil`.
## Example
iex> :ok = System.put_env("MY_TEST_ENV", "foo")
...> Application.put_env(:myapp, :test_var, {:system, "MY_TEST_ENV"})
...> "foo" = #{__MODULE__}.get_env(:myapp, :test_var)
"foo"
iex> :ok = System.delete_env("MY_TEST_ENV")
...> Application.put_env(:myapp, :test_var, {:system, :integer, "MY_TEST_ENV", "bar"})
...> "bar" = #{__MODULE__}.get_env(:myapp, :test_var)
"bar"
iex> :ok = System.delete_env("MY_TEST_ENV")
...> Application.put_env(:myapp, :test_var, {:system, :integer, "MY_TEST_ENV"})
...> nil = #{__MODULE__}.get_env(:myapp, :test_var)
nil
iex> :ok = System.delete_env("MY_TEST_ENV")
...> Application.put_env(:myapp, :test_var, {:system, :integer, "MY_TEST_ENV"})
...> "baz" = #{__MODULE__}.get_env(:myapp, :test_var, "baz")
"baz"
iex> :ok = System.put_env("MY_TEST_ENV", "foo")
...> Application.put_env(:myapp, :test_var, {:system, :integer, "MY_TEST_ENV"})
...> nil = #{__MODULE__}.get_env(:myapp, :test_var)
nil
iex> nil = #{__MODULE__}.get_env(:myapp, :does_not_exist)
nil
iex> Application.put_env(:myapp, :test_var, 1)
...> 1 = #{__MODULE__}.get_env(:myapp, :test_var)
1
"""
@spec get_env(app :: app(), key :: key(), default :: value()) :: value()
def get_env(app, key, default \\ nil) do
with {:ok, config} <- Application.fetch_env(app, key),
{:ok, config} <- Resolver.resolve(config) do
config
else
:error -> default
{:error, _reason} -> default
end
end
@doc """
Reads all key-value pairs from an application environment and replaces them
with resolved values.
# Example
iex> :ok = System.put_env("MY_TEST_ENV", "foo")
...> Application.put_env(:myapp, :test_var, {:system, "MY_TEST_ENV"})
...> Confex.resolve_env!(:myapp)
...> "foo" = Application.get_env(:myapp, :test_var)
"foo"
iex> :ok = System.put_env("MY_TEST_ENV", "foo")
...> Application.put_env(:myapp, :test_var, {:system, :integer, "MY_TEST_ENV"})
...> Confex.resolve_env!(:myapp)
** (ArgumentError) can't fetch value for key `:test_var` of application `:myapp`, can not cast "foo" to Integer
The optional `opts` argument is passed through to the internal call to
`Application.put_env/4` and may be used to set the `timeout` and/or
`persistent` parameters.
*Warning!* Do not use this function if you want to change your environment
while VM is running. All `{:system, _}` tuples would be replaced with actual values.
"""
@spec resolve_env!(app :: app(), opts :: Keyword.t()) :: [{key(), value()}] | no_return
def resolve_env!(app, opts \\ []) do
app
|> Application.get_all_env()
|> Enum.map(&resolve_and_update_env(app, &1, opts))
end
defp resolve_and_update_env(app, {key, config}, opts) do
case Resolver.resolve(config) do
{:ok, config} ->
:ok = Application.put_env(app, key, config, opts)
{key, config}
{:error, {_reason, message}} ->
raise ArgumentError, "can't fetch value for key `#{inspect(key)}` of application `#{inspect(app)}`, " <> message
end
end
@doc """
Recursively merges configuration with default values.
Both values must be either in `Keyword` or `Map` structures, otherwise ArgumentError is raised.
## Example
iex> [b: 3, a: 1] = #{__MODULE__}.merge_configs!([a: 1], [a: 2, b: 3])
[b: 3, a: 1]
iex> %{a: 1, b: 3} = #{__MODULE__}.merge_configs!(%{a: 1}, %{a: 2, b: 3})
%{a: 1, b: 3}
iex> #{__MODULE__}.merge_configs!(%{a: 1}, [b: 2])
** (ArgumentError) can not merge default values [b: 2] with configuration %{a: 1} because their types mismatch, \
expected both to be either Map or Keyword structures
"""
@spec merge_configs!(config :: Keyword.t() | map, defaults :: Keyword.t() | map) :: Keyword.t() | map
def merge_configs!(config, []), do: config
def merge_configs!(nil, defaults), do: defaults
def merge_configs!(config, defaults) do
cond do
Keyword.keyword?(config) and Keyword.keyword?(defaults) ->
defaults
|> Keyword.merge(config, &compare/3)
|> Resolver.resolve!()
is_map(config) and is_map(defaults) ->
defaults
|> Map.merge(config, &compare/3)
|> Resolver.resolve!()
true ->
raise ArgumentError,
"can not merge default values #{inspect(defaults)} " <>
"with configuration #{inspect(config)} because their types mismatch, " <>
"expected both to be either Map or Keyword structures"
end
end
defp compare(_k, v1, v2) do
if is_map(v2) or Keyword.keyword?(v2) do
merge_configs!(v1, v2)
else
v2
end
end
# Helper to include configuration into module and validate it at compile-time/run-time.
@doc false
defmacro __using__(opts) do
quote bind_quoted: [opts: opts], location: :keep do
@otp_app Keyword.fetch!(opts, :otp_app)
@module_config_defaults Keyword.delete(opts, :otp_app)
@doc """
Returns module configuration.
If application environment contains values in `Keyword` or `Map` struct,
defaults from macro usage will be recursively merged with application configuration.
If one of the configuration parameters does not exist or can not be resolved, raises `ArgumentError`.
"""
@spec config() :: any()
def config do
@otp_app
|> Confex.get_env(__MODULE__)
|> Confex.merge_configs!(@module_config_defaults)
|> Confex.Resolver.resolve!()
|> validate_config!()
end
@spec validate_config!(config :: any()) :: any()
def validate_config!(config), do: config
defoverridable validate_config!: 1
end
end
end