|
| 1 | +defmodule Provider do |
| 2 | + @moduledoc """ |
| 3 | + Retrieval of configuration settings from external sources, such as OS env vars. |
| 4 | +
|
| 5 | + This module is an alternative to app env for retrieval of configuration settings. It allows you |
| 6 | + to properly consolidate system settings, define per-env defaults, add strong typing, and |
| 7 | + compile time guarantees. |
| 8 | +
|
| 9 | + ## Basic example |
| 10 | +
|
| 11 | + defmodule MySystem.Config do |
| 12 | + use Provider, |
| 13 | + source: Provider.SystemEnv, |
| 14 | + params: [ |
| 15 | + {:db_host, dev: "localhost"}, |
| 16 | + {:db_name, dev: "my_db_dev", test: "my_db_test"}, |
| 17 | + {:db_pool_size, type: :integer, default: 10}, |
| 18 | + # ... |
| 19 | + ] |
| 20 | + end |
| 21 | +
|
| 22 | + This will generate the following functions in the module: |
| 23 | +
|
| 24 | + - `fetch_all` - retrieves values of all parameters |
| 25 | + - `validate!` - validates that all parameters are correctly provided |
| 26 | + - `db_host`, `db_name`, `db_pool_size`, ... - getter of each declared parameter |
| 27 | +
|
| 28 | + ## Describing params |
| 29 | +
|
| 30 | + Each param is described in the shape of `{param_name, param_spec}`, where `param_name` is an |
| 31 | + atom, and `param_spec` is a keyword list. Providing only `param_name` (without a tuple), is the |
| 32 | + same as `{param_name, []}`. |
| 33 | +
|
| 34 | + The following keys can be used in the `param_spec`: |
| 35 | +
|
| 36 | + - `:type` - Param type (see `t:type/0`). Defaults to `:string`. |
| 37 | + - `:default` - Default value used if the param is not provided. Defaults to `nil` (no default). |
| 38 | + - `:dev` - Default value in `:dev` and `:test` mix env. Defaults to `nil` (no default). |
| 39 | + - `:test` - Default value in `:test` mix env. Defaults to `nil` (no default). |
| 40 | +
|
| 41 | + Default options are considered in the following order: |
| 42 | +
|
| 43 | + 1. `:test` (if mix env is `:test`) |
| 44 | + 2. `:dev` (if mix env is either `:dev` or `:test`) |
| 45 | + 3. `:default` |
| 46 | +
|
| 47 | + For example, if `:test` and `:default` options are given, the `:test` value will be used as a |
| 48 | + default in `:test` env, while `:default` will be used in all other envs. |
| 49 | +
|
| 50 | + When you invoke the generated functions, values will be retrieved from the external storage |
| 51 | + (e.g. OS env). If some value is not available, a default value will be used (if provided). The |
| 52 | + values are then casted according to the parameter type. |
| 53 | +
|
| 54 | + Each default can be a constant, but it can also be an expression, which is evaluated at runtime. |
| 55 | + For example: |
| 56 | +
|
| 57 | + defmodule MySystem.Config do |
| 58 | + use Provider, |
| 59 | + source: Provider.SystemEnv, |
| 60 | + params: [ |
| 61 | + # db_name/0 will be invoked when you try to retrieve this parameter (or all parameters) |
| 62 | + {:db_name, dev: db_name()}, |
| 63 | + # ... |
| 64 | + ] |
| 65 | +
|
| 66 | + defp db_name(), do: #... |
| 67 | + end |
| 68 | +
|
| 69 | + It's worth noting that `Provider` performs compile-time purging of needless defaults. When you |
| 70 | + compile the code in `:prod`, `:dev` and `:test` defaults will not be included in the binaries. |
| 71 | + Consequently, any private function invoked only in dev/test will also not be invoked, and you'll |
| 72 | + get a compiler warning when compiling the code in prod. To eliminate such warnings, you can |
| 73 | + conditionally define the function only in required mix env, by moving the function definition |
| 74 | + under an `if Mix.env() == ` conditional. |
| 75 | +
|
| 76 | + ## Generating template |
| 77 | +
|
| 78 | + The config module will contain the `template/0` function which generates the configuration |
| 79 | + template. To print the template to stdout, you can invoke: |
| 80 | +
|
| 81 | + MIX_ENV=prod mix compile |
| 82 | + MIX_ENV=prod mix run --no-start -e 'IO.puts(MySystem.Config.template())' |
| 83 | +
|
| 84 | + ## Lower level API |
| 85 | +
|
| 86 | + The basic retrieval functionality is available via functions of this module, such as |
| 87 | + `fetch_all/2`, or `fetch_one/2`. These functions are a lower level plumbing API which is less |
| 88 | + convenient to use, but more flexible. Most of the time the `use`-based interface will serve you |
| 89 | + better, but if you have more specific needs which are not covered by that, you can reach for |
| 90 | + these functions. |
| 91 | + """ |
| 92 | + |
| 93 | + use Boundary, |
| 94 | + exports: [Source, SystemEnv], |
| 95 | + deps: [Ecto.Changeset, {Mix, :compile}] |
| 96 | + |
| 97 | + alias Ecto.Changeset |
| 98 | + |
| 99 | + @type source :: module |
| 100 | + @type params :: %{param_name => param_spec} |
| 101 | + @type param_name :: atom |
| 102 | + @type param_spec :: %{type: type, default: value} |
| 103 | + @type type :: :string | :integer | :float | :boolean |
| 104 | + @type value :: String.t() | number | boolean | nil |
| 105 | + @type data :: %{param_name => value} |
| 106 | + |
| 107 | + # ------------------------------------------------------------------------ |
| 108 | + # API |
| 109 | + # ------------------------------------------------------------------------ |
| 110 | + |
| 111 | + @doc "Retrieves all params according to the given specification." |
| 112 | + @spec fetch_all(source, params) :: {:ok, data} | {:error, [String.t()]} |
| 113 | + def fetch_all(source, params) do |
| 114 | + types = Enum.into(params, %{}, fn {name, spec} -> {name, spec.type} end) |
| 115 | + |
| 116 | + data = |
| 117 | + params |
| 118 | + |> Stream.zip(source.values(Map.keys(types))) |
| 119 | + |> Enum.into(%{}, fn {{param, opts}, provided_value} -> |
| 120 | + value = if is_nil(provided_value), do: opts.default, else: provided_value |
| 121 | + {param, value} |
| 122 | + end) |
| 123 | + |
| 124 | + {%{}, types} |
| 125 | + |> Changeset.cast(data, Map.keys(types)) |
| 126 | + |> Changeset.validate_required(Map.keys(types), message: "is missing") |
| 127 | + |> case do |
| 128 | + %Changeset{valid?: true} = changeset -> {:ok, Changeset.apply_changes(changeset)} |
| 129 | + %Changeset{valid?: false} = changeset -> {:error, changeset_error(source, changeset)} |
| 130 | + end |
| 131 | + end |
| 132 | + |
| 133 | + @doc "Retrieves a single parameter." |
| 134 | + @spec fetch_one(source, param_name, param_spec) :: {:ok, value} | {:error, [String.t()]} |
| 135 | + def fetch_one(source, param_name, param_spec) do |
| 136 | + with {:ok, map} <- fetch_all(source, %{param_name => param_spec}), |
| 137 | + do: {:ok, Map.fetch!(map, param_name)} |
| 138 | + end |
| 139 | + |
| 140 | + @doc "Retrieves a single param, raising if the value is not available." |
| 141 | + @spec fetch_one!(source, param_name, param_spec) :: value |
| 142 | + def fetch_one!(source, param, param_spec) do |
| 143 | + case fetch_one(source, param, param_spec) do |
| 144 | + {:ok, value} -> value |
| 145 | + {:error, errors} -> raise Enum.join(errors, ", ") |
| 146 | + end |
| 147 | + end |
| 148 | + |
| 149 | + # ------------------------------------------------------------------------ |
| 150 | + # Private |
| 151 | + # ------------------------------------------------------------------------ |
| 152 | + |
| 153 | + defp changeset_error(source, changeset) do |
| 154 | + changeset |
| 155 | + |> Ecto.Changeset.traverse_errors(fn {msg, opts} -> |
| 156 | + Enum.reduce( |
| 157 | + opts, |
| 158 | + msg, |
| 159 | + fn {key, value}, acc -> String.replace(acc, "%{#{key}}", to_string(value)) end |
| 160 | + ) |
| 161 | + end) |
| 162 | + |> Enum.flat_map(fn {key, errors} -> |
| 163 | + Enum.map(errors, &"#{source.display_name(key)} #{&1}") |
| 164 | + end) |
| 165 | + |> Enum.sort() |
| 166 | + end |
| 167 | + |
| 168 | + @doc false |
| 169 | + defmacro __using__(spec) do |
| 170 | + spec = |
| 171 | + update_in( |
| 172 | + spec[:params], |
| 173 | + fn params -> Enum.map(params, &normalize_param_spec(&1, Mix.env())) end |
| 174 | + ) |
| 175 | + |
| 176 | + quote bind_quoted: [spec: spec] do |
| 177 | + # Generate typespec mapping for each param |
| 178 | + typespecs = |
| 179 | + Enum.map( |
| 180 | + Keyword.fetch!(spec, :params), |
| 181 | + fn {param_name, param_spec} -> |
| 182 | + type = |
| 183 | + case Keyword.fetch!(param_spec, :type) do |
| 184 | + :integer -> quote(do: integer()) |
| 185 | + :float -> quote(do: float()) |
| 186 | + :boolean -> quote(do: boolean()) |
| 187 | + :string -> quote(do: String.t()) |
| 188 | + end |
| 189 | + |
| 190 | + {param_name, type} |
| 191 | + end |
| 192 | + ) |
| 193 | + |
| 194 | + # Convert each param's spec into a quoted map. This is done so we can inject the map |
| 195 | + # with constants direcly into the function definition. In other words, this ensures that |
| 196 | + # we converted the input keyword list into a map at compile time, not runtime. |
| 197 | + quoted_params = |
| 198 | + spec |
| 199 | + |> Keyword.fetch!(:params) |
| 200 | + |> Enum.map(fn {name, spec} -> {name, quote(do: %{unquote_splicing(spec)})} end) |
| 201 | + |
| 202 | + @doc "Retrieves all parameters." |
| 203 | + @spec fetch_all :: {:ok, %{unquote_splicing(typespecs)}} | {:error, [String.t()]} |
| 204 | + def fetch_all do |
| 205 | + Provider.fetch_all( |
| 206 | + unquote(Keyword.fetch!(spec, :source)), |
| 207 | + |
| 208 | + # quoted_params is itself a keyword list, so we need to convert it into a map |
| 209 | + %{unquote_splicing(quoted_params)} |
| 210 | + ) |
| 211 | + end |
| 212 | + |
| 213 | + @doc "Validates all parameters, raising if some values are missing or invalid." |
| 214 | + @spec validate!() :: :ok |
| 215 | + def validate!() do |
| 216 | + with {:error, errors} <- fetch_all() do |
| 217 | + raise "Following OS env var errors were found:\n#{Enum.join(Enum.sort(errors), "\n")}" |
| 218 | + end |
| 219 | + |
| 220 | + :ok |
| 221 | + end |
| 222 | + |
| 223 | + # Generate getter for each param. |
| 224 | + Enum.each( |
| 225 | + quoted_params, |
| 226 | + fn {param_name, param_spec} -> |
| 227 | + @spec unquote(param_name)() :: unquote(Keyword.fetch!(typespecs, param_name)) |
| 228 | + @doc "Returns the value of the `#{param_name}` param, raising on error." |
| 229 | + # bug in credo spec check |
| 230 | + # credo:disable-for-next-line Credo.Check.Readability.Specs |
| 231 | + def unquote(param_name)() do |
| 232 | + Provider.fetch_one!( |
| 233 | + unquote(Keyword.fetch!(spec, :source)), |
| 234 | + unquote(param_name), |
| 235 | + unquote(param_spec) |
| 236 | + ) |
| 237 | + end |
| 238 | + end |
| 239 | + ) |
| 240 | + |
| 241 | + @doc "Returns a template configuration file." |
| 242 | + @spec template :: String.t() |
| 243 | + def template do |
| 244 | + unquote(Keyword.fetch!(spec, :source)).template(%{unquote_splicing(quoted_params)}) |
| 245 | + end |
| 246 | + end |
| 247 | + end |
| 248 | + |
| 249 | + defp normalize_param_spec(param_name, mix_env) when is_atom(param_name), |
| 250 | + do: normalize_param_spec({param_name, []}, mix_env) |
| 251 | + |
| 252 | + defp normalize_param_spec({param_name, param_spec}, mix_env) do |
| 253 | + default_keys = |
| 254 | + case mix_env do |
| 255 | + :test -> [:test, :dev, :default] |
| 256 | + :dev -> [:dev, :default] |
| 257 | + :prod -> [:default] |
| 258 | + end |
| 259 | + |
| 260 | + default_value = |
| 261 | + default_keys |
| 262 | + |> Stream.map(&Keyword.get(param_spec, &1)) |
| 263 | + |> Enum.find(&(not is_nil(&1))) |
| 264 | + |
| 265 | + # We need to escape to make sure that default of e.g. `foo()` is correctly passed to |
| 266 | + # `__using__` quote block and properly resolved as a runtime function call. |
| 267 | + # |
| 268 | + # The `unquote: true` option ensures that default of `unquote(foo)` is resolved in the |
| 269 | + # context of the client module. |
| 270 | + |> Macro.escape(unquote: true) |
| 271 | + |
| 272 | + {param_name, [type: Keyword.get(param_spec, :type, :string), default: default_value]} |
| 273 | + end |
| 274 | + |
| 275 | + defmodule Source do |
| 276 | + @moduledoc "Contract for storage sources." |
| 277 | + alias Provider |
| 278 | + |
| 279 | + @doc """ |
| 280 | + Invoked to provide the values for the given parameters. |
| 281 | +
|
| 282 | + This function should return all values in the requested orders. For each param which is not |
| 283 | + available, `nil` should be returned. |
| 284 | + """ |
| 285 | + @callback values([Provider.param_name()]) :: [Provider.value()] |
| 286 | + |
| 287 | + @doc "Invoked to convert the param name to storage specific name." |
| 288 | + @callback display_name(Provider.param_name()) :: String.t() |
| 289 | + |
| 290 | + @doc "Invoked to create operator template." |
| 291 | + @callback template(Provider.params()) :: String.t() |
| 292 | + end |
| 293 | +end |
0 commit comments