Current section
Files
Jump to
Current section
Files
lib/confy.ex
defmodule Confy do
@moduledoc """
Confy allows you to make your configuration explicit:
- Specify exactly what fields are expected.
- Specify exactly what values these fields might take, by giving them parser-functions.
- Load their values from a slew of different locations, with 'explicitly passed in to the function' as final option.
"""
defmodule MissingRequiredFieldsError do
@moduledoc """
Default exception to be raised when a required field is not existent in any configuration source.
"""
defexception [:message]
end
defmodule ParsingError do
@moduledoc """
Default exception to be raised when it is impossible to parse one of the configuration values.
(See also `Confy.Parsers`)
"""
defexception [:message]
end
defmodule Schema do
@moduledoc """
Functions that can be used inside `Confy.defconfig/2`.
"""
@doc """
Specifies a field that is part of the configuration struct.
Can/should only be called inside a call to `Confy.defconfig`.
- `name` should be an atom representing the field. It will also become the field name for the struct that is created.
- `parser` should either be:
- an arity-one function reference like `&YourModule.some_type_parser/1`.
- An atom representing one of the common parser function names in `Confy.Parsers` like `:integer`, `:string`, `:boolean` or `:term`.
Supported field options are:
- `default:`, supplies a default value to this field. If not set, the configuration field is set to be _required_.
You are highly encouraged to add a `@doc`umentation text above each and every field;
these will be added to the configuration's module documentation.
"""
defmacro field(name, parser, options \\ []) do
quote do
field_documentation = Module.delete_attribute(__MODULE__, :doc)
field_documentation =
case field_documentation do
{_line, val} ->
val
nil ->
IO.warn(
"Missing documentation for configuration field `#{unquote(name)}`. Please add it by adding `@doc \"field documentation here\"` above the line where you define it."
)
""
end
Confy.__field__(
__MODULE__,
unquote(name),
unquote(parser),
field_documentation,
unquote(options)
)
end
end
end
@doc """
Defines a configuration structure in the current module.
Fields are added to this configuration structure by calling `Confy.Schema.field/3`
(which can be called just as `field` because `Confy.Schema` is autoamatically imported into the
inner context of the call to `defconfig`.)
The `options` that can be passed to this module are used as defaults for the options passed to a call to `Confy.load/2` or `YourModule.load/1`.
See also `Confy.Schema.field/3` and `Confy.Options`
## Reflection
The special function `__confy__/1` will be defined on the module as well. It is not intended to be used
by people that want to consume your configuration,
but it is there to e.g. allow `Confy.Provider` implementations to be smarter
in how they fetch the configuration for the module. For instance, configuration
might be lazily fetched, when knowing what field names exist beforehand.
`YourModule.__confy__/1` supports the following publicly usable parameters:
- `__confy__(:field_names)` returns a MapSet of atoms, one per field in the configuration structure.
- `__confy__(:defaults)` returns a Map containing only the `field_name => value`s of field names having default values.
- `__confy__(:requireds)` returns a MapSet of atoms, one per required field in the configuration structure.
- `__confy__(:parsers)` returns a Map of the format `field_name => parser`.
"""
defmacro defconfig(options \\ [], do: block) do
quote do
import Confy.Schema
Module.register_attribute(__MODULE__, :config_fields, accumulate: true)
try do
unquote(block)
after
config_fields =
Module.get_attribute(__MODULE__, :config_fields)
|> Enum.reverse()
{line_number, existing_moduledoc} =
Module.delete_attribute(__MODULE__, :moduledoc) || {0, ""}
Module.put_attribute(
__MODULE__,
:moduledoc,
{line_number, existing_moduledoc <> Confy.__config_doc__(config_fields)}
)
defstruct(Confy.__struct_fields__(config_fields))
# Reflection; part of 'public API' for Config Providers,
# but not of public API for consumers of '__MODULE__'.
@field_names Confy.__field_names__(config_fields)
@defaults Confy.__defaults__(config_fields)
@required_fields Confy.__required_fields__(config_fields)
@parsers Confy.__parsers__(config_fields)
# Super secret private reflection; doing this at compile-time speeds up `load`.
@la_defaults for {name, val} <- @defaults, into: %{}, do: {name, [val]}
@la_requireds for name <- @required_fields, into: %{}, do: {name, []}
@loading_accumulator Map.merge(@la_defaults, @la_requireds)
@doc false
def __confy__(:field_names), do: @field_names
def __confy__(:defaults), do: @defaults
def __confy__(:required_fields), do: @required_fields
def __confy__(:parsers), do: @parsers
def __confy__(:__loading_begin_accumulator__), do: @loading_accumulator
@doc """
Loads, parses, and normalizes the configuration of `#{inspect(__MODULE__)}`, based on the current source settings, returning the result as a struct.
For more information about the options this function supports, see
`Confy.load/2` and `Confy.Options`
"""
def load(options \\ []), do: Confy.load(__MODULE__, options ++ unquote(options))
@doc """
Loads, parses and normalizes the configuration of `#{inspect(__MODULE__)}`, using the provided `explicit_values` (and falling back to values configured elsewhere)
For more information about the options this function supports, see
`Confy.load_explicit/3` and `Confy.Options`
"""
def load_explicit(explicit_values, options \\ []),
do: Confy.load_explicit(__MODULE__, explicit_values, options ++ unquote(options))
:ok
end
end
end
@doc """
Loads, parses, and normalizes the configuration of `config_module`, based on the current source settings, returning the result as a struct.
(This is the more general way of calling `config_module.load/1`).
See `Confy.Options` for more information of the options that can be supplied to this function,
and how it can be configured further.
"""
def load(config_module, options \\ []) do
explicit_values =
(options[:explicit_values] || [])
|> Enum.to_list()
prevent_improper_explicit_values!(config_module, explicit_values)
options = parse_options(config_module, options)
# Values explicitly passed in are always the last, highest priority source.
sources = options.sources ++ [explicit_values]
sources_configs = load_sources_configs(config_module, sources)
if options.explain do
sources_configs
else
prevent_missing_required_fields!(config_module, sources_configs, options)
parsers = config_module.__confy__(:parsers)
sources_configs
|> Enum.map(&try_load_and_parse!(&1, parsers, config_module, options))
|> (fn config -> struct(config_module, config) end).()
end
end
@doc """
Loads, parses and normalizes the configuration of `config_module`, using the provided `explicit_values` (and falling back to values configured elsewhere)
This call is conceptually the same as `Confy.load(config_module, [explicit_values: [] | options])`, but makes it more explicit that values
are meant to be passed in as arguments.
Prefer this function if you do not intend to use Confy's 'cascading configuration' functionality, such as when e.g. just parsing options passed to a function,
`use`-statement or other macro.
"""
def load_explicit(config_module, explicit_values, options \\ []) do
full_options = put_in(options, [:explicit_values], explicit_values)
load(config_module, full_options)
end
# Raises if `explicit_values` contains keys that are not part of the configuration structure of `config_module`.
defp prevent_improper_explicit_values!(config_module, explicit_values) do
improper_explicit_values =
explicit_values
|> Keyword.keys()
|> MapSet.new()
|> MapSet.difference(config_module.__confy__(:field_names))
if(Enum.any?(improper_explicit_values)) do
raise ArgumentError,
"The following fields passed as `:explicit_values` are not part of `#{
inspect(config_module)
}`'s fields: `#{improper_explicit_values |> Enum.map(&inspect/1) |> Enum.join(", ")}`."
end
end
# Raises appropriate error if required fields of `config_module` are missing in `sources_configs`.
defp prevent_missing_required_fields!(config_module, sources_configs, options) do
missing_required_fields =
sources_configs
|> Enum.filter(fn {_key, value} -> value == [] end)
|> Enum.into(%{})
if Enum.any?(missing_required_fields) do
field_names = Map.keys(missing_required_fields)
raise options.missing_fields_error,
"Missing required fields for `#{config_module}`: `#{
field_names |> Enum.map(&inspect/1) |> Enum.join(", ")
}`."
end
end
# Loads the listed `sources` in turn, warning for missing ones.
defp load_sources_configs(config_module, sources) do
sources
|> Enum.map(&load_source(&1, config_module))
|> reject_and_warn_unloadable_sources(config_module)
|> list_of_configs2config_of_lists(config_module)
end
# Attempts to parse the highest-priority value of a given `name`.
# Upon failure, raises an appropriate error.
defp try_load_and_parse!({name, values}, parsers, config_module, options) do
case parsers[name].(hd(values)) do
{:ok, value} ->
{name, value}
{:error, reason} ->
raise options.parsing_error,
reason <>
" (required for loading the field `#{inspect(name)}` of `#{inspect(config_module)}`)"
other ->
raise ArgumentError,
"Improper Confy configuration parser result. Parser `#{inspect(parsers[name])}` is supposed to return either {:ok, val} or {:error, reason} but instead, `#{
inspect(other)
}` was returned."
end
end
# Parses `options` into a normalized `Confy.Options` struct.
defp parse_options(config_module, options)
# Catch bootstrapping-case
defp parse_options(Confy.Options, options) do
%{
__struct__: Confy.Options,
sources:
options[:sources] ||
Process.get(:confy, [])[:sources] ||
Application.get_env(Confy, :sources) ||
[],
missing_fields_error:
options[:missing_fields_error] ||
Process.get(Confy, [])[:missing_fields_error] ||
Application.get_env(Confy, :missing_fields_error) ||
Confy.MissingRequiredFieldsError,
parsing_error:
options[:parsing_error] ||
Process.get(Confy, [])[:parsing_error] ||
Application.get_env(Confy, :parsing_error) ||
Confy.ParsingError,
explain:
options[:explain] ||
false
}
end
defp parse_options(_config_module, options), do: Confy.Options.load(explicit_values: options)
# Turns a list of Access-implementations into a map of lists.
# In the end, empty values will look like `key: []`.
# And filled ones like `key: [something | ...]`
defp list_of_configs2config_of_lists(list_of_configs, config_module) do
begin_accumulator = config_module.__confy__(:__loading_begin_accumulator__)
list_of_configs
|> Enum.reduce(begin_accumulator, fn config, acc ->
:maps.map(
fn key, values_list ->
case Access.fetch(config, key) do
{:ok, val} -> [val | values_list]
:error -> values_list
end
end,
acc
)
end)
end
defp load_source(source, config_module) do
{source, Confy.Provider.load(source, config_module)}
end
# Logs errors on sources that cannot be found,
# and transforms `{source, {:ok, config}} -> config` for all successful configurations.
defp reject_and_warn_unloadable_sources(sources_configs, config_module) do
require Logger
sources_configs
|> Enum.flat_map(fn
{_source, {:ok, config}} ->
[config]
{source, {:error, error}} ->
case error do
:not_found ->
Logger.error("""
While loading the configuration `#{inspect(config_module)}`, the source `#{
inspect(source)
}` could not be found.
Please make sure it exists.
In the case you do not need this source, consider removing this source from the `sources:` list.
""")
:malformed ->
Logger.error("""
While loading the configuration `#{inspect(config_module)}`, found out that
it was not possible to parse the configuration inside #{inspect(source)}.
This usually indicates a grave problem!
""")
end
[]
end)
end
@doc false
# Handles the actual work of the `field` macro.
def __field__(module, name, parser, field_documentation, options) do
parser = normalize_parser(parser)
Module.put_attribute(module, :config_fields, {name, parser, field_documentation, options})
end
# Extracts the struct definition keyword list
# from the outputs of the list of `field` calls.
@doc false
def __struct_fields__(config_fields) do
config_fields
|> Enum.map(fn {name, _parser, _documentation, options} ->
{name, options[:default]}
end)
end
@doc false
# Builds the module documentation
# for the configuration.
# This includes information on each of the fields,
# with the user-supplied documentation description,
# as well as the used parser and potential default value.
def __config_doc__(config_fields) do
acc =
config_fields
|> Enum.reduce("", fn {name, parser, documentation, options}, acc ->
doc = """
### #{name}
#{documentation || "ASDF"}
Validated/parsed by calling `#{Macro.to_string(parser)}`.
"""
doc =
case Access.fetch(options, :default) do
{:ok, val} ->
"""
#{doc}
Defaults to `#{inspect(val)}`.
"""
:error ->
"""
#{doc}
Required field.
"""
end
acc <> doc
end)
"""
## Configuration structure documentation:
This configuration was made using the `Confy` library.
It contains the following fields:
#{acc}
"""
end
@doc false
# Builds a map of fields with default values.
def __defaults__(config_fields) do
config_fields
|> Enum.filter(fn {_name, _parser, _documentation, options} ->
case Access.fetch(options, :default) do
{:ok, _} -> true
:error -> false
end
end)
|> Enum.map(fn {name, _parser, _documentation, options} ->
{name, options[:default]}
end)
|> Enum.into(%{})
end
@doc false
# Builds a MapSet of all the required fields
def __required_fields__(config_fields) do
config_fields
|> Enum.filter(fn {_name, _parser, _documentation, options} ->
case Access.fetch(options, :default) do
:error -> true
_ -> false
end
end)
|> Enum.map(fn {name, _, _, _} ->
name
end)
|> MapSet.new()
end
@doc false
# Builds a MapSet of all the fields
def __field_names__(config_fields) do
config_fields
|> Enum.map(fn {name, _, _, _} -> name end)
|> MapSet.new()
end
@doc false
# Builds a map of parsers for the fields.
def __parsers__(config_fields) do
config_fields
|> Enum.map(fn {name, parser, _, _} ->
{name, parser}
end)
|> Enum.into(%{})
end
# Replaces simplified atom parsers with
# an actual reference to the parser function in `Confy.Parsers`.
# NOTE: I dislke the necessity of `Code.eval_quoted` here, but do not currently know of another way.
defp normalize_parser(parser) when is_atom(parser) do
case Confy.Parsers.__info__(:functions)[parser] do
nil ->
raise ArgumentError,
"Parser shorthand `#{inspect(parser)}` was not recognized. Only atoms representing names of functions that live in `Confy.Parsers` are."
1 ->
Function.capture(Confy.Parsers, parser, 1)
end
end
defp normalize_parser(other), do: other
end