Skip to content

Commit 41e9d96

Browse files
committed
Move provider into elixir common
1 parent 5683c86 commit 41e9d96

File tree

4 files changed

+332
-10
lines changed

4 files changed

+332
-10
lines changed

lib/provider.ex

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
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

lib/provider/system_env.ex

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
defmodule Provider.SystemEnv do
2+
# credo:disable-for-this-file Credo.Check.Readability.Specs
3+
@moduledoc "Provider source which retrieves values from OS env vars."
4+
5+
@behaviour Provider.Source
6+
alias Provider.Source
7+
8+
@impl Source
9+
def display_name(param_name), do: param_name |> Atom.to_string() |> String.upcase()
10+
11+
@impl Source
12+
def values(param_names), do: Enum.map(param_names, &System.get_env(display_name(&1)))
13+
14+
@impl Source
15+
def template(params) do
16+
params
17+
|> Enum.sort()
18+
|> Enum.map(&param_entry/1)
19+
|> Enum.join("\n")
20+
end
21+
22+
defp param_entry({name, %{default: nil} = spec}) do
23+
"""
24+
# #{spec.type}
25+
#{display_name(name)}=
26+
"""
27+
end
28+
29+
defp param_entry({name, spec}) do
30+
"""
31+
# #{spec.type}
32+
# #{display_name(name)}="#{String.replace(to_string(spec.default), "\n", "\\n")}"
33+
"""
34+
end
35+
end

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ defmodule VBT.MixProject do
3434
{:bamboo, "~> 2.4"},
3535
{:bamboo_phoenix, "~> 1.0.0"},
3636
{:bcrypt_elixir, "~> 3.2"},
37+
{:boundary, "~> 0.8", runtime: false},
3738
{:credo, "~> 1.7", runtime: false},
3839
{:dialyxir, "~> 1.4", runtime: false},
3940
{:ecto_enum, "~> 1.4"},
@@ -48,7 +49,6 @@ defmodule VBT.MixProject do
4849
{:phoenix_live_view, "~> 0.14", optional: true},
4950
{:phoenix, "~> 1.5.12"},
5051
{:plug_cowboy, "~> 2.7"},
51-
{:provider, "~> 0.2.1"},
5252
{:sentry, "~> 8.0"},
5353
{:stream_data, "~> 1.1.3", only: [:test, :dev]}
5454
]

0 commit comments

Comments
 (0)