Pax.Config (Pax v0.0.1-dev)

View Source

Pax.Config is a module for defining configuration structs and functions for Pax. It has two main concerns:

  1. Ingest and validate configuration data, checking for common mistakes and typos.
  2. Resolve configuration values for code that is configurable, calling functions and checking return types.

Ingesting and validation

Configuration data supplied by a developer is validated against a specification of expected keys and their possible types. These types are just basic Elixir types, such as would be checked with a guard clause. This is not intended to be a complete type system, just enough to help the developer if they make a mistake.

Supported Types

Config typeElixir typeExample
nilnilnil
:atomatom():foo
:stringbinary()"foo"
:booleanboolean()true, false
:integerinteger()42
:floatfloat()42.0
:tupletuple(){1, 2, 3}
:listlist()[1, 2, 3]
:mapmap()%{foo: "bar"}
:modulemodule()MyProject.Module
:structstruct()%MyStruct{name: "foo"}
{:struct, Module}struct(){:struct, MyStruct} => %MyStruct{name: "foo"}
:dateDate~D[2020-01-01]
:timeTime~T[12:00:00]
:naive_datetimeNaiveDateTime~N[2020-01-01 12:00:00]
:datetimeDateTime~U[2020-01-01 12:00:00Z]
:uriURIURI.parse("https://example.com")
:functionfunction():function => fn -> "foo" end
{:function, arity}function(){:function, 1} => fn x -> x end
{:function, type}function(){function, :atom} => fn -> :foo end
{:function, types}function(){function, [nil, :atom, :string]} => fn -> "foo" end
{:function, arity, type}function(){:function, 2, :integer} => fn x, y -> x * y end
{:function, arity, types}function(){:function, 2, [:integer, nil]} => fn _, _ -> nil end

The spec of types can either be a single type, or a list of types. If a list of types is provided, the value must match at least one of the types in the list.

Any extra keys in the config data that are not expected will return an error.

A map of validated configuration is returned that can be used with fetch/3, fetch!/3 and get/4 to resolve the configuration values.

Nested Configuration

The spec can be a map of keys to either types, or another map of keys to types. This allows for nested configuration data.

For example, you can have a spec like the following, then the data must conform to the spec with the same nesting.

iex> spec = %{
...>   foo: [:integer, {:function, 1, :integer}],
...>   bar: %{
...>     baz: [:string]
...>   }
...> }
...> data = [
...>   foo: 42,
...>   bar: [
...>     baz: "hello"
...>   ]
...> ]
...> {:ok, _config} = Pax.Config.validate(spec, data)
{:ok,
  %{
    foo: {:integer, 42},
    bar: %{baz: {:string, "hello"}}
  }}

To fetch the configured value of nested keys, you can use the fetch/3, fetch!/3 and get/4 functions with a list of keys, similar to the get_in/2 function in Elixir.

iex> spec = %{foo: [:integer, {:function, 1, :integer}], bar: %{:baz => [:string]}}
...> data = [foo: 42, bar: [baz: "hello"]]
...> config = Pax.Config.validate!(spec, data)
...> Pax.Config.fetch(config, :foo)
{:ok, 42}
...> Pax.Config.get(config, [:bar, :baz])
"hello"

Resolving configuration values

Since configuration values can be functions, this module provides a way to call those functions and get the resolved value. This is useful for code that wants to use the configuration data without having to know how to call functions. If the spec allows a function, then when you get the value you must pass the args (if any) that would be required to resolve the function. The args are only used if the user supplied an anonymous function as the value for that config key.

For example, if your spec is like the following, then when you fetch the config value for the :foo config key, you must pass the argument expected by the function, even if the user hasn't provided a function, and instead just provided a value.

iex> spec = %{
...>   foo: [:integer, {:function, 1, :integer}]
...> }
...>
...> # The user has provided an integer instead of a function returning an integer, but when
...> # fetching the value, you still must pass the argument expected by the function
...> data = %{foo: 42}
...> config = Pax.Config.validate!(spec, data)
...> Pax.Config.fetch(config, :foo, [:arg])
{:ok, 42}
...>
...> # This is so if the user provided a function, then fetching the value will be able
...> # to pass that arg to the user-supplied function.
...> data = %{foo: fn arg -> if arg == :one, do: 1, else: 0 end}
...> {:ok, config} = Pax.Config.validate(spec, data)
...> Pax.Config.fetch(config, :foo, [:one])
{:ok, 1}

Summary

Functions

Fetch a value for a specific key from a config map. Returns {:ok, value} if the key is found in the config, otherwise :error.

Fetch a value for a specific key from a config map, raising an error if the key is not found.

Gets the value for a specific key from a config map, returning the default if not found.

Validate user-provided configuration data against a configuration spec.

Validate user-provided configuration data against a configuration spec, raising an error if the data does not conform.

Functions

fetch(config, key_or_keys, args \\ [])

@spec fetch(config :: map(), key_or_keys :: atom(), args :: list()) ::
  {:ok, any()} | :error

Fetch a value for a specific key from a config map. Returns {:ok, value} if the key is found in the config, otherwise :error.

The config map must be the result of a call to validate/3.

Either an individual key can be given, or a list of keys if the configuration data is nested. See Nested Configuration for more information.

In the case that a function is allowed in the spec, then the correct args must be passed to this function to resolve the value. If no functions are allowed by the spec, the args can be omitted.

An ArgumentError will be raised if the config is not a map with the correct structure, as returned from validate/3.

A Pax.Config.TypeError will be raised if the function provided by the user does not return the correct type, as specified by the spec.

A Pax.Config.ArityError will be raised if the count of args given to this function do not match one of the specs for the given key.

fetch!(config, key, args \\ [])

@spec fetch!(config :: map(), key_or_keys :: atom() | [atom(), ...], args :: list()) ::
  any()

Fetch a value for a specific key from a config map, raising an error if the key is not found.

The config map must be the result of a call to validate/3.

Either an individual key can be given, or a list of keys if the configuration data is nested. See Nested Configuration for more information.

In the case that a function is allowed in the spec, then the correct args must be passed to this function to resolve the value. If no functions are allowed by the spec, the args can be omitted.

A KeyError will be raised if the key is not found in the configuration, which means it was not in the data given to validate/3.

An ArgumentError will be raised if the config is not a map with the correct structure, as returned from validate/3.

A Pax.Config.TypeError will be raised if the function provided by the user does not return the correct type, as specified by the spec.

A Pax.Config.ArityError will be raised if the count of args given to this function do not match one of the specs for the given key.

get(config, key_or_keys, args \\ [], default \\ nil)

@spec get(
  config :: map(),
  key_or_keys :: atom() | [atom(), ...],
  args :: list(),
  default :: any()
) ::
  any()

Gets the value for a specific key from a config map, returning the default if not found.

The config map must be the result of a call to validate/3.

Either an individual key can be given, or a list of keys if the configuration data is nested. See Nested Configuration for more information.

In the case that a function is allowed in the spec, then the correct args must be passed to this function to resolve the value. If no functions are allowed by the spec, the args can be omitted.

Important Note

If you are passing a default that is a list, then you need to use the get/4 function with an empty list for the args, otherwise the default will be treated as the args, and the default will actually be nil

iex> Pax.Config.get(config, :key, [], [1, 2, 3])

An ArgumentError will be raised if the config is not a map with the correct structure, as returned from validate/3.

A Pax.Config.TypeError will be raised if the function provided by the user does not return the correct type, as specified by the spec.

A Pax.Config.ArityError will be raised if the count of args given to this function do not match one of the specs for the given key.

validate(spec, data, opts \\ [])

@spec validate(spec :: map(), data :: map() | keyword(), opts :: keyword()) ::
  {:ok, map()} | {:error, term()}

Validate user-provided configuration data against a configuration spec.

The spec is a map of expected keys and the type (or types) that the data should provide. Please see Supported Types for the list of allowed types.

If the spec has has any errors then a Pax.Config.SpecError will be raised.

If the data does conform to the spec, then {:ok, config} will be returned, where config is a map of validated configuration that can be used with the fetch/3, fetch!/3 and get/4 functions to resolve the value.

If the data not not conform to the spec, then {:error, reason} will be returned, where reason is a string describing the error.

Only keys that are given in the data will be returned in the config. If the spec has a key that is not in the data, then it will not be in the config.

Options

  • :validate_config_spec - Validate the spec itself. This is useful to turn on when developing an adapter or plugin, but should be turned off in production to avoid unnecessary checks. Defaults to false unless the application env :validate_config_spec is set to true in the :pax application at compile time. E.g. in "config/dev.exs":

    config :pax, validate_config_spec: true

    If it is changed, you will need to mix deps.compile --force pax to recompile pax with the new value.

validate!(spec, data, opts \\ [])

@spec validate!(spec :: map(), data :: map() | keyword(), opts :: keyword()) :: map()

Validate user-provided configuration data against a configuration spec, raising an error if the data does not conform.

The spec is a map of expected keys and the type (or types) that the data should provide. Please see Supported Types for the list of allowed types.

If the data does conform to the spec, then {:ok, config} will be returned, where config is a map of validated configuration that can be used with the fetch/3, fetch!/3 and get/4 functions to resolve the value.

Only keys that are given in the data will be returned in the config. If the spec has a key that is not in the data, then it will not be in the config.

If the data does not form to the spec, then a Pax.ConfigError will be raised.

Options

  • :validate_config_spec - Validate the spec itself, raising a Pax.Config.SpecError in the case of errors. This is useful to turn on when developing an adapter or plugin, but should be turned off in production to avoid unnecessary checks. Defaults to false unless the application env :validate_config_spec is set to true in the :pax application at compile time. E.g. in "config/dev.exs":

    config :pax, validate_config_spec: true

    If it is changed, you will need to mix deps.compile --force pax to recompile pax with the new value.