defmodule Dotenvy do @moduledoc """ `Dotenvy` is an Elixir port of the original [dotenv](https://github.com/bkeepers/dotenv) Ruby gem. It is designed to help applications follow the principles of the [12-factor app](https://12factor.net/) and its recommendation to store configuration in the environment. Unlike other configuration helpers, `Dotenvy` enforces no convention for the naming of your files: `.env` is a common choice, you may name your configuration files whatever you wish. See the [strategies](docs/strategies.md) for examples of various use cases. """ import Dotenvy.Transformer alias Dotenvy.Transformer.Error require Logger @typedoc """ An input source may be either a path to an env file or a map with string keys, e.g. `"envs/.env"` or `%{"FOO" => "bar"}`. This allows users to specify a list of env files interspersed with other values from other sources, e.g. `System.get_env()` or values fetched from a secure parameter store. """ @type input_source :: String.t() | %{optional(String.t()) => String.t()} @default_parser Dotenvy.Parser @doc """ A parser implementation should receive the `contents` read from a file, a map of `vars` (with string keys, as would come from `System.get_env/0`), and a keyword list of `opts`. This callback is provided to help facilitate testing. See `Dotenvy.Parser` for the default implementation. """ @callback parse(contents :: binary(), vars :: map(), opts :: keyword()) :: {:ok, map()} | {:error, any()} @doc """ Reads an env variable and converts its output or returns a default value. > #### Use `env!/2` when possible {: .info} > > Use of `env!/2` is recommended over `env!/3` because it creates a stronger contract with > the environment: your app literally will not start when required env variables are missing. If the given `variable` is *set*, its value is converted to the given `type`. The provided `default` value is _only_ used when the variable is _not_ set; **the `default` value is returned as-is, without conversion**. This allows greater control of the output. This function may raise an error when the conversion is delegated to `Dotenvy.Transformer.to!/2` -- see its documentation for a list of supported types. This function attempts to read a value from a local data store of sourced values; it will fall back to `System.fetch_env/1` when no locally stored variable is available. ## Examples iex> env!("PORT", :integer, 5432) 5433 iex> env!("NOT_SET", :boolean, %{not: "converted"}) %{not: "converted"} iex> System.put_env("HOST", "") iex> env!("HOST", :string!, "localhost") ** (RuntimeError) Error converting HOST to string!: non-empty value required """ @doc since: "0.3.0" @spec env!(variable :: binary(), type :: atom(), default :: any()) :: any() | no_return() def env!(variable, type, default) do case fetch_var(variable) do :error -> default {:ok, value} -> to!(value, type) end rescue error in Error -> reraise "Error converting #{variable} to #{type}: #{error.message}", __STACKTRACE__ end @deprecated "Use `Dotenvy.env!/3` instead" @spec env(variable :: binary(), type :: atom(), default :: any()) :: any() | no_return() def env(variable, type \\ :string, default \\ nil) def env(variable, type, default), do: env!(variable, type, default) @doc """ Reads the given env `variable` and converts its value to the given `type`. This function attempts to read a value from a local data store of sourced values; it will fall back to `System.fetch_env/1` when no locally stored variable is available. This function may raise an error because type conversion is delegated to `Dotenvy.Transformer.to!/2` -- see its documentation for a list of supported types. ## Examples iex> env!("PORT", :integer) 5432 iex> env!("ENABLED", :boolean) true """ @spec env!(variable :: binary(), type :: atom()) :: any() | no_return() def env!(variable, type \\ :string) def env!(variable, type) do case fetch_var(variable) do :error -> raise "Application environment variable #{variable} not set" {:ok, value} -> to!(value, type) end rescue error in Error -> reraise "Error converting variable #{variable} to #{type}: #{error.message}", __STACKTRACE__ error -> reraise error, __STACKTRACE__ end @doc """ Like its Bash namesake command, `source/2` accumulates values from the given input(s). The accumulated values are stored via a side effect function to make them available to the `env!/2` and `env!/3` functions. Think of `source/2` as a _merging operation_ which can accept maps (like `Map.merge/2`) or paths to env files. Inputs are processed from left to right so that values can be overridden by each subsequent input. As with `Map.merge/2`, the right-most input takes precedence. ## Options - `:parser` module that implements `c:Dotenvy.parse/3` callback. Default: `Dotenvy.Parser` - `:require_files` specifies which of the given `files` (if any) *must* be present. When `true`, all the listed files must exist. When `false`, none of the listed files must exist. When some of the files are required and some are optional, provide a list specifying which files are required. If a file listed here is not included in the function's `files` argument, it is ignored. Default: `false` - `:side_effect` an arity 1 function called after the successful parsing inputs. The default is an internal function that stores the values inside a process dictionary so the values are available to the `env!/2` and `env!/3` functions. You may also set this value to `false` to disable a side-effect altogether, but this will effectively neutralize the `env!/2` function. ## Examples The simplest implementation is to parse a single file by including its path: iex> Dotenvy.source(".env") {:ok, %{ "TIMEOUT" => "5000", "DATABASE_URL" => "postgres://postgres:postgres@localhost/myapp", # ...etc... } } More commonly, you will source _multiple_ files (often based on the `config_env()`) and you will defer to pre-existing system variables. The most common pattern looks like this: iex> Dotenvy.source([ "\#\{config_env()\}.env", "\#\{config_env()\}.override.env", System.get_env() ]) In the above example, the `prod.env`, `dev.env`, and `test.env` files would be version-controlled, but the `*.override.env` variants would be ignored, giving developers the ability to override values without needing to modify versioned files. > #### Give Precedence to System Envs! {: .warning} > > Don't forget to include `System.get_env()` as the final input to `source/2` so that > system environment variables take precedence over values sourced from `.env` files. > > When you run a shell command like `❯ LOG_LEVEL=debug mix run`, your expectation is probably that > the `LOG_LEVEL` variable would be set to `debug`, overriding whatever may have been defined > in your sourced `.env` files. Similarly, you may export env vars in your Bash profile. > System env vars are not granted precedence automatically: you must explicitly include > `System.get_env()` as the final input to `source/2`. If your env files are making use of variable substitution based on system env vars, e.g. `${PWD}` (see the [Dotenv File Format](docs/dotenv-file-format.md)), then you would need to include `System.get_env()` _before_ your inputs. For example, if your `.env` references the system `HOME` variable: ``` # .env CACHE_DIR=${HOME}/cache ``` then your `source/2` command would need to make the system env vars available to the parser by including them as one of the inputs, e.g. iex> Dotenvy.source([System.get_env(), ".env"]) Including the `System.get_env()` before your files means that your files have final say over the values, potentially overriding any pre-existing system env vars. In some cases, you may wish to reference the system vars both _before and after_ your own .env files, e.g. iex> Dotenvy.source([System.get_env(), ".env", System.get_env()]) or you may wish to cherry-pick which variables you need to make available for variable substitution: iex> Dotenvy.source([ %{"HOME" => System.get_env("HOME")}, ".env", System.get_env() ]) This syntax favors explicitness so there is no confusion over what might have been "automagically" accumulated. """ @spec source(inputs :: input_source() | [input_source()], opts :: keyword()) :: {:ok, %{optional(String.t()) => String.t()}} | {:error, any()} def source(files, opts \\ []) def source(file, opts) when is_binary(file), do: source([file], opts) def source(inputs, opts) when is_list(inputs) do side_effect = Keyword.get(opts, :side_effect, &put_all_vars/1) require_files = Keyword.get(opts, :require_files, false) with :ok <- verify_inputs(inputs, require_files), {:ok, vals} <- acc_vals(inputs, %{}, opts) do if is_function(side_effect), do: side_effect.(vals) {:ok, vals} end end @doc """ As `source/2`, but returns a map on success or raises on error. """ @spec source!(files :: binary() | [binary()], opts :: keyword()) :: %{optional(String.t()) => String.t()} | no_return() def source!(files, opts \\ []) def source!(file, opts) when is_binary(file), do: source!([file], opts) def source!(files, opts) when is_list(files) do case source(files, opts) do {:ok, vars} -> vars {:error, error} -> raise error end end defp fetch_var(variable) do :dotenvy_vars |> Process.get(%{}) |> Map.fetch(variable) |> case do {:ok, value} -> {:ok, value} :error -> System.fetch_env(variable) end end defp put_all_vars(vars), do: Process.put(:dotenvy_vars, vars) # handles the parsing of a single file or an input map defp acc_vals([], acc, _opts), do: {:ok, acc} defp acc_vals([map | remaining], acc, opts) when is_map(map) do acc_vals(remaining, Map.merge(acc, map), opts) end defp acc_vals([file | remaining], acc, opts) when is_binary(file) do parser = Keyword.get(opts, :parser, @default_parser) require_files = Keyword.get(opts, :require_files, false) with {:ok, contents} <- read_file(file, require_files), {:ok, new_vars} <- parser.parse(contents, acc, opts) do acc_vals(remaining, Map.merge(acc, new_vars), opts) else :continue -> acc_vals(remaining, acc, opts) {:error, error} -> {:error, "There was error with file #{inspect(file)}: #{inspect(error)}"} end end @spec verify_inputs(list(), list() | boolean()) :: :ok | {:error, any()} defp verify_inputs(_, true), do: :ok defp verify_inputs(_, false), do: :ok defp verify_inputs(inputs, require_files) do input_set = inputs |> Enum.filter(fn x -> is_binary(x) end) |> MapSet.new() required_set = MapSet.new(require_files) case MapSet.equal?(required_set, input_set) || MapSet.subset?(required_set, input_set) do true -> :ok false -> {:error, "Missing one or more files specified by :require_files"} end end # Reads the file after checking whether or not it exists @spec read_file(file :: binary(), true | false | [binary()]) :: {:ok, binary()} | {:error, any()} | :continue defp read_file(file, false) do case File.exists?(file) do true -> File.read(file) false -> :continue end end defp read_file(file, true) do case File.exists?(file) do true -> File.read(file) false -> {:error, "file not found"} end end defp read_file(file, require_files) when is_list(require_files) do file |> read_file(Enum.member?(require_files, file)) end end