diff --git a/README.md b/README.md index e70e184..3d9f075 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ defmodule MySystem.Application do use Application def start(_type, _args) do - MySystem.Config.validate!() + MySystem.Config.load!() # ... end diff --git a/lib/cache.ex b/lib/cache.ex new file mode 100644 index 0000000..b326974 --- /dev/null +++ b/lib/cache.ex @@ -0,0 +1,22 @@ +defmodule Provider.Cache do + @moduledoc """ + Defines a behaviour for cache implementations to follow + """ + + @callback set(mod :: module(), key :: atom(), value :: term()) :: :ok + @callback get(mod :: module(), key :: atom()) :: {:ok, term()} | {:error, :not_found} + + @spec set(module(), atom(), term()) :: :ok + def set(mod, key, value) do + impl().set(mod, key, value) + end + + @spec get(module(), atom()) :: {:ok, term()} | {:error, :not_found} + def get(mod, key) do + impl().get(mod, key) + end + + defp impl do + Application.get_env(:provider, :cache, Provider.Cache.ETS) + end +end diff --git a/lib/cache/ets.ex b/lib/cache/ets.ex new file mode 100644 index 0000000..668dc6b --- /dev/null +++ b/lib/cache/ets.ex @@ -0,0 +1,40 @@ +defmodule Provider.Cache.ETS do + @moduledoc """ + An ets based cache implementation + """ + @behaviour Provider.Cache + + use GenServer + + @spec start_link(Keyword.t()) :: GenServer.server() + def start_link(_opts) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + @impl Provider.Cache + def set(module, key, value) do + GenServer.call(__MODULE__, {:set, module, key, value}) + end + + @impl Provider.Cache + def get(module, key) do + case :ets.lookup(__MODULE__, {module, key}) do + [{{^module, ^key}, value}] -> {:ok, value} + [] -> {:error, :not_found} + end + end + + @impl GenServer + def init(:ok) do + state = :ets.new(__MODULE__, [:named_table]) + + {:ok, state} + end + + @impl GenServer + def handle_call({:set, module, key, value}, _from, state) do + :ets.insert(state, {{module, key}, value}) + + {:reply, :ok, state} + end +end diff --git a/lib/provider.ex b/lib/provider.ex index 65510ac..9dd6cbe 100644 --- a/lib/provider.ex +++ b/lib/provider.ex @@ -21,8 +21,7 @@ defmodule Provider do This will generate the following functions in the module: - - `fetch_all` - retrieves values of all parameters - - `validate!` - validates that all parameters are correctly provided + - `load!` - validates that all parameters are correctly provided and stores them in the cache - `db_host`, `db_name`, `db_pool_size`, ... - getter of each declared parameter ## Describing params @@ -99,7 +98,7 @@ defmodule Provider do @type source :: module @type params :: %{param_name => param_spec} @type param_name :: atom - @type param_spec :: %{type: type, default: value} + @type param_spec :: %{optional(:source) => String.t(), type: type, default: value} @type type :: :string | :integer | :float | :boolean @type value :: String.t() | number | boolean | nil @type data :: %{param_name => value} @@ -109,13 +108,13 @@ defmodule Provider do # ------------------------------------------------------------------------ @doc "Retrieves all params according to the given specification." - @spec fetch_all(source, params) :: {:ok, data} | {:error, [String.t()]} - def fetch_all(source, params) do + @spec fetch_all(source, params, Keyword.t()) :: {:ok, data} | {:error, [String.t()]} + def fetch_all(source, params, opts) do types = Enum.into(params, %{}, fn {name, spec} -> {name, spec.type} end) data = params - |> Stream.zip(source.values(Map.keys(types))) + |> Stream.zip(source.values(params, opts)) |> Enum.into(%{}, fn {{param, opts}, provided_value} -> value = if is_nil(provided_value), do: opts.default, else: provided_value {param, value} @@ -125,24 +124,11 @@ defmodule Provider do |> Changeset.cast(data, Map.keys(types)) |> Changeset.validate_required(Map.keys(types), message: "is missing") |> case do - %Changeset{valid?: true} = changeset -> {:ok, Changeset.apply_changes(changeset)} - %Changeset{valid?: false} = changeset -> {:error, changeset_error(source, changeset)} - end - end - - @doc "Retrieves a single parameter." - @spec fetch_one(source, param_name, param_spec) :: {:ok, value} | {:error, [String.t()]} - def fetch_one(source, param_name, param_spec) do - with {:ok, map} <- fetch_all(source, %{param_name => param_spec}), - do: {:ok, Map.fetch!(map, param_name)} - end + %Changeset{valid?: true} = changeset -> + {:ok, Changeset.apply_changes(changeset)} - @doc "Retrieves a single param, raising if the value is not available." - @spec fetch_one!(source, param_name, param_spec) :: value - def fetch_one!(source, param, param_spec) do - case fetch_one(source, param, param_spec) do - {:ok, value} -> value - {:error, errors} -> raise Enum.join(errors, ", ") + %Changeset{valid?: false} = changeset -> + {:error, changeset_error(source, params, changeset)} end end @@ -150,7 +136,7 @@ defmodule Provider do # Private # ------------------------------------------------------------------------ - defp changeset_error(source, changeset) do + defp changeset_error(source, params, changeset) do changeset |> Ecto.Changeset.traverse_errors(fn {msg, opts} -> Enum.reduce( @@ -160,7 +146,7 @@ defmodule Provider do ) end) |> Enum.flat_map(fn {key, errors} -> - Enum.map(errors, &"#{source.display_name(key)} #{&1}") + Enum.map(errors, &"#{source.display_name(key, params[key])} #{&1}") end) |> Enum.sort() end @@ -170,10 +156,21 @@ defmodule Provider do spec = update_in( spec[:params], - fn params -> Enum.map(params, &normalize_param_spec(&1, Mix.env())) end + fn params -> + Enum.map(params, &normalize_param_spec(&1, Mix.env())) + end ) - quote bind_quoted: [spec: spec] do + {source, opts} = + case Keyword.fetch!(spec, :source) do + {source, opts} -> {source, Macro.escape(opts, unquote: true)} + source -> {source, []} + end + + spec = + update_in(spec[:source], fn _source -> source end) + + quote bind_quoted: [spec: spec, opts: opts] do # Generate typespec mapping for each param typespecs = Enum.map( @@ -199,22 +196,24 @@ defmodule Provider do |> Keyword.fetch!(:params) |> Enum.map(fn {name, spec} -> {name, quote(do: %{unquote_splicing(spec)})} end) - @doc "Retrieves all parameters." - @spec fetch_all :: {:ok, %{unquote_splicing(typespecs)}} | {:error, [String.t()]} - def fetch_all do - Provider.fetch_all( - unquote(Keyword.fetch!(spec, :source)), - - # quoted_params is itself a keyword list, so we need to convert it into a map - %{unquote_splicing(quoted_params)} - ) - end - - @doc "Validates all parameters, raising if some values are missing or invalid." - @spec validate!() :: :ok - def validate! do - with {:error, errors} <- fetch_all() do - raise "Following OS env var errors were found:\n#{Enum.join(Enum.sort(errors), "\n")}" + @doc "Loads and validates all parameters, raising if some values are missing or invalid." + @spec load!() :: :ok + def load! do + source = unquote(Keyword.fetch!(spec, :source)) + opts = unquote(opts) + + case Provider.fetch_all( + source, + %{ + unquote_splicing(quoted_params) + }, + opts + ) do + {:ok, values} -> + Enum.each(values, fn {k, v} -> Provider.Cache.set(__MODULE__, k, v) end) + + {:error, errors} -> + raise "#{source} encountered errors loading values:\n#{Enum.join(Enum.sort(errors), "\n")}" end :ok @@ -229,11 +228,15 @@ defmodule Provider do # bug in credo spec check # credo:disable-for-next-line Credo.Check.Readability.Specs def unquote(param_name)() do - Provider.fetch_one!( - unquote(Keyword.fetch!(spec, :source)), - unquote(param_name), - unquote(param_spec) - ) + case Provider.Cache.get(__MODULE__, unquote(param_name)) do + {:ok, value} -> + value + + {:error, :not_found} -> + source = unquote(Keyword.fetch!(spec, :source)) + + raise "#{source.display_name(unquote(param_name), unquote(param_spec))} is missing" + end end end ) @@ -241,7 +244,9 @@ defmodule Provider do @doc "Returns a template configuration file." @spec template :: String.t() def template do - unquote(Keyword.fetch!(spec, :source)).template(%{unquote_splicing(quoted_params)}) + source = unquote(Keyword.fetch!(spec, :source)) + + source.template(%{unquote_splicing(quoted_params)}) end end end @@ -269,7 +274,9 @@ defmodule Provider do # context of the client module. |> Macro.escape(unquote: true) - {param_name, [type: Keyword.get(param_spec, :type, :string), default: default_value]} + {param_name, + Keyword.drop(param_spec, [:type, :default]) ++ + [type: Keyword.get(param_spec, :type, :string), default: default_value]} end defmodule Source do @@ -282,10 +289,12 @@ defmodule Provider do This function should return all values in the requested orders. For each param which is not available, `nil` should be returned. """ - @callback values([Provider.param_name()]) :: [Provider.value()] + @callback values(Provider.params(), Keyword.t()) :: [ + Provider.value() + ] @doc "Invoked to convert the param name to storage specific name." - @callback display_name(Provider.param_name()) :: String.t() + @callback display_name(Provider.param_name(), Provider.param_spec()) :: String.t() @doc "Invoked to create operator template." @callback template(Provider.params()) :: String.t() diff --git a/lib/provider/application.ex b/lib/provider/application.ex new file mode 100644 index 0000000..5b1767b --- /dev/null +++ b/lib/provider/application.ex @@ -0,0 +1,15 @@ +defmodule Provider.Application do + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + Provider.Cache.ETS + ] + + opts = [strategy: :one_for_one, name: Provider.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/lib/provider/json_endpoint.ex b/lib/provider/json_endpoint.ex new file mode 100644 index 0000000..0352c4e --- /dev/null +++ b/lib/provider/json_endpoint.ex @@ -0,0 +1,52 @@ +defmodule Provider.JsonEndpoint do + @moduledoc """ + Provider source which retrieves values from a JSON endpoint. + + The following options are accepted. + + * :endpoint - This is the URL where the JSON configuration can be found. + """ + + @behaviour Provider.Source + + require Logger + alias Provider.Source + + @impl Source + def display_name(param_name, spec), do: Map.get(spec, :source, to_string(param_name)) + + @impl Source + def values(params, opts) do + endpoint = Keyword.fetch!(opts, :endpoint) + + response = + [{Tesla.Middleware.BaseUrl, endpoint}, Tesla.Middleware.JSON] + |> Tesla.client() + |> Tesla.get("") + + case response do + {:ok, response} -> + Enum.map(params, fn {k, spec} -> + response.body[display_name(k, spec)] + end) + + {:error, reason} -> + Logger.warning("#{__MODULE__} unable to retrieve values - #{reason}") + + Enum.map(params, fn {_k, _spec} -> + nil + end) + end + end + + @impl Source + def template(params) do + params + |> Enum.map(fn {k, spec} -> + {display_name(k, spec), spec.default} + end) + |> Map.new() + |> Jason.encode!() + |> Jason.Formatter.pretty_print() + end +end diff --git a/lib/provider/system_env.ex b/lib/provider/system_env.ex index cb74fba..fb736b1 100644 --- a/lib/provider/system_env.ex +++ b/lib/provider/system_env.ex @@ -6,10 +6,11 @@ defmodule Provider.SystemEnv do alias Provider.Source @impl Source - def display_name(param_name), do: param_name |> Atom.to_string() |> String.upcase() + def display_name(param_name, _spec), do: param_name |> Atom.to_string() |> String.upcase() @impl Source - def values(param_names), do: Enum.map(param_names, &System.get_env(display_name(&1))) + def values(params, _opts), + do: Enum.map(params, fn {k, spec} -> k |> display_name(spec) |> System.get_env() end) @impl Source def template(params) do @@ -21,14 +22,14 @@ defmodule Provider.SystemEnv do defp param_entry({name, %{default: nil} = spec}) do """ # #{spec.type} - #{display_name(name)}= + #{display_name(name, spec)}= """ end defp param_entry({name, spec}) do """ # #{spec.type} - # #{display_name(name)}="#{String.replace(to_string(spec.default), "\n", "\\n")}" + # #{display_name(name, spec)}="#{String.replace(to_string(spec.default), "\n", "\\n")}" """ end end diff --git a/mix.exs b/mix.exs index 7cdb46e..76dda38 100644 --- a/mix.exs +++ b/mix.exs @@ -8,6 +8,7 @@ defmodule Provider.MixProject do app: :provider, version: @version, elixir: "~> 1.10", + elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), aliases: aliases(), @@ -21,17 +22,23 @@ defmodule Provider.MixProject do def application do [ + mod: {Provider.Application, []}, extra_applications: [:logger] ] end + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + defp deps do [ {:boundary, "~> 0.8", runtime: false}, {:credo, "~> 1.5", only: [:dev, :test]}, {:ecto, "~> 3.7"}, {:ex_doc, "~> 0.25", only: :dev}, - {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false} + {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false}, + {:jason, "~> 1.4", optional: true}, + {:tesla, "~> 1.8", optional: true} ] end diff --git a/mix.lock b/mix.lock index cc4ff00..320f90a 100644 --- a/mix.lock +++ b/mix.lock @@ -13,6 +13,8 @@ "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, + "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, + "tesla": {:hex, :tesla, "1.8.0", "d511a4f5c5e42538d97eef7c40ec4f3e44effdc5068206f42ed859e09e51d1fd", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "10501f360cd926a309501287470372af1a6e1cbed0f43949203a4c13300bc79f"}, } diff --git a/test/cache/ets_test.exs b/test/cache/ets_test.exs new file mode 100644 index 0000000..e474586 --- /dev/null +++ b/test/cache/ets_test.exs @@ -0,0 +1,33 @@ +defmodule Provider.Cache.ETSTest do + use ExUnit.Case, async: false + + alias Provider.Cache.ETS + + describe "get/2" do + test "returns an error tuple if not value is found" do + assert {:error, :not_found} = ETS.get(__MODULE__, :key_not_found) + end + end + + describe "set/3" do + test "retrieves the value after it has been set" do + assert :ok == ETS.set(__MODULE__, :set_success_1, "value") + assert :ok == ETS.set(__MODULE__, :set_success_2, 42) + assert :ok == ETS.set(__MODULE__, :set_success_3, true) + assert :ok == ETS.set(__MODULE__, :set_success_4, 3.14) + + assert {:ok, "value"} = ETS.get(__MODULE__, :set_success_1) + assert {:ok, 42} = ETS.get(__MODULE__, :set_success_2) + assert {:ok, true} = ETS.get(__MODULE__, :set_success_3) + assert {:ok, 3.14} = ETS.get(__MODULE__, :set_success_4) + end + + test "overwrites a given key" do + assert :ok == ETS.set(__MODULE__, :set_success_1, "value1") + assert {:ok, "value1"} = ETS.get(__MODULE__, :set_success_1) + + assert :ok == ETS.set(__MODULE__, :set_success_1, "value2") + assert {:ok, "value2"} = ETS.get(__MODULE__, :set_success_1) + end + end +end diff --git a/test/provider/json_endpoint_test.exs b/test/provider/json_endpoint_test.exs new file mode 100644 index 0000000..3ec5ce5 --- /dev/null +++ b/test/provider/json_endpoint_test.exs @@ -0,0 +1,93 @@ +defmodule Provider.JsonEndpointTest do + use ExUnit.Case + + alias Provider.JsonEndpointTest.TestModule + + describe "generated module" do + test "load!/0 succeeds for correct data" do + Tesla.Mock.mock(fn %{method: :get} -> + %Tesla.Env{ + status: 200, + body: %{"opt_1" => "some data", "opt_2" => 42, "opt_6" => false, "opt7" => 3.14} + } + end) + + assert TestModule.load!() == :ok + end + + test "load!/0 raises on error" do + Tesla.Mock.mock(fn %{method: :get} -> + %Tesla.Env{ + status: 200, + body: %{"opt_2" => "foobar"} + } + end) + + System.put_env("OPT_2", "foobar") + error = assert_raise RuntimeError, fn -> TestModule.load!() end + + assert error.message =~ "opt_1 is missing" + assert error.message =~ "opt_2 is invalid" + assert error.message =~ "opt_6 is missing" + assert error.message =~ "opt7 is missing" + end + + test "access function succeed for correct data" do + Tesla.Mock.mock(fn %{method: :get} -> + %Tesla.Env{ + status: 200, + body: %{"opt_1" => "some data", "opt_2" => 42, "opt_6" => false, "opt7" => 3.14} + } + end) + + TestModule.load!() + + assert TestModule.opt_1() == "some data" + assert TestModule.opt_2() == 42 + assert TestModule.opt_3() == "foo" + assert TestModule.opt_4() == "bar" + assert TestModule.opt_5() == "baz" + assert TestModule.opt_6() == false + assert TestModule.opt_7() == 3.14 + end + + test "access function raises for on error" do + assert_raise RuntimeError, "opt_1 is missing", fn -> TestModule.opt_1() end + end + + test "template/0 generates config template" do + assert TestModule.template() == + ~s|{ + \"opt7\": null, + \"opt_1\": null, + \"opt_2\": null, + \"opt_3\": \"foo\", + \"opt_4\": \"bar\", + \"opt_5\": \"baz\", + \"opt_6\": null +}| + end + end + + defmodule TestModule do + baz = "baz" + + use Provider, + source: {Provider.JsonEndpoint, [endpoint: bar()]}, + params: [ + :opt_1, + {:opt_2, type: :integer}, + {:opt_3, default: "foo"}, + + # runtime resolving of the default value + {:opt_4, default: bar()}, + + # compile-time resolving of the default value + {:opt_5, default: unquote(baz)}, + {:opt_6, type: :boolean}, + {:opt_7, type: :float, source: "opt7"} + ] + + defp bar, do: "bar" + end +end diff --git a/test/provider/system_env_test.exs b/test/provider/system_env_test.exs new file mode 100644 index 0000000..80f2483 --- /dev/null +++ b/test/provider/system_env_test.exs @@ -0,0 +1,98 @@ +defmodule Provider.SystemEnvTest do + use ExUnit.Case, async: true + + alias Provider.SystemEnvTest.TestModule + + describe "generated module" do + setup do + Enum.each(1..7, &System.delete_env("OPT_#{&1}")) + end + + test "load!/0 succeeds for correct data" do + System.put_env("OPT_1", "some data") + System.put_env("OPT_2", "42") + System.put_env("OPT_6", "false") + System.put_env("OPT_7", "3.14") + + assert TestModule.load!() == :ok + end + + test "load!/0 raises on error" do + System.put_env("OPT_2", "foobar") + error = assert_raise RuntimeError, fn -> TestModule.load!() end + assert error.message =~ "OPT_1 is missing" + assert error.message =~ "OPT_2 is invalid" + assert error.message =~ "OPT_6 is missing" + assert error.message =~ "OPT_7 is missing" + end + + test "access function succeed for correct data" do + System.put_env("OPT_1", "some data") + System.put_env("OPT_2", "42") + System.put_env("OPT_6", "false") + System.put_env("OPT_7", "3.14") + + TestModule.load!() + + assert TestModule.opt_1() == "some data" + assert TestModule.opt_2() == 42 + assert TestModule.opt_3() == "foo" + assert TestModule.opt_4() == "bar" + assert TestModule.opt_5() == "baz" + assert TestModule.opt_6() == false + assert TestModule.opt_7() == 3.14 + end + + test "access function raises for on error" do + assert_raise RuntimeError, "OPT_1 is missing", fn -> TestModule.opt_1() end + end + + test "template/0 generates config template" do + assert TestModule.template() == + """ + # string + OPT_1= + + # integer + OPT_2= + + # string + # OPT_3="foo" + + # string + # OPT_4="bar" + + # string + # OPT_5="baz" + + # boolean + OPT_6= + + # float + OPT_7= + """ + end + end + + defmodule TestModule do + baz = "baz" + + use Provider, + source: Provider.SystemEnv, + params: [ + :opt_1, + {:opt_2, type: :integer}, + {:opt_3, default: "foo"}, + + # runtime resolving of the default value + {:opt_4, default: bar()}, + + # compile-time resolving of the default value + {:opt_5, default: unquote(baz)}, + {:opt_6, type: :boolean}, + {:opt_7, type: :float} + ] + + defp bar, do: "bar" + end +end diff --git a/test/provider_test.exs b/test/provider_test.exs index d5656be..9d037f4 100644 --- a/test/provider_test.exs +++ b/test/provider_test.exs @@ -1,100 +1,6 @@ defmodule ProviderTest do use ExUnit.Case, async: true alias Provider - alias ProviderTest.TestModule - - describe "fetch_one" do - test "returns correct value" do - param = param_spec() - System.put_env(param.os_env_name, "some value") - assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == {:ok, "some value"} - end - - test "returns default value if OS env is not set" do - param = param_spec(default: "default value") - - assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == - {:ok, "default value"} - end - - test "ignores default value and returns OS env value if it's available" do - param = param_spec(default: "default value") - System.put_env(param.os_env_name, "os env value") - - assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == - {:ok, "os env value"} - end - - test "converts to integer" do - param = param_spec(type: :integer, default: 123) - - assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == {:ok, 123} - - System.put_env(param.os_env_name, "456") - assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == {:ok, 456} - end - - test "converts to float" do - param = param_spec(type: :float, default: 3.14) - - assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == {:ok, 3.14} - - System.put_env(param.os_env_name, "2.72") - assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == {:ok, 2.72} - end - - test "converts to boolean" do - param = param_spec(type: :boolean, default: true) - - assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == {:ok, true} - - System.put_env(param.os_env_name, "false") - assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == {:ok, false} - end - - test "reports error on missing value" do - param = param_spec() - - assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == - {:error, [error(param, "is missing")]} - end - - test "empty string is treated as a missing value" do - param = param_spec() - System.put_env(param.os_env_name, "") - - assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == - {:error, [error(param, "is missing")]} - end - - for type <- ~w/integer float boolean/a do - test "reports error on #{type} conversion" do - param = param_spec(type: unquote(type), default: 123) - System.put_env(param.os_env_name, "invalid value") - - assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == - {:error, [error(param, "is invalid")]} - end - end - end - - describe "fetch_one!" do - test "returns correct value" do - param = param_spec() - System.put_env(param.os_env_name, "some value") - assert Provider.fetch_one!(Provider.SystemEnv, param.name, param.opts) == "some value" - end - - test "returns default value if OS env is not set" do - param = param_spec() - - assert_raise( - RuntimeError, - "#{param.os_env_name} is missing", - fn -> Provider.fetch_one!(Provider.SystemEnv, param.name, param.opts) end - ) - end - end describe "fetch_all" do test "returns correct values" do @@ -107,7 +13,7 @@ defmodule ProviderTest do params = Enum.into([param1, param2, param3], %{}, &{&1.name, &1.opts}) - assert Provider.fetch_all(Provider.SystemEnv, params) == + assert Provider.fetch_all(Provider.SystemEnv, params, []) == {:ok, %{param1.name => "some value", param2.name => 42, param3.name => 3.14}} end @@ -120,107 +26,11 @@ defmodule ProviderTest do params = Enum.into([param1, param2, param3], %{}, &{&1.name, &1.opts}) - assert Provider.fetch_all(Provider.SystemEnv, params) == + assert Provider.fetch_all(Provider.SystemEnv, params, []) == {:error, Enum.sort([error(param1, "is missing"), error(param3, "is invalid")])} end end - describe "generated module" do - setup do - Enum.each(1..7, &System.delete_env("OPT_#{&1}")) - end - - test "fetch_all/0 succeeds for correct data" do - System.put_env("OPT_1", "qux") - System.put_env("OPT_2", "42") - System.put_env("OPT_6", "false") - System.put_env("OPT_7", "3.14") - - assert TestModule.fetch_all() == - {:ok, - %{ - opt_1: "qux", - opt_2: 42, - opt_3: "foo", - opt_4: "bar", - opt_5: "baz", - opt_6: false, - opt_7: 3.14 - }} - end - - test "fetch_all/0 returns errors for invalid data" do - assert TestModule.fetch_all() == - { - :error, - ["OPT_1 is missing", "OPT_2 is missing", "OPT_6 is missing", "OPT_7 is missing"] - } - end - - test "validate!/0 succeeds for correct data" do - System.put_env("OPT_1", "some data") - System.put_env("OPT_2", "42") - System.put_env("OPT_6", "false") - System.put_env("OPT_7", "3.14") - - assert TestModule.validate!() == :ok - end - - test "validate!/0 raises on error" do - System.put_env("OPT_2", "foobar") - error = assert_raise RuntimeError, fn -> TestModule.validate!() end - assert error.message =~ "OPT_1 is missing" - assert error.message =~ "OPT_2 is invalid" - assert error.message =~ "OPT_6 is missing" - assert error.message =~ "OPT_7 is missing" - end - - test "access function succeed for correct data" do - System.put_env("OPT_1", "some data") - System.put_env("OPT_2", "42") - System.put_env("OPT_6", "false") - System.put_env("OPT_7", "3.14") - - assert TestModule.opt_1() == "some data" - assert TestModule.opt_2() == 42 - assert TestModule.opt_3() == "foo" - assert TestModule.opt_4() == "bar" - assert TestModule.opt_5() == "baz" - assert TestModule.opt_6() == false - assert TestModule.opt_7() == 3.14 - end - - test "access function raises for on error" do - assert_raise RuntimeError, "OPT_1 is missing", fn -> TestModule.opt_1() end - end - - test "template/0 generates config template" do - assert TestModule.template() == - """ - # string - OPT_1= - - # integer - OPT_2= - - # string - # OPT_3="foo" - - # string - # OPT_4="bar" - - # string - # OPT_5="baz" - - # boolean - OPT_6= - - # float - OPT_7= - """ - end - end - defp param_spec(overrides \\ []) do name = :"test_env_#{System.unique_integer([:positive, :monotonic])}" opts = Map.merge(%{type: :string, default: nil}, Map.new(overrides)) @@ -229,26 +39,4 @@ defmodule ProviderTest do end defp error(param, message), do: "#{param.os_env_name} #{message}" - - defmodule TestModule do - baz = "baz" - - use Provider, - source: Provider.SystemEnv, - params: [ - :opt_1, - {:opt_2, type: :integer}, - {:opt_3, default: "foo"}, - - # runtime resolving of the default value - {:opt_4, default: bar()}, - - # compile-time resolving of the default value - {:opt_5, default: unquote(baz)}, - {:opt_6, type: :boolean}, - {:opt_7, type: :float} - ] - - defp bar, do: "bar" - end end diff --git a/test/support/proc_dict_cache.ex b/test/support/proc_dict_cache.ex new file mode 100644 index 0000000..59cf9d1 --- /dev/null +++ b/test/support/proc_dict_cache.ex @@ -0,0 +1,16 @@ +defmodule Provider.ProcDictCache do + @behaviour Provider.Cache + + @impl true + def set(mod, key, val) do + Process.put({mod, key}, val) + end + + @impl true + def get(mod, key) do + case Process.get({mod, key}, :undefined) do + :undefined -> {:error, :not_found} + v -> {:ok, v} + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..f970a7d 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,4 @@ +Application.put_env(:provider, :cache, Provider.ProcDictCache) +Application.put_env(:tesla, :adapter, Tesla.Mock) + ExUnit.start()