From 0dda0c263b78e7fdefab5d7245937f7f5193ec1d Mon Sep 17 00:00:00 2001 From: Justin Wood Date: Wed, 21 Feb 2024 11:51:28 -0500 Subject: [PATCH 1/2] Create a simple cache This will prevent the need from retrieving the value for each and every call. This may not be overly important for env vars that are currently implemented. However, this could become a big cost saver for when you are retrieving your values from an external service. We also renamed the MyConfig.validate!/0 function to MyConfig.load!/0. You are now required to call `MyConfig.load!/0` before you try using your configuration as that is what will actually grab the values and store them into the cache. --- README.md | 2 +- lib/cache.ex | 22 ++++++ lib/cache/ets.ex | 40 ++++++++++ lib/provider.ex | 67 +++++++--------- lib/provider/application.ex | 15 ++++ mix.exs | 1 + test/cache/ets_test.exs | 33 ++++++++ test/provider_test.exs | 148 +++++++----------------------------- 8 files changed, 166 insertions(+), 162 deletions(-) create mode 100644 lib/cache.ex create mode 100644 lib/cache/ets.ex create mode 100644 lib/provider/application.ex create mode 100644 test/cache/ets_test.exs 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..9877cb8 --- /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..a7f4d8b 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 @@ -130,22 +129,6 @@ defmodule Provider do 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 - - @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, ", ") - end - end - # ------------------------------------------------------------------------ # Private # ------------------------------------------------------------------------ @@ -199,22 +182,20 @@ 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 + case Provider.fetch_all( + unquote(Keyword.fetch!(spec, :source)), + %{ + unquote_splicing(quoted_params) + } + ) do + {:ok, values} -> + Enum.each(values, fn {k, v} -> Provider.Cache.set(__MODULE__, k, v) end) + + {:error, errors} -> + raise "Following OS env var errors were found:\n#{Enum.join(Enum.sort(errors), "\n")}" end :ok @@ -229,11 +210,19 @@ 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} -> + raise "#{unquote(Keyword.fetch!(spec, :source)).display_name(unquote(param_name))} is missing" + end + + # Provider.fetch_one!( + # unquote(Keyword.fetch!(spec, :source)), + # unquote(param_name), + # unquote(param_spec) + # ) end end ) 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/mix.exs b/mix.exs index 7cdb46e..3d93ac2 100644 --- a/mix.exs +++ b/mix.exs @@ -21,6 +21,7 @@ defmodule Provider.MixProject do def application do [ + mod: {Provider.Application, []}, extra_applications: [:logger] ] end 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_test.exs b/test/provider_test.exs index d5656be..f00c2e7 100644 --- a/test/provider_test.exs +++ b/test/provider_test.exs @@ -1,99 +1,11 @@ defmodule ProviderTest do use ExUnit.Case, async: true alias Provider + alias ProviderTest.ProcDictCache 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 + setup_all do + Application.put_env(:provider, :cache, ProcDictCache) end describe "fetch_all" do @@ -130,45 +42,18 @@ defmodule ProviderTest 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 + 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.validate!() == :ok + assert TestModule.load!() == :ok end - test "validate!/0 raises on error" do + test "load!/0 raises on error" do System.put_env("OPT_2", "foobar") - error = assert_raise RuntimeError, fn -> TestModule.validate!() end + 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" @@ -181,6 +66,8 @@ defmodule ProviderTest do 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" @@ -251,4 +138,21 @@ defmodule ProviderTest do defp bar, do: "bar" end + + defmodule 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 end From a94f07a3bc159790e6e95f431ee02d0b77fa37cd Mon Sep 17 00:00:00 2001 From: Justin Wood Date: Mon, 26 Feb 2024 11:44:42 -0500 Subject: [PATCH 2/2] Create JsonEndpoint Provider This is meant to enable the user to retrieve values from a JSON endpoint. This also includes a way to make value names to how we want to reference them in our own systems. This means that if our json endpoint (or any other provider) gives us the key `foo-bar`, we can still use `foo_bar` in our own code. Please see `opt_7` in `json_endpoint_test.exs` for an example of how to do this. This should work for all providers, and not just for the JsonEndpoint. If the JsonEndpoint provider is unable to make the call to the specified endpoint, it will log a warning and pass nil values back to the caller and will still succeed assuming you have created default values for all params. This is mainly useful in development where you may not actually have another HTTP server running in order to retrieve values. --- lib/cache.ex | 2 +- lib/provider.ex | 68 +++++++++------ lib/provider/json_endpoint.ex | 52 ++++++++++++ lib/provider/system_env.ex | 9 +- mix.exs | 8 +- mix.lock | 2 + test/provider/json_endpoint_test.exs | 93 +++++++++++++++++++++ test/provider/system_env_test.exs | 98 ++++++++++++++++++++++ test/provider_test.exs | 120 +-------------------------- test/support/proc_dict_cache.ex | 16 ++++ test/test_helper.exs | 3 + 11 files changed, 323 insertions(+), 148 deletions(-) create mode 100644 lib/provider/json_endpoint.ex create mode 100644 test/provider/json_endpoint_test.exs create mode 100644 test/provider/system_env_test.exs create mode 100644 test/support/proc_dict_cache.ex diff --git a/lib/cache.ex b/lib/cache.ex index 9877cb8..b326974 100644 --- a/lib/cache.ex +++ b/lib/cache.ex @@ -17,6 +17,6 @@ defmodule Provider.Cache do end defp impl do - Application.get_env(:provider, :cache) || Provider.Cache.ETS + Application.get_env(:provider, :cache, Provider.Cache.ETS) end end diff --git a/lib/provider.ex b/lib/provider.ex index a7f4d8b..9dd6cbe 100644 --- a/lib/provider.ex +++ b/lib/provider.ex @@ -98,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} @@ -108,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} @@ -124,8 +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)} + %Changeset{valid?: true} = changeset -> + {:ok, Changeset.apply_changes(changeset)} + + %Changeset{valid?: false} = changeset -> + {:error, changeset_error(source, params, changeset)} end end @@ -133,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( @@ -143,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 @@ -153,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( @@ -185,17 +199,21 @@ defmodule Provider do @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( - unquote(Keyword.fetch!(spec, :source)), + 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 "Following OS env var errors were found:\n#{Enum.join(Enum.sort(errors), "\n")}" + raise "#{source} encountered errors loading values:\n#{Enum.join(Enum.sort(errors), "\n")}" end :ok @@ -215,14 +233,10 @@ defmodule Provider do value {:error, :not_found} -> - raise "#{unquote(Keyword.fetch!(spec, :source)).display_name(unquote(param_name))} is missing" - end + source = unquote(Keyword.fetch!(spec, :source)) - # Provider.fetch_one!( - # unquote(Keyword.fetch!(spec, :source)), - # unquote(param_name), - # unquote(param_spec) - # ) + raise "#{source.display_name(unquote(param_name), unquote(param_spec))} is missing" + end end end ) @@ -230,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 @@ -258,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 @@ -271,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/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 3d93ac2..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(), @@ -26,13 +27,18 @@ defmodule Provider.MixProject do ] 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/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 f00c2e7..9d037f4 100644 --- a/test/provider_test.exs +++ b/test/provider_test.exs @@ -1,12 +1,6 @@ defmodule ProviderTest do use ExUnit.Case, async: true alias Provider - alias ProviderTest.ProcDictCache - alias ProviderTest.TestModule - - setup_all do - Application.put_env(:provider, :cache, ProcDictCache) - end describe "fetch_all" do test "returns correct values" do @@ -19,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 @@ -32,82 +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 "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 - defp param_spec(overrides \\ []) do name = :"test_env_#{System.unique_integer([:positive, :monotonic])}" opts = Map.merge(%{type: :string, default: nil}, Map.new(overrides)) @@ -116,43 +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 - - defmodule 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 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()