├── test ├── test_helper.exs └── provider_test.exs ├── .tool-versions ├── .formatter.exs ├── .credo.exs ├── .gitignore ├── mix.exs ├── lib ├── provider │ └── system_env.ex └── provider.ex ├── LICENSE ├── .github └── workflows │ └── test.yaml ├── mix.lock └── README.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 22.2 2 | elixir 1.10.2-otp-22 3 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | %{ 2 | configs: [ 3 | %{ 4 | name: "default", 5 | files: %{ 6 | included: ["lib/", "src/", "test/", "web/", "apps/"], 7 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 8 | }, 9 | requires: [], 10 | strict: true, 11 | color: true, 12 | checks: [ 13 | # extra enabled checks 14 | {Credo.Check.Readability.AliasAs, []}, 15 | {Credo.Check.Readability.SinglePipe, []}, 16 | {Credo.Check.Readability.Specs, []}, 17 | 18 | # disabled checks 19 | {Credo.Check.Design.TagTODO, false} 20 | ] 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | provider-*.tar 24 | 25 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Provider.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :provider, 7 | version: "0.1.0", 8 | elixir: "~> 1.10", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | aliases: aliases(), 12 | compilers: extra_compilers() ++ Mix.compilers(), 13 | boundary: [externals_mode: :strict] 14 | ] 15 | end 16 | 17 | def application do 18 | [ 19 | extra_applications: [:logger] 20 | ] 21 | end 22 | 23 | defp deps do 24 | [ 25 | {:boundary, "~> 0.8", runtime: false}, 26 | {:credo, "~> 1.5", only: [:dev, :test]}, 27 | {:ecto, "~> 3.7"}, 28 | {:ex_doc, "~> 0.25.1", only: :dev}, 29 | {:dialyxir, "~> 1.1", only: [:dev, :test]} 30 | ] 31 | end 32 | 33 | defp aliases do 34 | [ 35 | credo: ~w/compile credo/ 36 | ] 37 | end 38 | 39 | defp extra_compilers(), do: if(Mix.env() == :prod, do: [], else: [:boundary]) 40 | end 41 | -------------------------------------------------------------------------------- /lib/provider/system_env.ex: -------------------------------------------------------------------------------- 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(¶m_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 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020, [Very Big Things](https://verybigthings.com/) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | on: push 2 | 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | 7 | env: 8 | CACHE_VERSION: v1 9 | steps: 10 | - uses: actions/checkout@v2 11 | 12 | - uses: erlef/setup-beam@v1 13 | with: 14 | otp-version: 22.2 15 | elixir-version: 1.10.2 16 | 17 | - name: Restore cached deps 18 | uses: actions/cache@v1 19 | with: 20 | path: deps 21 | key: deps-${{ env.CACHE_VERSION }}-${{ github.ref }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 22 | restore-keys: | 23 | deps-${{ env.CACHE_VERSION }}-${{ github.ref }}- 24 | deps-${{ env.CACHE_VERSION }}- 25 | 26 | - name: Restore cached build 27 | uses: actions/cache@v1 28 | with: 29 | path: _build 30 | key: build-${{ env.CACHE_VERSION }}-${{ github.ref }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 31 | restore-keys: | 32 | build-${{ env.CACHE_VERSION }}-${{ github.ref }}- 33 | build-${{ env.CACHE_VERSION }}- 34 | 35 | - name: Fetch deps 36 | run: mix deps.get 37 | 38 | - name: Compile project 39 | run: | 40 | MIX_ENV=test mix compile --warnings-as-errors 41 | MIX_ENV=dev mix compile --warnings-as-errors 42 | MIX_ENV=prod mix compile --warnings-as-errors 43 | 44 | - name: Run linter checks 45 | run: mix credo list 46 | 47 | - name: Check code format 48 | run: mix format --check-formatted 49 | 50 | - name: Run dialyzer 51 | run: mix dialyzer 52 | 53 | - name: Run tests 54 | run: mix test 55 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "boundary": {:hex, :boundary, "0.8.0", "74fa1cc077e92e2e62c7ceb0116dcd6872cf0e6ab5094325a6460f60b3da2e6a", [:mix], [], "hexpm", "cafbd95ad4b4acdf9e5f42d9590cf49993062cfb5dd4bce4a28791974f362d16"}, 3 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 4 | "credo": {:hex, :credo, "1.5.6", "e04cc0fdc236fefbb578e0c04bd01a471081616e741d386909e527ac146016c6", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4b52a3e558bd64e30de62a648518a5ea2b6e3e5d2b164ef5296244753fc7eb17"}, 5 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 6 | "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, 7 | "earmark_parser": {:hex, :earmark_parser, "1.4.15", "b29e8e729f4aa4a00436580dcc2c9c5c51890613457c193cc8525c388ccb2f06", [:mix], [], "hexpm", "044523d6438ea19c1b8ec877ec221b008661d3c27e3b848f4c879f500421ca5c"}, 8 | "ecto": {:hex, :ecto, "3.7.1", "a20598862351b29f80f285b21ec5297da1181c0442687f9b8329f0445d228892", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d36e5b39fc479e654cffd4dbe1865d9716e4a9b6311faff799b6f90ab81b8638"}, 9 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 10 | "ex_doc": {:hex, :ex_doc, "0.25.1", "4b736fa38dc76488a937e5ef2944f5474f3eff921de771b25371345a8dc810bc", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "3200b0a69ddb2028365281fbef3753ea9e728683863d8cdaa96580925c891f67"}, 11 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 12 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 13 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 14 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, 15 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 16 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 17 | "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, 18 | } 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Provider 2 | 3 | Provider is a library which helps managing the operator configuration of a system. 4 | 5 | The term operator configuration refers to the settings which have to be provided by the operator of the system before the system is started. Typical examples include database credentials (host, database name, login, password), http and https ports, 3rd party API keys, and such. On the other hand, things such as Ecto database adapter, or dev/test-only variations such as mocks, are not a part of operator configuration. 6 | 7 | Provider offers the following features: 8 | 9 | - Consolidation of operator settings into a single place 10 | - Basic strong typing (automatic conversion of external settings to string, integer, or boolean) 11 | - Compile time guarantees 12 | - Runtime validation during the system startup 13 | - Automatic creation of operator templates, such as .env file which lists all configuration parameters 14 | - In-code provisioning of dev/test defaults, which removes the need to use external files such as .envrc, or manage duplicate settings in CI yamls 15 | 16 | Provider was born out of real need and is based on lessons learned and issues experienced with config scripts and app config. Provider doesn't use app config, and is instead only focused on fetching values from external sources, such as OS env. The responsibility of working with those values is left to the client. 17 | 18 | Provider is currently used in a few small projects, but it's not exhaustively tested nor optimized. The API is not considered as stable, and it may change significantly. Provider currently only supports the features needed by the projects it powers. Therefore, it has a couple of limitations: 19 | 20 | - Provider can't be used to provide compile-time settings or to configure applications which require the settings before the main app is started (such as Kernel or Logger). In these cases you'll still need to use config.exs & friends. 21 | - Only OS env is currently supported as an external source. 22 | - No support for `nil` or empty strings. Each configured value has to be provided, and it can't be an empty string. 23 | 24 | Tackling these shortcomings is on the roadmap, but it hasn't happen yet, because there was no actual need to address them so far. 25 | 26 | ## Quick start 27 | 28 | Add provider as a dependency: 29 | 30 | ```elixir 31 | defmodule MySystem.MixProject do 32 | # ... 33 | 34 | defp deps do 35 | [ 36 | {:provider, github: "verybigthings/provider"}, 37 | # ... 38 | ] 39 | end 40 | end 41 | ``` 42 | 43 | Create a module where you'll consolidate your configuration: 44 | 45 | ```elixir 46 | defmodule MySystem.Config do 47 | use Provider, 48 | source: Provider.SystemEnv, 49 | params: [ 50 | # The `:dev` option sets a dev/test default. This default won't be used in `:prod` mix env. 51 | {:db_host, dev: "localhost"}, 52 | 53 | # The `:test` option overrides the default in the test mix env. 54 | {:db_name, dev: "my_db_dev", test: "my_db_test"}, 55 | 56 | # The `:default` option sets a default in all mix envs, which means that this setting is 57 | # optional. 58 | # 59 | # Owing to `type: :integer`, the external input will be converted into an integer. An error 60 | # will be raised if this conversion fails. 61 | {:db_pool_size, type: :integer, default: 10}, 62 | 63 | # ... 64 | ] 65 | end 66 | ``` 67 | 68 | Validate configuration during app startup: 69 | 70 | ```elixir 71 | defmodule MySystem.Application do 72 | use Application 73 | 74 | def start(_type, _args) do 75 | MySystem.Config.validate!() 76 | 77 | # ... 78 | end 79 | 80 | # ... 81 | end 82 | 83 | ``` 84 | 85 | Use config module functions to fetch the values: 86 | 87 | ```elixir 88 | defmodule MySystem.Repo do 89 | use Ecto.Repo, 90 | otp_app: :my_system, 91 | adapter: Ecto.Adapters.Postgres 92 | 93 | @impl Ecto.Repo 94 | def init(_type, config) do 95 | {:ok, 96 | Keyword.merge( 97 | config, 98 | hostname: MySystem.Config.db_host(), 99 | database_name: MySystem.Config.db_name(), 100 | pool_size: MySystem.Config.db_pool_size(), 101 | # ... 102 | ) 103 | } 104 | end 105 | end 106 | ``` 107 | 108 | Invoke `MySystem.Config.template()` in prod Mix env to generate a .env template with all configuration parameters. The function will return a string which you can print to screen or save to a file. 109 | 110 | ## Documentation 111 | 112 | Since this library is not currently published on hex, you need to build the documentation locally, or read the [moduledoc in source](https://github.com/VeryBigThings/provider/blob/master/lib/provider.ex#L2). 113 | 114 | ## Alternatives 115 | 116 | There are a couple of other libraries available with similar features: 117 | 118 | - [Skogsrå](https://github.com/gmtprime/skogsra) 119 | - [Specify](https://github.com/Qqwy/elixir-specify) 120 | - [Vapor](https://github.com/keathley/vapor) 121 | 122 | ## License 123 | 124 | [MIT](LICENSE) 125 | -------------------------------------------------------------------------------- /test/provider_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProviderTest do 2 | use ExUnit.Case, async: true 3 | alias Provider 4 | alias ProviderTest.TestModule 5 | 6 | describe "fetch_one" do 7 | test "returns correct value" do 8 | param = param_spec() 9 | System.put_env(param.os_env_name, "some value") 10 | assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == {:ok, "some value"} 11 | end 12 | 13 | test "returns default value if OS env is not set" do 14 | param = param_spec(default: "default value") 15 | 16 | assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == 17 | {:ok, "default value"} 18 | end 19 | 20 | test "ignores default value and returns OS env value if it's available" do 21 | param = param_spec(default: "default value") 22 | System.put_env(param.os_env_name, "os env value") 23 | 24 | assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == 25 | {:ok, "os env value"} 26 | end 27 | 28 | test "converts to integer" do 29 | param = param_spec(type: :integer, default: 123) 30 | 31 | assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == {:ok, 123} 32 | 33 | System.put_env(param.os_env_name, "456") 34 | assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == {:ok, 456} 35 | end 36 | 37 | test "converts to float" do 38 | param = param_spec(type: :float, default: 3.14) 39 | 40 | assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == {:ok, 3.14} 41 | 42 | System.put_env(param.os_env_name, "2.72") 43 | assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == {:ok, 2.72} 44 | end 45 | 46 | test "converts to boolean" do 47 | param = param_spec(type: :boolean, default: true) 48 | 49 | assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == {:ok, true} 50 | 51 | System.put_env(param.os_env_name, "false") 52 | assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == {:ok, false} 53 | end 54 | 55 | test "reports error on missing value" do 56 | param = param_spec() 57 | 58 | assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == 59 | {:error, [error(param, "is missing")]} 60 | end 61 | 62 | test "empty string is treated as a missing value" do 63 | param = param_spec() 64 | System.put_env(param.os_env_name, "") 65 | 66 | assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == 67 | {:error, [error(param, "is missing")]} 68 | end 69 | 70 | for type <- ~w/integer float boolean/a do 71 | test "reports error on #{type} conversion" do 72 | param = param_spec(type: unquote(type), default: 123) 73 | System.put_env(param.os_env_name, "invalid value") 74 | 75 | assert Provider.fetch_one(Provider.SystemEnv, param.name, param.opts) == 76 | {:error, [error(param, "is invalid")]} 77 | end 78 | end 79 | end 80 | 81 | describe "fetch_one!" do 82 | test "returns correct value" do 83 | param = param_spec() 84 | System.put_env(param.os_env_name, "some value") 85 | assert Provider.fetch_one!(Provider.SystemEnv, param.name, param.opts) == "some value" 86 | end 87 | 88 | test "returns default value if OS env is not set" do 89 | param = param_spec() 90 | 91 | assert_raise( 92 | RuntimeError, 93 | "#{param.os_env_name} is missing", 94 | fn -> Provider.fetch_one!(Provider.SystemEnv, param.name, param.opts) end 95 | ) 96 | end 97 | end 98 | 99 | describe "fetch_all" do 100 | test "returns correct values" do 101 | param1 = param_spec() 102 | param2 = param_spec(type: :integer) 103 | param3 = param_spec(type: :float, default: 3.14) 104 | 105 | System.put_env(param1.os_env_name, "some value") 106 | System.put_env(param2.os_env_name, "42") 107 | 108 | params = Enum.into([param1, param2, param3], %{}, &{&1.name, &1.opts}) 109 | 110 | assert Provider.fetch_all(Provider.SystemEnv, params) == 111 | {:ok, %{param1.name => "some value", param2.name => 42, param3.name => 3.14}} 112 | end 113 | 114 | test "reports errors" do 115 | param1 = param_spec() 116 | param2 = param_spec(type: :integer, default: 42) 117 | param3 = param_spec(type: :float) 118 | 119 | System.put_env(param3.os_env_name, "invalid value") 120 | 121 | params = Enum.into([param1, param2, param3], %{}, &{&1.name, &1.opts}) 122 | 123 | assert Provider.fetch_all(Provider.SystemEnv, params) == 124 | {:error, Enum.sort([error(param1, "is missing"), error(param3, "is invalid")])} 125 | end 126 | end 127 | 128 | describe "generated module" do 129 | setup do 130 | Enum.each(1..7, &System.delete_env("OPT_#{&1}")) 131 | end 132 | 133 | test "fetch_all/0 succeeds for correct data" do 134 | System.put_env("OPT_1", "qux") 135 | System.put_env("OPT_2", "42") 136 | System.put_env("OPT_6", "false") 137 | System.put_env("OPT_7", "3.14") 138 | 139 | assert TestModule.fetch_all() == 140 | {:ok, 141 | %{ 142 | opt_1: "qux", 143 | opt_2: 42, 144 | opt_3: "foo", 145 | opt_4: "bar", 146 | opt_5: "baz", 147 | opt_6: false, 148 | opt_7: 3.14 149 | }} 150 | end 151 | 152 | test "fetch_all/0 returns errors for invalid data" do 153 | assert TestModule.fetch_all() == 154 | { 155 | :error, 156 | ["OPT_1 is missing", "OPT_2 is missing", "OPT_6 is missing", "OPT_7 is missing"] 157 | } 158 | end 159 | 160 | test "validate!/0 succeeds for correct data" do 161 | System.put_env("OPT_1", "some data") 162 | System.put_env("OPT_2", "42") 163 | System.put_env("OPT_6", "false") 164 | System.put_env("OPT_7", "3.14") 165 | 166 | assert TestModule.validate!() == :ok 167 | end 168 | 169 | test "validate!/0 raises on error" do 170 | System.put_env("OPT_2", "foobar") 171 | error = assert_raise RuntimeError, fn -> TestModule.validate!() end 172 | assert error.message =~ "OPT_1 is missing" 173 | assert error.message =~ "OPT_2 is invalid" 174 | assert error.message =~ "OPT_6 is missing" 175 | assert error.message =~ "OPT_7 is missing" 176 | end 177 | 178 | test "access function succeed for correct data" do 179 | System.put_env("OPT_1", "some data") 180 | System.put_env("OPT_2", "42") 181 | System.put_env("OPT_6", "false") 182 | System.put_env("OPT_7", "3.14") 183 | 184 | assert TestModule.opt_1() == "some data" 185 | assert TestModule.opt_2() == 42 186 | assert TestModule.opt_3() == "foo" 187 | assert TestModule.opt_4() == "bar" 188 | assert TestModule.opt_5() == "baz" 189 | assert TestModule.opt_6() == false 190 | assert TestModule.opt_7() == 3.14 191 | end 192 | 193 | test "access function raises for on error" do 194 | assert_raise RuntimeError, "OPT_1 is missing", fn -> TestModule.opt_1() end 195 | end 196 | 197 | test "template/0 generates config template" do 198 | assert TestModule.template() == 199 | """ 200 | # string 201 | OPT_1= 202 | 203 | # integer 204 | OPT_2= 205 | 206 | # string 207 | # OPT_3="foo" 208 | 209 | # string 210 | # OPT_4="bar" 211 | 212 | # string 213 | # OPT_5="baz" 214 | 215 | # boolean 216 | OPT_6= 217 | 218 | # float 219 | OPT_7= 220 | """ 221 | end 222 | end 223 | 224 | defp param_spec(overrides \\ []) do 225 | name = :"test_env_#{System.unique_integer([:positive, :monotonic])}" 226 | opts = Map.merge(%{type: :string, default: nil}, Map.new(overrides)) 227 | os_env_name = name |> to_string() |> String.upcase() 228 | %{name: name, opts: opts, os_env_name: os_env_name} 229 | end 230 | 231 | defp error(param, message), do: "#{param.os_env_name} #{message}" 232 | 233 | defmodule TestModule do 234 | baz = "baz" 235 | 236 | use Provider, 237 | source: Provider.SystemEnv, 238 | params: [ 239 | :opt_1, 240 | {:opt_2, type: :integer}, 241 | {:opt_3, default: "foo"}, 242 | 243 | # runtime resolving of the default value 244 | {:opt_4, default: bar()}, 245 | 246 | # compile-time resolving of the default value 247 | {:opt_5, default: unquote(baz)}, 248 | {:opt_6, type: :boolean}, 249 | {:opt_7, type: :float} 250 | ] 251 | 252 | defp bar, do: "bar" 253 | end 254 | end 255 | -------------------------------------------------------------------------------- /lib/provider.ex: -------------------------------------------------------------------------------- 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 294 | --------------------------------------------------------------------------------