ELIXIR · 5 MIN READ · Feb 12, 2020

Skogsrå: Simplifying Your Elixir Configuration

Alex de Sousa

Alex de Sousa

BEAM engineer since 2012, Elixir alchemist since 2015, and open source author. Writing about Elixir, OTP, and software engineering.

Once an Elixir project is large enough, maintaining config files and configuration variables becomes a nightmare:

madness

Ideally though, configurations should be:

In summary: easy to maintain.

The problem

We’ll elaborate using the the following example:

config :myapp,
  hostname: System.get_env("HOSTNAME") || "localhost",
  port: String.to_integer(System.get_env("PORT") || "80")

The previous code is:

Conclusion: it’s hard to maintain.

Writing a config module

We could mitigate some of these problems with one simple approach:

The following, though a bit more verbose, would be the equivalent to the previous config:

defmodule Myapp.Config do
  @moduledoc "My app config."

  @doc "My hostname"
  def hostname do
    System.get_env("HOSTNAME") || "localhost"
  end

  @doc "My port"
  def port do
    String.to_integer(System.get_env("PORT") || "80")
  end
end

Unlike our original code, this one is:

However, we still have essentially the same code we had before, which is:

There’s gotta be a better way!

almost

There is a better way - Meet Skogsrå

Skogsrå is a library for loading configuration variables with ease, providing:

The previous example can be re-written as follows:

defmodule Myapp.Config do
  @moduledoc "My app config."
  use Skogsra

  @envdoc "My hostname"
  app_env :hostname, :myapp, :hostname,
    default: "localhost",
    os_env: "HOSTNAME"

  @envdoc "My port"
  app_env :port, :myapp, :port,
    default: 80,
    os_env: "PORT"
end

This module will have these functions:

With this implementation, we end up with:

dance

How it works

Calling Myapp.Config.port() will retrieve the value for the port in the following order:

  1. From the OS environment variable $PORT.
  2. From the configuration file e.g. our test config file might look like:

     # file config/test.exs
     use Mix.Config
    
     config :myapp,
       port: 4000
    
  3. From the default value, if it exists (In this case, it would return the integer 80).

The values will be casted as the default values’ type unless the option type is provided (see Explicit type casting section).

Though Skogsrå has many options and features, we will just explore the ones I use the most:

Explicit type casting

When the types are not any, binary, integer, float, boolean or atom, Skogsrå cannot automatically cast values solely by the default value’s type. Types then need to be specified explicitly using the option type. The available types are:

Defining custom types

Let’s say we need to read an OS environment variable called HISTOGRAM_BUCKETS as a list of integers:

export HISTOGRAM_BUCKETS="1, 10, 30, 60"

We could then implement Skogsra.Type behaviour to parse the string correctly:

defmodule Myapp.Type.IntegerList do
  use Skogsra.Type

  @impl Skogsra.Type
  def cast(value)

  def cast(value) when is_binary(value) do
    list =
      value
      |> String.split(~r/,/)
      |> Stream.map(&String.trim/1)
      |> Enum.map(String.to_integer/1)
    {:ok, list}
  end

  def cast(value) when is_list(value) do
    if Enum.all?(value, &is_integer/1), do: {:ok, value}, else: :error
  end

  def cast(_) do
    :error
  end
end

And finally use Myapp.Type.IntegerList in our Skogsrå configuration:

defmodule Myapp.Config do
  use Skogsra

  @envdoc "Histogram buckets"
  app_env :buckets, :myapp, :histogram_buckets,
    type: Myapp.Type.IntegerList,
    os_env: "HISTOGRAM_BUCKETS"
end

Then it should be easy to retrieve our buckets from an OS environment variable:

iex(1)> System.get_env("BUCKETS")
"1, 10, 30, 60"
iex(2)> Myapp.Config.buckets()
{:ok, [1, 10, 30, 60]}

or if the variable is not defined, from our application configuration:

iex(1)> System.app_env(:myapp, :histogram_buckets)
[1, 10, 30, 60]
iex(2)> Myapp.Config.buckets()
{:ok, [1, 10, 30, 60]}

Required variables

Skogsrå provides an option for making configuration variables mandatory. This is useful when there is no default value for our variable and Skogsrå it’s expected to find a value in either an OS environment variable or the application configuration e.g. given the following config module:

defmodule MyApp.Config do
  use Skogsra

  @envdoc "Server port."
  app_env :port, :myapp, :port,
    os_env: "PORT",
    required: true
end

The function Myapp.Config.port() will error if PORT is undefined and the application configuration is not found:

iex(1)> System.get_env("PORT")
nil
iex(2)> Application.get_env(:myapp, :port)
nil
iex(3)> MyApp.Config.port()
{:error, "Variable port in app myapp is undefined"}

Strong typing

All the configuration variables will have the correct function @spec definition e.g. given the following definition:

defmodule Myapp.Config do
  use Skogsra

  @envdoc "PostgreSQL hostname"
  app_env :db_port, :myapp, [:postgres, :port],
    default: 5432
end

The generated function Myapp.Config.db_port/0 will have the following @spec:

@spec db_port() :: {:ok, integer()} | {:error, binary()}

The type is derived from:

Conclusion

Skogsra provides a simple way to handle your Elixir application configurations in a type-safe and organized way. Big projects can certainly benefit from using it.

Hope you found this article useful. Happy coding!

coding

ABOUT THE AUTHOR
Alex de Sousa

Alex de Sousa

BEAM engineer since 2012, Elixir alchemist since 2015, and open source author. Writing about Elixir, OTP, and software engineering.

MORE POSTS