Commit 14a3bba1 authored by Robert Knight's avatar Robert Knight

Implement a simple dependency injection container

Implement a dependency injection / Inversion of Control container that
can be used to replace Angular's dependency injection.
parent 9d117094
/* global Map */
function isClass(functionOrClass) {
return functionOrClass.name.match(/^[A-Z]/);
}
/**
* `Injector` is a dependency injection container.
*
* It provides a convenient way to instantiate a set of named objects with
* dependencies. Objects are constructed using a factory function or
* class constructor. The factory function or constructor may have dependencies
* which are indicated by a `$inject` property on the function/class which
* is a list of the names of the dependencies. The `$inject` property can be
* added manually or by a compiler plugin (eg. `babel-plugin-angularjs-annotate`).
*
* To construct an object, call the `register` method with the name and factory
* function/class for the object and each of its dependencies, and then call
* the `get` method to construct or return the existing object with a given name,
* along with all of its dependencies.
*/
export class Injector {
constructor() {
// Map of name to factory function that creates an instance or class used
// as prototype for instance.
this._factories = new Map();
// Map of name to existing instance.
this._instances = new Map();
// Set of instances already being constructed. Used to detect circular
// dependencies.
this._constructing = new Set();
}
/**
* Construct or return the existing instance of an object with a given `name`
*
* @param {string} name - Name of object to construct
* @return {any} - The constructed object
*/
get(name) {
if (this._instances.has(name)) {
return this._instances.get(name);
}
const factory = this._factories.get(name);
if (!factory) {
throw new Error(`"${name}" is not registered`);
}
if (this._constructing.has(name)) {
throw new Error(
`Encountered a circular dependency when constructing "${name}"`
);
}
this._constructing.add(name);
try {
const resolvedDependencies = [];
const dependencies = factory.$inject || [];
for (const dependency of dependencies) {
try {
resolvedDependencies.push(this.get(dependency));
} catch (e) {
const resolveErr = new Error(
`Failed to construct dependency "${dependency}" of "${name}": ${e.message}`
);
resolveErr.cause = e;
throw resolveErr;
}
}
let instance;
if (isClass(factory)) {
// eslint-disable-next-line new-cap
instance = new factory(...resolvedDependencies);
} else {
instance = factory(...resolvedDependencies);
}
this._instances.set(name, instance);
return instance;
} finally {
this._constructing.delete(name);
}
}
/**
* Register a factory with a given name.
*
* If `factory`'s name starts with an upper-case letter it is treated as a
* class. If it starts with a lower-case letter it is treated as a factory
* function, which may return any type of value.
*
* @param {string} name - Name of object
* @param {() => any} factory -
* A function that constructs the service, or a class that will be instantiated
* when the object is requested.
*/
register(name, factory) {
this._factories.set(name, factory);
return this;
}
}
import { Injector } from '../injector';
describe('Injector', () => {
describe('#get', () => {
it('calls factory function to create instance', () => {
const instance = {};
const factory = sinon.stub().returns(instance);
const container = new Injector();
container.register('service', factory);
const constructed = container.get('service');
assert.equal(constructed, instance);
});
it('calls constructor to create instance', () => {
class Foo {}
const container = new Injector();
container.register('foo', Foo);
const constructed = container.get('foo');
assert.instanceOf(constructed, Foo);
});
it('returns the existing instance if already constructed', () => {
const instance = {};
const factory = sinon.stub().returns(instance);
const container = new Injector();
container.register('service', factory);
container.get('service');
const constructed = container.get('service');
assert.equal(constructed, instance);
assert.calledOnce(factory);
});
it('resolves dependencies', () => {
const container = new Injector();
const foo = {};
const bar = {};
const fooFactory = sinon.stub().returns(foo);
const barFactory = sinon.stub().returns(bar);
const bazFactory = (foo, bar) => ({ foo, bar });
bazFactory.$inject = ['foo', 'bar'];
container.register('foo', fooFactory);
container.register('bar', barFactory);
container.register('baz', bazFactory);
const baz = container.get('baz');
assert.equal(baz.foo, foo);
assert.equal(baz.bar, bar);
});
it('resolves transitive dependencies', () => {
const container = new Injector();
const addDeps = (factory, dependencies) => {
factory.$inject = dependencies;
return factory;
};
container.register('a', () => 'a');
container.register(
'b',
addDeps(a => a + 'b', ['a'])
);
container.register(
'c',
addDeps((b, a) => b + 'c' + a, ['b', 'a'])
);
assert.equal(container.get('c'), 'abca');
});
it('throws an error if factory is not registered', () => {
const container = new Injector();
assert.throws(() => {
container.get('invalid');
}, '"invalid" is not registered');
});
it('throws an error if dependency is not registered', () => {
const container = new Injector();
const fooFactory = () => 42;
fooFactory.$inject = ['bar'];
container.register('foo', fooFactory);
assert.throws(() => {
container.get('foo');
}, 'Failed to construct dependency "bar" of "foo"');
});
it('throws an error if a circular dependency is encountered', () => {
const container = new Injector();
const fooFactory = () => 42;
fooFactory.$inject = ['foo'];
container.register('foo', fooFactory);
let err;
try {
container.get('foo');
} catch (e) {
err = e;
}
assert.instanceOf(err, Error);
assert.equal(
err.toString(),
'Error: Failed to construct dependency "foo" of "foo": Encountered a circular dependency when constructing "foo"'
);
assert.instanceOf(err.cause, Error);
assert.equal(
err.cause.toString(),
'Error: Encountered a circular dependency when constructing "foo"'
);
});
});
describe('#register', () => {
it('returns container for chaining', () => {
const container = new Injector();
assert.equal(
container.register('foo', () => 42),
container
);
});
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment