1.2.2 • Published 3 years ago

@burgrp/appglue v1.2.2

Weekly downloads
-
License
CC BY 4.0
Repository
-
Last release
3 years ago

appglue

Simple dependency injection for Node.js.

Purpose

The appglue library removes typical Node.js hardcoded references caused by require() or import. This is achieved by "inverting the control" in a way, that modules are wired together by simple JSON file.

The JSON file describes references between modules and may also inject environment variables to further help with application configuration.

Although the name of the JSON file may be overridden, we will refer to this file as config.json as it is the default name.

API

The library takes the config.json, resolves all the module references and returns an object which is the application context.

The context may be returned by asynchronous method load e.g.:

let context = await require("@burgrp/appglue")({require}).load();

Or there is handy function main to simplify application startup code:

require("@burgrp/appglue")({require}).main(async context => {
    // do something with context
});

The {require} parameter is mandatory. Passing the caller's require reference is needed to properly resolve modules.

There is also an optional parameter file, which is the name of configuration JSON. As mentioned above, this defaults to config.json. Good practice is to initialize appglue with {require, file: __dirname + "/config.json"}, which makes the application independent of current working directory.

Hello world

Let's start with an artificial example - a simple application which consist of two JS modules: main and greeter. The greeter will export a function greet(who).

The config.json looks like:

{
    "greeter": {
        "module": "./greeter.js"
    }
}

The top level module main.js looks like:

require("@burgrp/appglue")({require}).main(context => {
    context.greeter.greet("Joe");
});

And the greeter module greeter.js looks like:

module.exports = context => {
    return {
        greet(who) {
            console.info(`Hello ${who}!`)
        }
    }
}

Note that the greeter module is a factory function, which returns an object. This is appglue idiom. The returned value, in this case the greeter object, is the resolved value inserted into application context. The context parameter is the nested, and already resolved, context inside the module.

Module parameters

In our simple example the context parameter will be empty, but what about to pass some parameters to greeter?

Then we add greeting property to the config.json:

{
    "greeter": {
        "module": "./greeter.js",
        "greeting": "Hello"
    }
}

And use that property in greeter.js:

module.exports = ({greeting}) => {
    return {
        greet(who) {
            console.info(`${greeting} ${who}!`)
        }
    }
}

Reusing modules

Now imagine we want to have two greeters, one formal, one informal.

We would change config.json to have two greeters:

{
    "formal": {
        "module": "./greeter.js",
        "greeting": "Good morning"
    },
    "informal": {
        "module": "./greeter.js",
        "greeting": "Howdy"
    }
}

And then we can refer both in main.js:

require("@burgrp/appglue")({require}).main(({formal, informal}) => {
    formal.greet("Mr. Novak");
    informal.greet("Joe");
});

In-context references

One module may get reference to another part of the context, if it was already resolved (i.e. referenced context must precede the referring context).

For example, we want to have a new module responsible for writing the string to console.

So we add the new module to config.json and add references:

{
    "writer": {
        "module": "./writer.js"
    },
    "formal": {
        "module": "./greeter.js",
        "greeting": "Good morning",
        "writer": "-> writer"
    },
    "informal": {
        "module": "./greeter.js",
        "greeting": "Howdy",
        "writer": "-> writer"
    }
}

The new writer.js module would look like:

module.exports = () => {
    return {
        write(str) {
            console.info(str);
        }
    }
}

Modified greeter.js like:

module.exports = ({greeting, writer}) => {
    return {
        greet(who) {
            writer.write(`${greeting} ${who}!`)
        }
    }
}

Note that anything behind -> is normal js code, evaluated in the already resolved context, so it may be more complex expression than just -> writer.

Environment variables

Environment variables are available in evaluation expression (->) either directly with $ prefix, or as map named $. This means that e.g. environment variable HOME is available by one of two ways:

  • $TEST
  • $.TEST

The difference is that $TEST makes the reference mandatory and initialization will fail, if TEST environment variable is undefined. Since $ is map of all environment variables and is always defined, $.TEST resolves to undefined but does not fail.

One could also reference environment variable with default value by -> $.TEST || 'my-default-value'.

In our example, if we want to make both greetings configurable, we change config.json to:

{
    "writer": {
        "module": "./writer.js"
    },
    "formal": {
        "module": "./greeter.js",
        "greeting": "-> $.FORMAL_GREETING || 'Good morning'",
        "writer": "-> writer"
    },
    "informal": {
        "module": "./greeter.js",
        "greeting": "-> $.INFORMAL_GREETING || 'Howdy'",
        "writer": "-> writer"
    }
}

This way we made our example configurable by environment variables without touching JS code itself. We can see application structure, environment references and defaults values on sight.

Late references

References by -> are resolved on the first pass which leads to restriction, that only already resolved parts of the context may be resolved. As the context is resolved recursively from top to bottom, one can reference only those parts of the context, which precede the reference. In our example, the reference -> writer would not work, if modules are listed in config.json in order formal, informal, writer.

To overcome this restriction, we may use so called late reference. Late reference is prefixed with => instead of -> and the reference resolves to parameter-less function (aka getter), which returns the reference value, when called.

If we would need, in our example, to put writer behind formal and informal, our config.json looks like:

{
    "formal": {
        "module": "./greeter.js",
        "greeting": "-> $.FORMAL_GREETING || 'Good morning'",
        "getWriter": "=> writer"
    },
    "informal": {
        "module": "./greeter.js",
        "greeting": "-> $.INFORMAL_GREETING || 'Howdy'",
        "getWriter": "=> writer"
    },
    "writer": {
        "module": "./writer.js"
    }
}

And because of late reference we get the getter instead of immediate value, we would need to change greeter.js to:

module.exports = ({greeting, getWriter}) => {
    return {
        greet(who) {
            getWriter().write(`${greeting} ${who}!`)
        }
    }
}

Type of the value returned by module function

In our example modules always return an object with functions. This resembles library style. But module function may return value of any type, including number, string, single function or array.

Nested modules

In our example we had only three modules, defined on the same level. Note that since modules are resolved recursively, they may be nested as needed. Any nested module becomes the owner's module initialization parameter.

Mixing constant objects and modules in context

Since the context is JSON structure, modules may be inserted to any level in the structure. The only key to identify the module is the module string property. If there is module property, the object is resolved as module. If there is no module property, the object is passed as-is to the context.

Await / async

Appglue fully supports asynchronous coding style, so module function may be async. Also the function passed to main(fnc) function may be async.

License

Licensed under Creative Commons Attribution 4.0 International (CC BY 4.0).