Current section

Files

Jump to
skogsra lib skogsra.ex
Raw

lib/skogsra.ex

defmodule Skogsra do
@moduledoc """
> The _SkogsrĂ¥_ was a mythical creature of the forest that appears in the form
> of a small, beautiful woman with a seemingly friendly temperament. However,
> those who are enticed into following her into the forest are never seen
> again.
This library attempts to improve the use of OS environment variables for
application configuration:
* Automatic type casting of values.
* Options documentation.
* Variables defaults.
## Small Example
You would create a settings module and define the e.g:
```elixir
defmodule MyApp.Settings do
use Skogsra
system_env :some_service_port,
default: 4000
app_env :some_service_hostname, :my_app, :hostname,
domain: MyApp.Repo,
default: "localhost"
end
```
and you would use it in a module as follows:
```
defmodule MyApp.SomeModule do
alias MyApp.Setting
(...)
def connect do
hostname = Settings.some_service_hostname()
port = Settings.some_service_port()
SomeService.connect(hostname, port)
end
(...)
end
```
### Example Explanation
The module `MyApp.Settings` will have two functions e.g:
* `some_service_port/0`: Returns the port as an integer. Calling this
function is roughly equivalent to the following code (without the automatic
type casting):
```
System.get_env("SOME_SERVICE_PORT") || 4000
```
* `some_service_hostname/0`: Returns the hostname as a binary. Calling this
function is roughly equivalent to the following code (without the automatic
type casting):
```
case System.get_env("SOME_SERVICE_HOSTNAME") do
nil ->
:my_app
|> Application.get_env(MyApp.Domain, [])
|> Keyword.get(:hostname, "localhost")
value ->
value
end
```
Things to note:
1. The functions have the same name as the OS environment variable, but in
lower case.
2. The functions infer the type from the `default` value. If no default value
is provided, it will be casted as binary by default.
3. Both functions try to retrieve and cast the value of an OS environment
variable, but the one declared with `app_env` searches for `:my_app`
configuration if the OS environment variable is empty:
```
config :my_app, MyApp.Domain,
hostname: "some_hostname"
```
If the default value is not present, Skogsra cannot infer the type, unless the
type is set with the option `type`. The possible values for `type` are
`:integer`, `:float`, `:boolean`, `:atom` and `:binary`.
## Recommended Usage
The recommended way of using this project is to define a `.env` file in the
root of your project with the variables that you want to define e.g:
```
export SOME_SERVICE_PORT=1234
```
and then when `source`ing the file right before you execute your application.
In `bash` (or `zsh`) would be like this:
```
$ source .env
```
The previous step can be automated by adding the following code to your
`~/.bashrc` (or `~/.zshrc`):
```
#################
# BEGIN: Auto env
export LAST_ENV=
function auto_env_on_chpwd() {
env_type="$1"
env_file="$PWD/.env"
if [ -n "$env_type" ]
then
env_file="$PWD/.env.$env_type"
if [ ! -r "$env_file" ]
then
echo -e "\e[33mFile $env_file does not exist.\e[0m"
env_file="$PWD/.env"
fi
fi
if [ -r "$env_file" ]
then
if [ -z "$LAST_ENV" ] && [ -r "$LAST_ENV" ]
then
`cat $LAST_ENV | sed -e 's/^export \([0-9a-zA-Z\_]*\)=.*$/unset \1/'`
echo -e "\e[32mUnloaded ENV VARS defined in \"$LAST_ENV\"\e[0m"
fi
export LAST_ENV="$env_file"
source $LAST_ENV
echo -e "\e[32mLoaded \"$LAST_ENV\"\e[0m"
fi
}
chpwd_functions=(${chpwd_functions[@]} "auto_env_on_chpwd")
if [ -n "$TMUX" ]; then
auto_env_on_chpwd
fi
alias change_to='function _change_to() {auto_env_on_chpwd $1}; _change_to'
# END: Auto env
###############
```
The previous code will attempt to `source` any `.env` file every time you
change directory e.g:
```
/home/alex $ cd my_app
Loaded "/home/alex/my_app/.env"
/home/alex/my_app $ echo "$SOME_SERVICE_PORT"
1234
```
Additionally, the command `change_to <ENV>` is included. To keep your `prod`,
`dev` and `test` environment variables separated, just create a
`.env.${MIX_ENV}` in the root directory of your project. And when you want to
use the variables set in one of those files, just run the following:
```
$ change_to dev # Will use `.env.dev` instead of `.env`
```
"""
require Logger
@doc """
For now is just equivalent to use `import Skogsra`.
"""
defmacro __using__(_) do
quote do
import Skogsra
end
end
@doc """
Macro that receives the `name` of the OS environment variable and some
optional `Keyword` list with `options` and generates a function with arity 0
with the same name of the OS environment variable, but in lower case e.g.
`"FOO"` would generate the function `foo/0`.
The available options are:
- `:static` - Whether the computation of the OS environment variable is
done on compiling time or not. By default its value is `false`.
- `:default` - Default value in case the OS environment variable doesn't
exist. By default is `nil`.
- `:type` - The type of the OS environment variable. By default is the type
of the default value. If there is no default value, the default type is
`binary`. The available types are `:binary`, `:integer`, `:float`,
`:boolean` and `:atom`.
e.g
```elixir
defmodule Settings do
use Skogsra
system_env :foo,
default: 42,
type: :integer
end
```
This would generate the function `Settings.foo/0` that would search for
the OS environment variable `"FOO"` and cast it to integer on runtime and
defaults to `42`.
"""
defmacro system_env(name, opts \\ []) do
if Keyword.get(opts, :static, false) do
Skogsra.static_system_env(name, opts)
else
Skogsra.runtime_system_env(name, opts)
end
end
@doc """
Macro that receives the `name` of the OS environment variable, the name of
the `app`, the name of the option `key` and some optional `Keyword` list with
`options` and generates a function with arity 0 with the same name of the OS
environment variable, but in lower case e.g. `"FOO"` would generate the
function `foo/0`.
The available options are:
- `:static` - Whether the computation of the OS environment variable or
application configuration option is done on compiling time or not. By
default its value is `false`.
- `:default` - Default value in case the OS environment variable and the
application configuration option don't exist. By default is `nil`.
- `:type` - The type of the OS environment variable. By default is the type
of the default value. If there is no default value, the default type is
`binary`. The available types are `:binary`, `:integer`, `:float`,
`:boolean` and `:atom`.
- `:domain` - The `key` to search in the configuration file e.g in:
```elixir
config :my_app, MyApp.Repo,
(...)
```
the domain would be `MyApp.Repo`. By default, there is no domain.
e.g
```elixir
defmodule Settings do
use Skogsra
app_env :foo, :my_app, :foo,
default: 42,
type: :integer,
domain: MyApp.Domain
end
```
This would generate the function `Settings.foo/0` that would search for
the OS environment variable `"FOO"` and cast it to integer on runtime. If the
OS environment variable is not found, attempts to search for the `:foo`
configuration option for the application `:my_app` and the domain
`MyApp.Domain`. If nothing is found either, it defaults to `42`.
Calling `Settings.foo/0` without a domain set is equivalent to:
```elixir
with value when not is_nil(value) <- System.get_env("FOO"),
{number, _} <- Integer.parse(value) do
number
else
_ ->
Application.get_env(:my_app, :foo, 42)
end
```
Calling `Settings.foo/0` with a domain set is equivalent to:
```elixir
with value when not is_nil(value) <- System.get_env("FOO"),
{number, _} <- Integer.parse(value) do
number
else
_ ->
opts = Application.get_env(:my_app, MyApp.Domain, [])
Keyword.get(opts, :foo, 42)
end
```
"""
defmacro app_env(name, app, key, opts \\ []) do
if Keyword.get(opts, :static, false) do
Skogsra.static_app_env(name, app, key, opts)
else
Skogsra.runtime_app_env(name, app, key, opts)
end
end
@doc """
Gets the OS environment variable by its `name` and cast it the the type of
the `default` value. If no `default` value is provided, returns a string.
If the OS environment variable is not found, returns the `default` value.
```
"""
@spec get_env(binary) :: term()
@spec get_env(binary, term) :: term()
def get_env(name, default \\ nil) when is_binary(name) do
default
|> type?()
|> get_env_as(name, default)
end
@doc """
Gets the OS environment variable by its `name` and casts it to the provided
`type`. If the OS environment variable is not found, returns the `default`
value.
"""
@spec get_env_as(atom(), binary()) :: term()
@spec get_env_as(atom(), binary(), term()) :: term()
def get_env_as(type, name, default \\ nil) when is_binary(name) do
default = cast_default(default, type)
with {:ok, value} <- do_get_env_as(type, name, default) do
value
else
{:error, message} ->
Logger.warn(fn ->
"Failed to get environment variable due to #{inspect message}"
end)
default
end
end
@doc """
Gets the OS environment variable by its name if present in the option
`:name`. If it's not found, attempts to get the application configuration
option by the `app` name and the option `key`. Optionally receives a
`Keyword` list of `options`
i.e:
- `:name` - Name of the OS environment variable. By default is `""`.
- `:default` - Default value in case the OS environment variable and the
application configuration option don't exist. By default is `nil`.
- `:type` - The type of the OS environment variable. By default is the type
of the default value. If there is no default value, the default type is
`binary`. The available types are `:binary`, `:integer`, `:float`,
`:boolean` and `:atom`.
- `:domain` - The `key` to search in the configuration file e.g in:
```elixir
config :my_app, MyApp.Repo,
hostname: "localhost",
(...)
```
the domain would be `MyApp.Repo`. By default, there is no domain. If the
domain is a list of atoms forces the algorithm to go recursively in the
structure to find the key.
e.g:
```elixir
defmodule MyApp do
def get_hostname do
Skogsra.get_app_env(:my_app, :hostname, domain: MyApp.Repo, name: "SOME_SERVICE_HOSTNAME")
end
end
```
"""
@spec get_app_env(atom(), atom()) :: term()
@spec get_app_env(atom(), atom(), list()) :: term()
def get_app_env(app, key, options \\ []) do
name = Keyword.get(options, :name, "")
default = Keyword.get(options, :default, nil)
type = Keyword.get(options, :type, type?(default))
with default = cast_default(default, type),
value when is_nil(value) <- get_env_as(type, name),
domain = Keyword.get(options, :domain),
{:ok, value} <- do_get_app_env(type, app, domain, key, default) do
value
else
{:error, _} ->
default
value ->
value
end
end
#########
# Helpers
@doc false
def runtime_system_env(name, opts) do
default = Keyword.get(opts, :default)
type = Keyword.get(opts, :type, type?(default))
env_name = name |> Atom.to_string() |> String.upcase()
quote do
def unquote(name)() do
Skogsra.get_env_as(unquote(type), unquote(env_name), unquote(default))
end
end
end
@doc false
def static_system_env(name, opts) do
default = Keyword.get(opts, :default)
type = Keyword.get(opts, :type, type?(default))
env_name = name |> Atom.to_string() |> String.upcase()
value = Skogsra.get_env_as(type, env_name, default)
quote do
def unquote(name)() do
unquote(value)
end
end
end
@doc false
def runtime_app_env(name, app, key, opts) do
env_name = name |> Atom.to_string() |> String.upcase()
quote do
def unquote(name)() do
Skogsra.get_app_env(
unquote(app),
unquote(key),
[{:name, unquote(env_name)} | unquote(opts)]
)
end
end
end
@doc false
def static_app_env(name, app, key, opts) do
env_name = name |> Atom.to_string() |> String.upcase()
value = Skogsra.get_app_env(app, key, [{:name, env_name} | opts])
quote do
def unquote(name)() do
unquote(value)
end
end
end
@doc false
def do_get_app_env(type, app, nil, key, default) do
value = Application.get_env(app, key, default)
cast(value, type)
end
def do_get_app_env(type, app, domain, key, default) when is_atom(domain) do
do_get_app_env(type, app, [domain], key, default)
end
def do_get_app_env(type, app, [domain | domains], key, default) do
case Application.get_env(app, domain) do
nil ->
{:ok, default}
opts ->
value = do_get_app_env(opts, domains, key, default)
cast(value, type)
end
end
@doc false
def do_get_app_env(opts, [], key, default) do
Keyword.get(opts, key, default)
end
def do_get_app_env(opts, [domain | domains], key, default) do
case Keyword.get(opts, domain) do
nil ->
default
new_opts ->
do_get_app_env(new_opts, domains, key, default)
end
end
@doc false
def do_get_env_as(type, name, default \\ nil)
def do_get_env_as(_, "", default) do
{:ok, default}
end
def do_get_env_as(type, name, default) do
value = System.get_env(name)
if is_nil(value) do
{:ok, default}
else
cast(value, type)
end
end
@doc false
def type?(nil), do: :any
def type?(value) when is_integer(value), do: :integer
def type?(value) when is_float(value), do: :float
def type?(value) when is_boolean(value), do: :boolean
def type?(value) when is_atom(value), do: :atom
def type?(_), do: :any
@doc false
def cast_default(default, type) do
with {:ok, default} <- cast(default, type) do
default
else
_ ->
nil
end
end
@doc false
def cast(value, :integer) when is_binary(value) do
value = String.trim(value)
with {number, ""} <- Integer.parse(value) do
{:ok, number}
else
{_, rest} ->
{:error, "#{rest} from #{value} couldn't be casted to integer"}
:error ->
{:error, "Cannot cast #{inspect value} to integer"}
end
end
def cast(value, :float) when is_binary(value) do
value = String.trim(value)
with {number, ""} <- Float.parse(value) do
{:ok, number}
else
{_, rest} ->
{:error, "#{rest} from #{value} couldn't be casted to float"}
:error ->
{:error, "Cannot cast #{inspect value} to float"}
end
end
def cast(value, :boolean) when is_binary(value) do
real_value =
value
|> String.downcase()
|> String.trim()
|> String.to_atom()
if is_boolean(real_value) do
{:ok, real_value}
else
{:error, "Cannot cast #{inspect value} to boolean"}
end
end
def cast(value, :atom) when is_binary(value) do
{:ok, String.to_atom(value)}
end
def cast(value, _) do
{:ok, value}
end
end