From fca37813ba17fffdb589c90b64b1aa3a26631c8e Mon Sep 17 00:00:00 2001 From: sloane Date: Mon, 23 Sep 2024 20:59:32 -0400 Subject: [PATCH] initial implementation --- .formatter.exs | 4 +++ .gitignore | 26 ++++++++++++++++++++ README.md | 21 ++++++++++++++++ lib/envars.ex | 58 ++++++++++++++++++++++++++++++++++++++++++++ mix.exs | 28 +++++++++++++++++++++ test/envars_test.exs | 43 ++++++++++++++++++++++++++++++++ test/test_helper.exs | 1 + 7 files changed, 181 insertions(+) create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 README.md create mode 100644 lib/envars.ex create mode 100644 mix.exs create mode 100644 test/envars_test.exs create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dafdf81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +envars-*.tar + +# Temporary files, for example, from tests. +/tmp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..696e855 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# Envars + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `envars` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:envars, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at . + diff --git a/lib/envars.ex b/lib/envars.ex new file mode 100644 index 0000000..cacaae3 --- /dev/null +++ b/lib/envars.ex @@ -0,0 +1,58 @@ +defmodule Envars do + @moduledoc """ + `Envars` provides a convenience function to fetch and parse environment + variables all in once place. This has the benefit of also providing an error + message listing all missing required values. + + ## Example + + %{ + "PORT" => port, + "ENABLE_SSL" => enable_ssl, + "AWS_ACCESS_KEY_ID" => access_key_id, + "PHX_HOST" => phx_host + } = Envars.read!(%{ + "PORT" => [type: :integer, required: false, default: 4000], + "ENABLE_SSL" => [type: :boolean], + "AWS_ACCESS_KEY_ID" => [type: :string], + "PHX_HOST" => [type: :string] + }) + """ + + @type field_type :: :string | :integer | :boolean + @type options :: [ + {:type, field_type()} | {:required, boolean()} | {:default, term()} + ] + + @spec read!(fields :: %{(field :: String.t()) => options()}) :: %{String.t() => term()} + def read!(fields) do + {valid, invalid} = + for {field, opts} <- fields do + type = Keyword.get(opts, :type, :string) + required = Keyword.get(opts, :required, true) + default = Keyword.get(opts, :default) + + value = System.get_env(field) + + case {value, required, default} do + {nil, true, nil} -> {:error, {field, :undefined}} + {nil, _, default} -> {:ok, {field, default}} + {value, _, _} -> {:ok, {field, parse(type, value)}} + end + end + |> Enum.split_with(&(elem(&1, 0) == :ok)) + + if Enum.empty?(invalid) do + for {:ok, {field, value}} <- valid, into: %{}, do: {field, value} + else + missing_fields = for {:error, {field, _}} <- invalid, do: field + raise "Missing environment variables:\n\n - #{Enum.join(missing_fields, "\n - ")}" + end + end + + defp parse(:string, value), do: value + defp parse(:integer, value), do: String.to_integer(value) + defp parse(:boolean, "0"), do: false + defp parse(:boolean, "1"), do: true + defp parse(:boolean, value), do: String.downcase(value) == "true" +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..1f39fe6 --- /dev/null +++ b/mix.exs @@ -0,0 +1,28 @@ +defmodule Envars.MixProject do + use Mix.Project + + def project do + [ + app: :envars, + version: "0.1.0", + elixir: "~> 1.17", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + # {:dep_from_hexpm, "~> 0.3.0"}, + # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + ] + end +end diff --git a/test/envars_test.exs b/test/envars_test.exs new file mode 100644 index 0000000..eabd0f0 --- /dev/null +++ b/test/envars_test.exs @@ -0,0 +1,43 @@ +defmodule EnvarsTest do + use ExUnit.Case + + describe "Envars.read!/1" do + setup do + System.put_env("STRING_VAR", "FOOBAR") + System.put_env("INTEGER_VAR", "123") + System.put_env("BOOLEAN_VAR_1", "1") + System.put_env("BOOLEAN_VAR_2", "0") + System.put_env("BOOLEAN_VAR_3", "True") + System.put_env("BOOLEAN_VAR_4", "FALSE") + end + + test "parses environment variables and uses defaults" do + assert %{ + "STRING_VAR" => "FOOBAR", + "INTEGER_VAR" => 123, + "UNDEFINED_VAR" => nil, + "BOOLEAN_VAR_1" => true, + "BOOLEAN_VAR_2" => false, + "BOOLEAN_VAR_3" => true, + "BOOLEAN_VAR_4" => false, + "DEFAULTED_VAR" => "" + } == + Envars.read!(%{ + "STRING_VAR" => [type: :string, required: true], + "INTEGER_VAR" => [type: :integer, default: 10], + "UNDEFINED_VAR" => [required: false], + "BOOLEAN_VAR_1" => [type: :boolean], + "BOOLEAN_VAR_2" => [type: :boolean], + "BOOLEAN_VAR_3" => [type: :boolean], + "BOOLEAN_VAR_4" => [type: :boolean], + "DEFAULTED_VAR" => [required: true, default: ""] + }) + end + + test "raises and error with ALL missing environment variables" do + assert_raise RuntimeError, ~r/MISSING_1\n - MISSING_2/, fn -> + Envars.read!(%{"MISSING_1" => [], "MISSING_2" => []}) + end + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()