diff --git a/deps.hex b/deps.hex index f2a7021..b8a93bd 100644 --- a/deps.hex +++ b/deps.hex @@ -26,6 +26,7 @@ mdex ="~> 0.1 # handle markdown #earmark_parser = "~> 1.4.37" # parse markdown (we can let earmark specify the version) html_sanitize_ex = "~> 1.4" # html sanitation sizeable = "~> 1.0" +want = "~> 1.17.1" opentelemetry_api = "~> 1.0" decorator = "~> 1.4 # function decorator helper diff --git a/lib/config_settings/env_config.ex b/lib/config_settings/env_config.ex new file mode 100644 index 0000000..cb06e02 --- /dev/null +++ b/lib/config_settings/env_config.ex @@ -0,0 +1,241 @@ +defmodule Bonfire.Common.EnvConfig do + @moduledoc """ + A Want type that reads environment variables and returns them as keyword lists or map(s). + + ## Features + + - Collects environment variables with a specified prefix. + - Allows key transformation via `:transform_keys`. + - Supports type casting via `:want_values` using the `Want` library. + - Supports both single (e.g. `MYAPP_DB_HOST`) and a list of configuration groups (e.g. `MYAPP_DB_1_HOST`, `MYAPP_DB_2_HOST`, etc). + - Returns keyword lists if all keys are atoms, otherwise returns maps. + + """ + use Want.Type + + @doc """ + Casts environment variables into keyword list(s) or map(s). + + ## Options + + - `prefix` (required): Prefix for environment variable matching. + - `transform_keys` (optional): Function to transform keys (e.g., `&String.to_existing_atom/1`). + - `want_values` (optional): Map of key type casts with optional defaults. + - `want_unknown_keys` (optional): Whether to also include unknown keys when using `want_values`. + - `indexed_list` (optional): Looks for an indexed list of env vars. Default: `false`. + - `max_index` (optional): Maximum index for indexed configs. Default: `1000`. + - `max_empty_streak` (optional): Stops after this many consecutive missing indices. Default: `10`. + + ## Examples + + ### Basic usage (usage as a `Want` custom type) + + iex> System.put_env("TESTA_DB_HOST", "localhost") + iex> Want.cast(System.get_env(), EnvConfig, prefix: "TESTA_DB") # FIXME: Want doesn't currently have a way to cast with a custom type at the top-level, only for data within a map or keyword list + {:ok, %{"host"=> "localhost"}} + + ### Basic usage with prefix only (direct usage) + + iex> System.put_env("TESTA_DB_HOST", "localhost") + iex> EnvConfig.parse(System.get_env(), prefix: "TESTA_DB") + %{"host"=> "localhost"} + + ### Basic usage with prefix only (direct usage, uses env from `System.get_env()` by default) + + iex> System.put_env("TESTA_DB_HOST", "localhost") + iex> EnvConfig.parse(prefix: "TESTA_DB") + %{"host"=> "localhost"} + + ### With key transformation + + iex> System.put_env("TESTB_DB_HOST", "localhost") + iex> System.put_env("TESTB_DB_PORT", "5432") + iex> EnvConfig.parse( + ...> prefix: "TESTB_DB", + ...> transform_keys: &String.to_existing_atom/1, + ...> ) + ...> |> Map.new() # just to make the test assertion easier + %{host: "localhost", port: "5432"} + + ### With type casting for specific keys + + iex> System.put_env("TESTC_DB_PORT", "5432") + iex> System.put_env("TESTC_DB_MAX_CONNECTIONS", "100") + iex> System.put_env("TESTC_DB_SSL", "true") + iex> EnvConfig.parse( + ...> prefix: "TESTC_DB", + ...> want_values: %{ + ...> port: :integer, + ...> max_connections: {:integer, default: 3}, + ...> ssl: :boolean + ...> } + ...> ) + ...> |> Map.new() # just to make the test assertion easier + %{ssl: true, max_connections: 100, port: 5432} + + ### With type casting for only some keys, including unknown keys as well (returns a map with mixed keys) + + iex> System.put_env("TESTU_DB_PORT", "5432") + iex> System.put_env("TESTU_DB_MAX_CONNECTIONS", "100") + iex> %{"max_connections"=> "100", port: 5432} = EnvConfig.parse( + ...> prefix: "TESTU_DB", + ...> want_unknown_keys: true, + ...> want_values: %{ + ...> port: :integer + ...> } + ...> ) + + ### With both transformation and type casting + + iex> System.put_env("TESTD_DB_HOST_", "localhost") + iex> EnvConfig.parse( + ...> prefix: "TESTD_DB", + ...> transform_keys: &String.trim(&1, "_"), + ...> want_values: %{ + ...> host: :string + ...> } + ...> ) + [host: "localhost"] + + ### Indexed list of configs + + iex> System.put_env("TESTE_DB_0_HOST", "localhost") + iex> System.put_env("TESTE_DB_1_HOST", "remote") + iex> EnvConfig.parse( + ...> prefix: "TESTE_DB", + ...> want_values: %{ + ...> host: :string + ...> }, + ...> indexed_list: true + ...> ) + [[host: "localhost"], [host: "remote"]] + """ + @impl true + def cast(input, opts) do + case parse(input, opts) do + {:ok, data} -> {:ok, data} + {:error, e} -> {:error, e} + data -> {:ok, data} + end + end + + def parse(input \\ nil, opts) do + prefix = Keyword.fetch!(opts, :prefix) + indexed_list = Keyword.get(opts, :indexed_list, false) + + parse_configs(input || System.get_env(), indexed_list, opts) + end + + defp parse_configs(env, false, opts) do + with {:ok, config} <- parse_env_vars(env, opts) do + config + end + end + + defp parse_configs(env, true = _indexed, opts) do + prefix = Keyword.fetch!(opts, :prefix) + max_index = Keyword.get(opts, :max_index, 1000) + max_empty_streak = Keyword.get(opts, :max_empty_streak, 10) + + Stream.iterate(0, &(&1 + 1)) + |> Stream.take(max_index + 1) + |> Stream.transform({[], 0}, fn index, {acc, empty_count} -> + config = parse_env_vars(index, env, opts) + + case config do + nil -> + if empty_count >= max_empty_streak - 1 do + {:halt, {acc, empty_count + 1}} + else + {[], {acc, empty_count + 1}} + end + + {:error, e} -> + raise RuntimeError, reason: e + + {:ok, config} -> + {[config], {acc ++ [config], 0}} + end + end) + |> Enum.to_list() + end + + defp parse_env_vars(index \\ nil, env, opts) do + prefix = Keyword.fetch!(opts, :prefix) + want_unknown_keys = Keyword.get(opts, :want_unknown_keys, false) + want_values = Keyword.get(opts, :want_values) + transform_keys = Keyword.get(opts, :transform_keys, & &1) + + # Build the pattern based on whether we're reading an indexed list of vars + prefix_pattern = + if index do + "^#{prefix}_#{index}_(.+)$" + else + "^#{prefix}_(.+)$" + end + + # Get matching environment variables + matching_vars = get_matching_vars(env, prefix_pattern) + + if matching_vars == %{} do + nil + else + matching_vars + |> Enum.map(fn {key, value} -> + transformed_key = + key + |> transform_keys.() + + {transformed_key, value} + end) + |> maybe_want(want_unknown_keys, want_values) + end + end + + defp get_matching_vars(env, prefix_pattern) do + env + |> Enum.filter(fn {key, _value} -> + Regex.match?(~r/#{prefix_pattern}/i, key) + end) + |> Enum.map(fn {key, value} -> + [_full, key_suffix] = Regex.run(~r/#{prefix_pattern}/i, key) + + {String.downcase(key_suffix), value} + end) + |> Enum.into(%{}) + end + + def maybe_want(input, _, nil), do: Map.new(input) + + def maybe_want(input, true, want_values) do + with {:ok, wanted_map} <- Want.map(input, prepare_want_map_schema(want_values)) do + # TODO: submit PR to Want adding an option to include unknown keys instead + {:ok, Enum.into(wanted_map, input) |> Map.new()} + end + end + + def maybe_want(input, _false, want_values) do + Want.keywords(input, prepare_want_map_schema(want_values)) + end + + # TODO: submit PR to Want adding an option to include the type as an atom in schemas if no other options are needed + defp prepare_want_map_schema(%{} = want) do + want + |> Enum.map(fn {k, v} -> + { + k, + prepare_want_map_schema(v) + } + end) + |> Enum.into(%{}) + end + + defp prepare_want_map_schema(nil), do: [key: :string] + defp prepare_want_map_schema(type) when is_atom(type), do: [type: type] + + defp prepare_want_map_schema({type, opts}) when is_list(opts) and is_atom(type), + do: Keyword.put(opts, :type, type) + + defp prepare_want_map_schema(v), do: v + +end \ No newline at end of file diff --git a/lib/text.ex b/lib/text.ex index 9765a70..157962d 100644 --- a/lib/text.ex +++ b/lib/text.ex @@ -117,7 +117,7 @@ defmodule Bonfire.Common.Text do @doc """ Generates a *unique* random string. - "Unique" means that this function will not return the same string more than once on the current OTP runtime. + "Unique" means that this function will not return the same string more than once on the current BEAM runtime, meaning until the application is next restarted. ## Examples @@ -131,7 +131,7 @@ defmodule Bonfire.Common.Text do @doc """ Generates a *unique* random integer. - "Unique" means that this function will not return the same integer more than once on the current OTP runtime. + "Unique" means that this function will not return the same integer more than once on the current BEAM runtime, meaning until the application is next restarted. ## Examples diff --git a/test/common/doctests/config_test.exs b/test/common/doctests/config_test.exs new file mode 100644 index 0000000..aaec98a --- /dev/null +++ b/test/common/doctests/config_test.exs @@ -0,0 +1,12 @@ +defmodule Bonfire.Common.ConfigTest do + use Bonfire.Common.DataCase, async: true + + Bonfire.Common.Config.put(:test_key, "test_value") + + doctest Bonfire.Common.Opts, import: false + doctest Bonfire.Common.Config, import: true + doctest Bonfire.Common.Settings, import: true + + alias Bonfire.Common.EnvConfig + doctest Bonfire.Common.EnvConfig, import: false +end diff --git a/test/common/doctests/doctests_test.exs b/test/common/doctests/doctests_test.exs index 9cdedda..6e6276e 100644 --- a/test/common/doctests/doctests_test.exs +++ b/test/common/doctests/doctests_test.exs @@ -5,12 +5,8 @@ defmodule Bonfire.Common.DocsTest do Bonfire.Common.Config.put(:test_key, "test_value") doctest Bonfire.Common, import: true - doctest Bonfire.Common.Opts, import: false doctest Bonfire.Common.Utils, import: true - doctest Bonfire.Common.Config, import: true - doctest Bonfire.Common.Settings, import: true - doctest Bonfire.Common.Localise, import: true doctest Bonfire.Common.Localise.Gettext, import: true diff --git a/test/common/doctests/e_test.exs b/test/common/doctests/e_test.exs index ab05776..9b36a06 100644 --- a/test/common/doctests/e_test.exs +++ b/test/common/doctests/e_test.exs @@ -2,7 +2,5 @@ defmodule Bonfire.Common.ETest do use Bonfire.Common.DataCase, async: true require Bonfire.Common.E - Bonfire.Common.Config.put(:test_key, "test_value") - doctest Bonfire.Common.E, import: false end