@l.degener/irma-config
v1.2.0
Published
Configuration subsystem extracted from https://github.com/ldegen/irma
Downloads
7
Readme
The Configuration Tree
The Configuration Builder
A configuration program comprises two things: a configuration and an action that makes use of that configuration. The action may be a no-op, it may also be a chain of several atomic steps. It's all the same to us.
To create such a configuration program, we use the ConfigurationBuilder
-API.
Basic Usage
A rather trivial example would look like this:
# instantiate the builder
config = ConfigBuilder()
# setup the lookup path for config type plugins
.typePath [process.env.TYPE_PATH]
# specify a YAML file to load
.load pathToConfigFile
# build the configuration program and return the root node
.build()
Programmatically adding/overriding settings
You can add / override settings by providing a configuration object to
the .add(object, [filename])
-method:
config = ConfigBuilder()
.add defaults
.load configFile
.add
foo:
bar: 42
.add()
will recursively merge the given object into the existing configuration.
Via the optional second argument you can associate a filename with the object.
This filename is used when resolving relative filesystem paths.
Note that while the given object
itself should be a plain javascript object, it
is totally allowed to contain ConfigNode
instances.
Loading YAML-Files
To load configuration from YAML files, use the .load()
and .tryLoad()
-methods.
Loading from a file has the same effect as providing the equivalent content to the .add()
-method.
The .tryLoad()
-method does the same as .load()
, but will silently ignore files that do
not exist or cannot be read from. .load()
on the other hand will throw an exception in
this case.
Setting up the Plugin Lookup Path
When writing YAML configuration files, you can annotate nodes with local tags to tell the config builder to use a particular custom type to represent that node. For example, if your YAML file contains this:
foo: !my-bar
oink: 42
the config builder will
Side Effects (a.k.a. Actions)
The API also allows you to attach side effects to the configuration program. As seen in the trivial example above, this is completely optional. The original reason for me to add this feature was the IRMa CLI module. On the one hand, it provides and manipulates the configuration. On the other hand, it also decides the primary action to execute (display help message, start server, create man page, ...).
config = ConfigBuilder()
.typePath [process.env.TYPE_PATH]
.load pathToConfigFile
.then (env, configRoot, argv)->
runMyApplication configRoot
# ... later, when everything is setup...
config.run env, argv
There are probably a million other ways to do this, I tried a couple and this is simply the
one I liked most. It ties in neatly with the way the bind
-operator works (see below).
Another benefit of this approach is that it will generalize nicely should we decide to
introduce asynchronous loading/processing of configuration elements. I am actually thinking
about deprecating the .build()
-method for this reason and make .then()
the prefered
way of accessing the configuration.
Note that env
and argv
are just arbitrary values that are passed to your actions.
See the explanation of environment
in the glossary for more details.
The configRoot
is the root node of the configuration, the same that would be returned by .build()
.
The bind
operator
The Configuration Builder mainly defines a monadic combinator bind
that
makes the set of configuration programs a monad.
First, let's assume that any instance of ConfigurationBuilder
has a
current configuration and a current action. It does not modify either of the two.
Now we call configBuilder.bind f
for some Kleisli Arrow f
. The
operation will start by creating an intermediate instance tmpBuilder
by
simply applying f
to the current configuration.
It will then create a new action combinedAction
by chaining the current
action of configBuilder
and that of tmpBuilder
, taking care of all the
result-passing and promise-related shenanigans.
Finally it will take the current configuration of tmpBuilder
and the
combinedAction
it just created, and wrap both up in a new
ConfigBuilder
. This is the return value.
Why is the current action not passed to the arrow functions?
The bind
-operation only passes the current configuration to the arrows,
but not the current action. Which is both good and bad.
It is good, because the chaining of the actions is taken care of by bind
.
so the arrows do not have to deal with this. My observation was that most of
the basic arrow steps either modify the configuration or the action, but
not both. If they did modify the action, it would usually be monotonic (i.e.
append a step to a chain of actions). So moving the responsibility to the
arrows would add repetitive clutter with little gain.
It is bad, because it effectively prohibits arrows from altering the action in non-monotonic ways (e.g. overriding or veto-ing of side effects). I have yet to encounter a case where this is a problem, but still -- it seems "incomplete".
I think I will have a second variant of the bind
-operation for
that. I could even examine the number of formal parameters of the arrow
function to determine which of the variants to use.
Glossary
Configuration
As far as the config builder is concerned, a configuration is just a plain
Javascript object. A builder will carry around some configuration object,
allowing you to incrementally modify or extend it. When you finally call the
.build()
-method, it will put the configuration into a new instance of
RootNode
, which will traverse the configuration and take care of
initializing any nested ConfigNode
-instances in the correct order.
Environment
When executing a configuration program via .run()
, the caller passes two
(optional) arguments: The so-called environment and an optional
initialization argument for the action. Both are treated similar in that they
do not end up in the final configuration but instead are passed on to the
action. They do however serve different purposes, which becomes clearer if we
consider programs that contain an action composed of more than one step. In
this case, each step will be called with the same environment. In contrast,
the second argument is only passed to the first step. The second step instead
sees the return value of the first step, the third that of the second and so
on.
Action
Actions provide a way of adding side effects to your configuration program. An action is a javascript function that takes three arguments:
- the environment,
- the root node of the final configuration
- an optional argument.
You may chain any number of actions to the program via the .then()
method.
When you execute the program (via .run()
), these actions will be executed
in the order they were attached. The optional third argument is used to pass
the result of the previous step to the next one. The second argument of the .run()
will be passed to the first action in the chain.
If your action involves asynchronous work, you probably want to have it return a promise object.
A rather canonic example for encoding side effects in a configuration program can be found in IRMa's CLI module. Depending on the options given on the command line -- it will either start the service, print help or generate a manpage when the configuration program is executed.
Configuration Program
A Configuration Program is just a pair of a configuration and an action. Keep in mind that actions are composable, so "one" action may in fact be composed of several atomic steps.
Config Programs are not directly accessible through the API, instead we use
ConfigBuilder
instances to manipulate and optionally/eventually execute
them.
Configuration Builder
The Configuration Builder is an API that allows you to construct and eventually execute configuration programs. It does so by using what I like to call "monadic composition", though the term may not be accurate by mathematical standards.
Arrows
In the ConfigBuilder
implementation you will find a group of
functions/methods being refered to as arrows, hinting to fact that they
either resamble Kleisli Arrows themselfs or are in fact higher-order functions
producing Kleisli Arrows. This is just a fancy term for a very simple thing: A
Kleisli Arrow is a function that takes a "plain" (i.e. non-monadic) value and
produces a monadic value.
In our case, the "arrows" are functions that take a plain configuration object
and return a new ConfigBuilder
. It's the type of function one would pass to
the .bind()
method. The API includes predefined implementations for what we
believe are very useful examples:
typePath
for modifying the plugin resolution pathload
andtryLoad
for merging configuration files into the configuration programadd
for programatically appending configuration directivesthen
for appending side effects
Since you would typically use those in conjunction with the .bind()
-method
anyway, the API has shortcut notations for exactly this. So for example
instead of builder.bind(load(someFileName))
, you can equivalently write
builder.load(someFileName))
.
Of course you can (and very often will) create your own, application-specific arrows. Think of the predefined ones as building-blocks to support that process.