bootwire

2.0.3 • Public • Published

bootwire

Build Status codecov

Application and dependencies bootstrap for node.js.

npm install --save bootwire

A super-minimal way to boot and compose application dependencies using ES6.

Bootwire is a very simple library that leverages ES6 destructuring to provide a no-black-magick-please way to boot node js applications and perform dependency injection.

Features

  • Support asynchronous boot (ie. wait for database connection to be ready).
  • Simplify tests allowing to mock parts of your application without stub and spies.
  • Predictable top-down flow.
  • IoC with plain old functions and constructors.
  • No require hijacking.
  • No module.exports['@something-magick-and-weird-here']
  • Wire components together not to the IoC container

Getting Started

Bootwire provides a way to create an application context and pass it down to a boot procedure.

The context object is just an object that exposes a few methods to manipulate and use it:

  • $set: set one or many properties
  • $provide: set a property to the result of the invocation of a provider function.
  • $wire: wire a function passing the context as parameter
  • $wireGlob: wire any files matching a glob pattern
  • $waitFor: await for specific dependencies to be wired
  • $get: get a value in the context by key or by path

Using $set and $provide on the context object will ensure that all of its properties will be only set once, allowing to inject providers, services connections, configs and so on during tests.

The boot procedure to which the context is passed is a function that acts as the single starting point of an application.

Dependency injection

As opposed to many IoC containers bootwire takes a radical approach to handle dependencies:

  • Dependencies resolution is not lazy: all of the components are wired together during the boot phase.
  • Dependency injection follows one and only one simple rule: if a dependency is already set it will not be set again.

Which result into an extremely simple way to replace a component or a setting during tests: just set it before the boot phase.

// index.js
 
require('./app').boot().catch(console.error);
// app.js
 
const bootwire = require('bootwire');
 
function bootProcedure({$provide, $set, $wire} /* this is the context object destructured */) {
  $set({
    logger: require('winston'),
    config: require('./config')
  });
 
  await $provide('db', async function({config}) {
    return await MongoClient.connect(config.mongodbUrl);
  });
 
  await $provide('userRepository', async function({db}) {
    return new UserRepository({db});
  });
 
  await $wire(startExpress);
}
 
module.exports = bootwire(bootProcedure);
// user.routes.js
 
module.exports = function({router, userRepository}) {
 
  router.get('/users', async function(req, res) {
    res.json(await userRepository.find());
  });
 
};

Integration tests are now extremely easy:

// app.spec.js
const app = require('./app');
 
it('does something', async function() {
  await app.boot({
      config: {port: await randomAvailablePort()},
      db: fakeMongodb // db will not be set during the boot
                      // since is already set here
  });
 
  // ...
});

And unit tests as well:

const UserRepository = require('./services/UserRepository');
 
it('retrieves all the users', async function() {
  const repo = new UserRepository({db: {
    find() {
      return Promise.resolve(usersFixture);
    }
  }});
 
  deepEqual(await repo.find(), expectedUsers);
});

The boot procedure also accepts multiple initial contexts that will be merged together, doing so will be easy to $provide a default initial context on each tests and override it on each test case:

// app.spec.js
const app = require('./app');
 
const defaultTestContext = {
  config: defaultConfig
};
 
it('does something', async function() {
  await app.boot(defaultTestContext,
    {
      config: {port: await randomAvailablePort()},
      db: fakeMongodb
    }
  );
 
  // ...
});

Usage patterns for complex applications

Split bootstrap into phases

// ./boot/index.js
 
const {promisify} = require('util');
const express = require('express');
const winston = require('winston');
 
module.exports = async function({$wireGlob, $set, $context}) {
  const config = require('./config');
  const app = express();
  const logger = winston;
 
  $set({
    config,
    app,
    logger
  });
 
  await $wireGlob('./services/**/*.wire.js');
  await $wireGlob('./middlewares/**/*.wire.js');
  await $wireGlob('./routes/**/*.wire.js');
 
  await promisify(app.listen)(config.port);
  logger(`Application running on port ${config.port}`);
};

Top Down $wireGlob

$wireGlob never process a file twice and ensure files are always processed in depth order from the most generic path to the deepest.

It can be leveraged to delegate complex wiring from a general boot file to more specialized procedures.

// ./index.js
 
const {promisify} = require('util');
const express = require('express');
 
module.exports = async function({$wireGlob, $set, $context}) {
  const app = express();
 
  $set({
    app
  });
 
  await $wireGlob('./routes/**/*.wire.js');
  await promisify(app.listen)(config.port);
};
// ./routes/wire.js
 
module.exports = async function({$wireGlob, $set, $context}) {
  await $wireGlob('./middlewares/**/*.middeware.js');
  await $wireGlob('./api/**/wire.js'); // NOTE: this will be processed only once
                                       // and from this file even if the path
                                       // matches also the glob from the call in
                                       // ./index.js
};

Bootstrap of many components

Using $wireGlob and $waitFor is possible to create self contained modules that can be wired together without having a main boot procedure knowing about everything.

// ./boot/index.js
 
module.exports = async function({$wireGlob}) {
  await $wireGlob('./*.wire.js');
};
// ./boot/logger.wire.js
 
module.exports = async function({$waitFor, $set}) {
  const {correlator} = await $waitFor('correlator');
 
  $set('logger', new CorrelationLogger(correlator));
};
// ./boot/correlator.wire.js
 
module.exports = function({$set}) {
  $set('correlator', new ZoneCorrelator());
};

Wiring classes and services

One way to perform IoC without any magic container is to use explicitly the constructor of services to inject dependencies.

Although it may seem a tight constraint it is actually a good way to create independent components that are easy to reuse in different context and applications.

This explicit and manual injection is intended and is necessary to achieve one of the goal of bootwire: don't require components to depend on the dependency injection framework.

// services/UserRepository.js
 
class UserRepository {
  constructor({db}) {
    this.collection = db.collection('users');
  }
 
  find() {
    return this.collection.find().toArray();
  }
}

Note how the UserRepository class is completely usable both with bootwire:

// boot/index.js
 
module.exports = function({$provide}) {
  await $provide('db', async function({config}) {
    return await MongoClient.connect(config.mongodbUrl);
  });
 
  await $provide('userRepository', async function({db}) {
    return new UserRepository({db});
  });
};

And without bootwire:

// tasks/dumpUsers.js
 
async main() {
  const db = await MongoClient.connect(process.env.MONGODB_URL);
  const repo = UserRepository({db});
  const users = await repo.find();
  console.info(JSON.stringify(users, null, 2));
}
 
main().catch(console.error);

Api

Classes

App : Object

App is a bootable application.

Context : Object

Context is the main application context object. It acts as dependency container and is intended to be passed down through all the initialization procedure.

Functions

bootwire(bootAndWireFn)App

Build a new App that will use invoke the boot and $wire procedure passed as parameter on boot.

Example usage:

const bootwire = require('bootwire');
const app = bootwire(require('./src/boot'));

if (require.main === module) { app.boot() .catch((err) => { // Something extremely bad happened while booting console.error(err); process.exit(1); }); }

module.exports = app;

Example tests:

const app = require('../..');

describe('app', function() { it('runs', async function() { const port = await getRandomPort();

await app.boot({ config: { port } });

await request('http://localhost:${port}/health'); // ... }); });

App : Object

App is a bootable application.

Kind: global class

app.boot(...initialContext) ⇒ Promise

Start an application with an initialContext

Kind: instance method of App
Returns: Promise - A promise resolving to Context when the boot procedure will complete.

Param Type Description
...initialContext Object One or more object to be merged in the context and build the initialContext. Note that any function already present in the prototype of Context (ie. $wire, $set, $provide) will NOT be overriden.

Context : Object

Context is the main application context object. It acts as dependency container and is intended to be passed down through all the initialization procedure.

Kind: global class

context.$context ⇒ Context

Returns the same context instance.

Useful in factory and provider functions to destructure both the context and its internal properties.

ie.

module.exports = function setupRoutes({app, context}) {
 // NOTE: config === context.config
 
 app.get('/users', require('./users.routes')(context));
}

Kind: instance property of Context
Returns: Context - the context object itself

context.$set(keyOrObject, value)

$set sets one or more keys in the context if they are not already present.

ie.

$set('logger', winston);
$set({
  config: require('./config'),
  logger: winston
});

Kind: instance method of Context

Param Type Description
keyOrObject String | Object a string key in case of single assignment or a key-value map in case of multiple assignment.
value Any the value to be assigned in case a string key is provided.

context.$provide(key, fn) ⇒ Promise

$provide allows to assign to a contpext key the result of a function (provider) that is invoked with context as parameter.

If the context key is already taken the $provide returns without doing anything.

The function to be evaluated can be synchronous or asynchronous. In either cases $provide returns a Promise to wait for to be sure the assignment took place (or has been rejected).

Kind: instance method of Context
Returns: Promise - a promise that will be resolved once $provide has completed the assignment or refused to assign.

Param Type Description
key String the key to be assigned
fn function the function to be evaluated. Context will be passed as param to this function.

context.$wire(...fns) ⇒ Promise

$wire invokes one or more asynchronous function passing the context as first parameter.

Kind: instance method of Context
Returns: Promise - a promise that will be resolved once fn will complete.

Param Type Description
...fns function the function or functions to be evaluated. Context will be passed as param.

context.$wireGlob(...patterns) ⇒ Promise

$wireGlob requires and wires files by patterns from the caller folder.

ie.

await $wireGlob('routes/*.wire.js');

Kind: instance method of Context
Returns: Promise - A promise that will be resolved once all the files are required and wired

Param Type Description
...patterns String One or more pattern expression (see https://github.com/isaacs/minimatch#usage for help) NOTE: path patterns are relative to the caller file and not to process.cwd()

context.$waitFor(...keys) ⇒ Promise

$waitFor wait for the resolution of the dependencies passed as argument and then it returns the context;

const {logger} = await $waitFor('logger');

Kind: instance method of Context
Returns: Promise - A promise resolving to the context once all the dependencies are ready

Param Type Description
...keys String A list of dependencies to be awaited

context.$get(key, [defaultValue]) ⇒ Any

Get a value from context by key or path.

const context = await app.boot();
 
const port = context.get('config.port');
const info = await request(`http://localhost:${port}/api/info`);
// ...

Kind: instance method of Context
Returns: Any - the value if found or defaultValue.

Param Type Description
key String a single key or a path of the form 'key1.key2.key3'.
[defaultValue] Any a value to be returned if the key is not found.

bootwire(bootAndWireFn) ⇒ App

Build a new App that will use invoke the boot and $wire procedure passed as parameter on boot.

Example usage:

const bootwire = require('bootwire');
const app = bootwire(require('./src/boot'));
 
if (require.main === module) {
  app.boot()
    .catch((err) => {
      // Something extremely bad happened while booting
      console.error(err);
      process.exit(1);
    });
}
 
module.exports = app;

Example tests:

const app = require('../..');
 
describe('app', function() {
  it('runs', async function() {
   const port = await getRandomPort();
 
   await app.boot({
     config: { port }
   });
 
   await request('http://localhost:${port}/health');
   // ...
  });
});

Kind: global function
Returns: App - A bootable App instance.

Param Type Description
bootAndWireFn function The function to be called.

Readme

Keywords

none

Package Sidebar

Install

npm i bootwire

Weekly Downloads

0

Version

2.0.3

License

UNLICENSED

Unpacked Size

40.8 kB

Total Files

29

Last publish

Collaborators

  • mcasimir