1.2.0 • Published 3 years ago

@l.degener/irma-config v1.2.0

Weekly downloads
4
License
MirOS
Repository
gitlab
Last release
3 years ago

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 path

  • load and tryLoad for merging configuration files into the configuration program

  • add for programatically appending configuration directives

  • then 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.

1.2.0

3 years ago

1.1.0

3 years ago

1.0.1

3 years ago

1.0.0

4 years ago