defmodule Toml.Provider do @moduledoc """ This module provides an implementation of both the Distilery and Elixir config provider behaviours, so that TOML files can be used for configuration in releases. ## Distillery Usage Add the following to your `rel/config.exs` release :myapp do # ...snip... set config_providers: [ {Toml.Provider, [path: "${XDG_CONFIG_DIR}/myapp.toml", transforms: [...]]} ] end ## Elixir Usage config_providers: [ {Toml.Provider, [ path: {:system, "XDG_CONFIG_DIR", "myapp.toml"}, transforms: [...] ]} ] This will result in `Toml.Provider` being invoked during boot, at which point it will evaluate the given path and read the TOML file it finds. If one is not found, or is not accessible, the provider will raise an error, and the boot sequence will terminate unsuccessfully. If it succeeds, it persists settings in the file to the application environment (i.e. you access it via `Application.get_env/2`). The config provider expects a certain format to the TOML file, namely that keys at the root of the document are tables which correspond to applications which need to be configured. If it encounters keys at the root of the document which are not tables, they are ignored. ## Options The same options that `Toml.parse/2` accepts are able to be provided to `Toml.Provider`, but there are two main differences: * `:path` (required) - sets the path to the TOML file to load config from * `:keys` - defaults to `:atoms`, but can be set to `:atoms!` if desired, all other key types are ignored, as it results in an invalid config structure """ has_config_api? = Version.match?(Version.parse!(System.version()), ">= 1.9.0") if has_config_api? do @behaviour Config.Provider end @doc false def init(opts) when is_list(opts) do opts = case Keyword.get(opts, :keys) do a when a in [:atoms, :atoms!] -> opts _ -> Keyword.put(opts, :keys, :atoms) end if is_distillery_env?() do # When running under Distillery, init performs load load([], opts) opts else # With 1.9 releases, init just preps arguments for `load` opts end end @doc false def load(config, opts) when is_list(opts) do path = Keyword.fetch!(opts, :path) # path expansion should happen in load rather than init, otherwise # in 1.9 releases using a {:system, env_var, path} tuple an error # will be raised if the env var is not set in the build environment with {:ok, path} <- expand_path(path) do map = Toml.decode_file!(path, opts) persist(config, to_keyword(map)) else {:error, reason} -> exit(reason) end end @doc false def get([app | keypath]) do config = Application.get_all_env(app) case get_in(config, keypath) do nil -> nil val -> {:ok, val} end end if has_config_api? do defp persist(config, keyword) when is_list(keyword) do config = Config.Reader.merge(config, keyword) Application.put_all_env(config, persistent: true) config end else defp persist(config, keyword) when is_list(keyword) do # For each app for {app, app_config} <- keyword do # Get base config base = Application.get_all_env(app) base = deep_merge(base, Keyword.get(config, app, [])) # Merge this app's TOML config over the base config merged = deep_merge(base, app_config) # Persist key/value pairs for this app for {k, v} <- merged do Application.put_env(app, k, v, persistent: true) end # Return merged config {app, merged} end end defp deep_merge(a, b) when is_list(a) and is_list(b) do if Keyword.keyword?(a) and Keyword.keyword?(b) do Keyword.merge(a, b, &deep_merge/3) else b end end defp deep_merge(_k, a, b) when is_list(a) and is_list(b) do if Keyword.keyword?(a) and Keyword.keyword?(b) do Keyword.merge(a, b, &deep_merge/3) else b end end defp deep_merge(_k, a, b) when is_map(a) and is_map(b) do Map.merge(a, b, &deep_merge/3) end defp deep_merge(_k, _a, b), do: b end # At the top level, convert the map to a keyword list of keyword lists # Keys with no children (i.e. keys which are not tables) are dropped defp to_keyword(map) when is_map(map) do for {k, v} <- map, v2 = to_keyword2(v), is_list(v2), into: [] do {k, v2} end end # For all other values, convert tables to keywords defp to_keyword2(map) when is_map(map) do Enum.map(map, fn {k, v} -> {k, to_keyword2(v)} end) end # And leave all other values untouched defp to_keyword2(term), do: term def expand_path(path) when is_binary(path) do case expand_path(path, <<>>) do {:ok, p} -> {:ok, Path.expand(p)} {:error, _} = err -> err end end if has_config_api? do def expand_path(path) do {:ok, Config.Provider.resolve_config_path!(path)} end end defp expand_path(<<>>, acc), do: {:ok, acc} defp expand_path(<>, acc) do case expand_var(rest) do {:ok, var, rest} -> expand_path(rest, acc <> var) {:error, _} = err -> err end end defp expand_path(<>, acc) do expand_path(rest, <>) end defp expand_var(bin), do: expand_var(bin, <<>>) defp expand_var(<<>>, _acc), do: {:error, :unclosed_var_expansion} defp expand_var(<>, acc), do: {:ok, System.get_env(acc) || "", rest} defp expand_var(<>, acc) do expand_var(rest, <>) end defp is_distillery_env? do if Version.match?(Version.parse!(System.version()), ">= 1.9.0") do Code.ensure_loaded?(Distillery.Releases.Config.Provider) else true end rescue _ -> case :erlang.phash2(1, 1) do 0 -> true _ -> false end end end