if Code.ensure_loaded?(Config.Provider) and Code.ensure_loaded?(:yamerl) do
defmodule Skogsra.Provider.Yaml do
@moduledoc """
This module defines a YAML config provider for Skogsra.
> **Important**: You need to add `{:yamerl, "~> 0.7"}` as a dependency
> along with `Skogsra` because the dependency is optional.
The following is an example for `Ecto` configuration:
```yaml
# file: /etc/my_app/config.yml
- app: "my_app"
module: "MyApp.Repo"
config:
database: "my_app_db"
username: "postgres"
password: "postgres"
hostname: "localhost"
port: 5432
```
Then in your release configuration you can add the following:
```elixir
config_providers: [{Skogsra.Provider.Yaml, ["/etc/my_app/config.yml"]}]
```
Once the system boots, it'll parse and add the YAML configuration.
"""
@behaviour Config.Provider
require Logger
@type key :: [integer()]
###########
# Callbacks
@impl Config.Provider
def init(path) when is_binary(path) do
path
end
@impl Config.Provider
def load(config, path) do
{:ok, _} = Application.ensure_all_started(:logger)
{:ok, _} = Application.ensure_all_started(:yamerl)
with {:ok, contents} <- File.read(path),
{:ok, parsed} <- parse(contents),
{:ok, new_config} <- load_config(parsed) do
Config.Reader.merge(config, new_config)
else
{:error, reason} ->
Logger.warning(
"File #{path} cannot be read/loaded " <>
"due to #{inspect(reason)}"
)
config
end
end
#########
# Helpers
@spec parse(binary()) :: {:ok, list()} | {:error, term()}
defp parse(contents)
defp parse(contents) when is_binary(contents) do
[yml] = :yamerl.decode(contents)
{:ok, yml}
rescue
_ ->
{:error, "Cannot parse configuration YAML file"}
end
# Loads a YAML config from a binary.
@spec load_config(list()) :: {:ok, keyword()} | {:error, term()}
@spec load_config(list(), list()) :: {:ok, keyword()} | {:error, term()}
defp load_config(yml, acc \\ [])
defp load_config([], acc) do
config =
acc
|> List.flatten()
|> merge_duplicates()
{:ok, config}
end
defp load_config([yml | rest], acc) do
with {:ok, config} <- load_app_config(yml) do
load_config(rest, [config | acc])
end
end
@spec merge_duplicates(keyword()) :: keyword()
defp merge_duplicates(config)
defp merge_duplicates(config) when is_list(config) do
config
|> Enum.reduce(%{}, &do_merge_duplicates/2)
|> Enum.to_list()
end
@spec do_merge_duplicates({atom(), term()}, map()) :: map()
defp do_merge_duplicates(pair, acc)
defp do_merge_duplicates({key, [{_, _} | _] = value}, acc) do
Map.update(acc, key, value, fn
[{_, _} | _] = existing -> merge_duplicates(existing ++ value)
_existing -> merge_duplicates(value)
end)
end
defp do_merge_duplicates({key, value}, acc) do
Map.put(acc, key, value)
end
# Loads an app config from a YAML parsed document.
@spec load_app_config(list()) :: {:ok, keyword()} | {:error, term()}
defp load_app_config(config) do
with {:ok, app} <- get_app(config),
{:ok, namespace} <- get_namespace(config),
{:ok, module} <- get_module(config),
{:ok, app_config} <- get_config(config) do
module = module || namespace
if is_nil(module) do
{:ok, [{app, app_config}]}
else
{:ok, [{app, [{module, app_config}]}]}
end
end
end
# Gets the name of an app.
@spec get_app(list()) :: {:ok, atom()} | {:error, term()}
defp get_app(nodes) when is_list(nodes) do
value =
nodes
|> get_key!(~c"app")
|> to_atom()
{:ok, value}
rescue
_ ->
{:error, "Name of the app is invalid"}
end
# Gets namespace for an app.
@spec get_namespace(list()) :: {:ok, module()} | {:error, term()}
defp get_namespace(nodes) do
case get_key(nodes, ~c"namespace") do
nil ->
{:ok, nil}
value ->
value =
value
|> List.to_string()
|> String.split(~r/\./)
|> Module.concat()
{:ok, value}
end
rescue
_ ->
{:error, "Namespace is invalid"}
end
# Gets module to be configured.
@spec get_module(list()) :: {:ok, module()} | {:error, term()}
defp get_module(nodes) do
case get_key(nodes, ~c"module") do
nil ->
{:ok, nil}
value ->
value =
value
|> List.to_string()
|> String.split(~r/\./)
|> Module.safe_concat()
{:ok, value}
end
rescue
_ ->
{:error, "Module is invalid"}
end
# Gets config key for an app.
@spec get_config(list()) :: {:ok, keyword()} | {:error, term()}
defp get_config(nodes) do
value =
nodes
|> get_key!(~c"config")
|> Enum.map(&expand_variable/1)
|> List.flatten()
{:ok, value}
rescue
_ ->
{:error, "Config is invalid"}
end
# Expands a variable
@spec expand_variable({key(), term()}) :: {atom(), term()}
defp expand_variable({key, value}) do
key = to_atom(key)
value = expand_value(value)
{key, value}
end
# Expand a value
@spec expand_value(term()) :: term()
defp expand_value([char | _] = value) when is_integer(char) do
"#{value}"
end
defp expand_value([{_, _} | _] = values) do
values
|> Enum.map(&expand_variable/1)
|> List.flatten()
end
defp expand_value(values) when is_list(values) do
Enum.map(values, &expand_value/1)
end
defp expand_value(value) do
value
end
# Transforms a char list to atom.
@spec to_atom(key()) :: atom() | no_return()
defp to_atom(key) do
key
|> List.to_string()
|> String.to_atom()
end
# Gets a key from a node list.
@spec get_key(list(), key()) :: nil | term()
defp get_key(nodes, key) do
with {^key, value} <- Enum.find(nodes, fn {k, _} -> k == key end) do
value
end
end
# Gets a key from a node list or fails if it's no found.
@spec get_key!(list(), key()) :: term() | no_return()
defp get_key!(nodes, key) do
case get_key(nodes, key) do
nil -> raise RuntimeError, message: "Key #{key} not found"
value -> value
end
end
end
end