Skip to content

Instantly share code, notes, and snippets.

@ssube
Created July 17, 2017 00:10
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ssube/8709d4ba49b53eb27114b921f3c67c4d to your computer and use it in GitHub Desktop.
Save ssube/8709d4ba49b53eb27114b921f3c67c4d to your computer and use it in GitHub Desktop.
fairly basic named DI for TS
import {Iterable, List, Map} from 'immutable';
import {isFunction, kebabCase} from 'lodash';
import {InstanceBoundError} from 'main/error/InstanceBoundError';
import {MissingValueError} from 'main/error/MissingValueError';
import {Dependency, Descriptor} from 'main/utility/inject/Dependency';
import {Injected} from 'main/utility/inject/Injected';
import {Factory, Module, ProviderType} from 'main/utility/inject/Module';
export const injectionSymbol = Symbol('inject');
export interface Constructor<R> {
new(...args: Array<any>): R;
}
// the contract for some type can be identified with...
export type Contract<R> = string | symbol | Constructor<R>;
export function contractName(c: Contract<any>): string {
if (isFunction(c)) {
return kebabCase(c.name);
} else {
return kebabCase(c.toString());
}
}
export function constructorName<T>(it: any): string {
const ctor = Reflect.getPrototypeOf(it).constructor as Constructor<T>;
return contractName(ctor);
}
/**
* Provider options.
*/
export interface BaseOptions {
container: Container;
}
/**
* This is an exceptionally minimal DI container.
*/
export class Container {
public static from(...modules: Array<Module>) {
return new Container(List(modules));
}
protected modules: List<Module>;
protected ready: boolean;
constructor(modules: List<Module>) {
this.modules = modules;
this.ready = false;
}
/**
* Configure each module, linking dependencies to their contracts and preparing factories.
*
* This must be done sequentially, since some modules may use classes from other modules.
*/
public async configure() {
for (const module of this.modules) {
await module.configure(this);
}
this.ready = true;
return this;
}
/**
* Returns the best provided value for the request contract.
*/
public async get<
TReturn extends Injected<TOptions>,
TOptions extends BaseOptions = BaseOptions
>(contract: Contract<TReturn>, options?: Partial<TOptions>): Promise<TReturn> {
if (!this.ready) {
throw new InstanceBoundError('container has not been configured yet');
}
const module = this.provides(contract);
if (module) {
const provider = module.get<TReturn>(contract);
if (provider.type === ProviderType.Constructor) {
return this.construct<TReturn, TOptions>(provider.value, options);
} else if (provider.type === ProviderType.Factory) {
return this.invoke<TReturn, TOptions>(provider.value, module, options);
} else if (provider.type === ProviderType.Instance) {
return provider.value;
} else {
throw new MissingValueError(`no known provider for contract: ${contractName(contract)}`);
}
} else if (isFunction(contract)) {
// @todo this shouldn't need a cast but detecting constructors is difficult
return this.construct<TReturn, TOptions>(contract as Constructor<TReturn>, options);
} else {
throw new MissingValueError(`no provider for contract: ${contractName(contract)}`);
}
}
public getModules(): List<Module> {
return this.modules;
}
/**
* Create a child container with additional modules.
*/
public with(...modules: Array<Module>): Container {
const merged = this.modules.concat(modules);
return new Container(merged.toList());
}
protected async construct<R, O>(ctor: Constructor<R>, options?: Partial<O>): Promise<R> {
const deps = await this.fulfill(ctor);
const args = {...deps, ...(options as any)}; // caught between a lint and a cast
return Reflect.construct(ctor, [args]);
}
protected async invoke<R, O>(factory: Factory<R>, thisArg: Module, options?: Partial<O>): Promise<R> {
const deps = await this.fulfill(factory);
const args = {...deps, ...(options as any)};
return Reflect.apply(factory, thisArg, [args]);
}
/**
* Prepare a map with the dependencies for a descriptor.
*
* This will always inject the container itself to configure children.
*/
protected async dependencies<O extends BaseOptions>(deps: Array<Dependency>): Promise<O> {
const options: Partial<O> = {};
options.container = this;
for (const dependency of deps) {
const {contract, name} = dependency;
const dep = await this.get(contract);
options[name] = dep;
}
return options as O;
}
/**
* This is a simple helper for the common `describe` and `dependencies` pair.
*/
protected async fulfill<O extends BaseOptions>(implementation: any): Promise<O> {
const descriptor = Reflect.get(implementation, injectionSymbol);
if (descriptor) {
return this.dependencies<O>(descriptor.dependencies);
} else {
return this.dependencies<O>([]);
}
}
protected provides<R>(contract: Contract<R>): Module | undefined {
return this.modules.find((item) => (!!item && item.has(contract)));
}
}
import {Contract} from 'main/utility/inject/Container';
export interface Dependency {
name: string;
contract: Contract<any>;
}
export interface Descriptor {
dependencies: Array<Dependency>;
}
import {BaseOptions} from 'main/utility/inject/Container';
/**
* Abstract base for any class that can be dependency injected.
*
* This works around a limitation in TS that prevents classes from fulfilling interface constructors.
*/
export abstract class Injected<O extends BaseOptions> {
constructor(options: O) {
/* noop */
}
}
import {Constructor, Container, Contract, contractName} from 'main/utility/inject/Container';
export enum ProviderType {
None,
Constructor,
Factory,
Instance
}
export type Factory<R> = (options: any) => Promise<R>;
export type Implementation<T> = Constructor<T> | Factory<T>;
export type Provider<R> = {
type: ProviderType.Constructor;
value: Constructor<R>;
} | {
type: ProviderType.Factory;
value: Factory<R>
} | {
type: ProviderType.Instance;
value: R;
};
export interface FluentBinding<I, R> {
toConstructor(implementation: Constructor<I>): R;
toFactory(factory: Factory<I>): R;
toInstance(instance: I): R;
}
/**
* Provides a set of dependencies, bound in the `configure` method.
*/
export abstract class Module {
protected providers: Map<string, Provider<any>>;
constructor() {
this.providers = new Map();
}
public abstract async configure(container: Container): Promise<void>;
public get<C>(contract: Contract<C>): Provider<C> {
const name = contractName(contract);
return this.providers.get(name) as Provider<C>;
}
/**
* Indicate if this module provides a dependency and if so, how.
*
* @todo Memoize this if performance becomes a problem.
*/
public has<C>(contract: Contract<C>): boolean {
const name = contractName(contract);
return this.providers.has(name);
}
/**
* Register a class as the provider for a particular contract. The class will be instantiated after having
* dependencies resolved, its parameters being the dependencies and any additional arguments passed to the container.
*/
public bind<C, I extends C>(contract: Contract<C>): FluentBinding<I, this> {
const name = contractName(contract);
return {
toConstructor: (constructor) => {
this.providers.set(name, {type: ProviderType.Constructor, value: constructor});
return this;
},
toFactory: (factory) => {
this.providers.set(name, {type: ProviderType.Factory, value: factory});
return this;
},
toInstance: (instance) => {
this.providers.set(name, {type: ProviderType.Instance, value: instance});
return this;
}
};
}
}
import {expect} from 'chai';
import {spy} from 'sinon';
import {Container} from 'main/utility/inject/Container';
import {Module} from 'main/utility/inject/Module';
const testModuleCount = 8; // the number of test modules to create
describe('injection container', () => {
it('should configure modules', async () => {
class TestModule extends Module {
public async configure() { /* noop */ }
}
const module = new TestModule();
spy(module, 'configure');
const container = Container.from(module);
await container.configure();
expect(module.configure).to.have.been.calledOnce;
});
it('should be created from some modules', async () => {
class TestModule extends Module {
public async configure() { /* noop */ }
}
const modules = Array(testModuleCount).fill(null).map(() => new TestModule());
const container = Container.from(...modules);
expect(container.getModules().toJS()).to.deep.equal(modules);
});
it('should be configured before being used', async () => {
class TestModule extends Module {
public async configure() { /* noop */ }
}
const modules = Array(testModuleCount).fill(null).map(() => new TestModule());
const container = Container.from(...modules);
expect(container.get(TestModule)).to.be.rejected;
});
it('should be extended with some modules', async () => {
class TestModule extends Module {
public async configure() { /* noop */ }
}
const modules = Array(testModuleCount).fill(null).map(() => new TestModule());
const container = Container.from(...modules);
const extension = Array(testModuleCount).fill(null).map(() => new TestModule());
const extended = container.with(...extension);
expect(extended.getModules().size).to.equal(testModuleCount * 2); // look ma, no magic numbers
expect(extended.getModules().toJS()).to.include.members(modules);
expect(extended.getModules().toJS()).to.include.members(extension);
});
it('should handle a module returning bad providers', async () => {
class TestModule extends Module {
public async configure(moduleContainer: Container) {
this.bind('d').toInstance({});
}
public get(contract: any): any {
return {
type: 'invalid',
value: null
};
}
}
const module = new TestModule();
const container = Container.from(module);
await container.configure();
expect(container.get('d')).to.be.rejected;
});
});
import {expect} from 'chai';
import {spy} from 'sinon';
import {BaseOptions, Container} from 'main/utility/inject/Container';
import {Module, ProviderType} from 'main/utility/inject/Module';
describe('injection modules', () => {
it('should be extendable', async () => {
class TestModule extends Module {
public async configure(moduleContainer: Container) { /* noop */ }
}
const module = new TestModule();
spy(module, 'configure');
const container = Container.from(module);
await container.configure();
expect(module.configure).to.have.been.calledOnce;
expect(module.configure).to.have.been.calledWith(container);
});
it('should report bindings', async () => {
class TestModule extends Module {
public async configure(moduleContainer: Container) {
this.bind('a').toConstructor(TestModule);
this.bind('b').toFactory(() => Promise.resolve(3));
this.bind('c').toInstance(1);
}
}
const module = new TestModule();
const container = Container.from(module);
await container.configure();
expect(module.has('a'), 'has a constructor').to.be.true;
expect(module.has('b'), 'has a factory').to.be.true;
expect(module.has('c'), 'has an instance').to.be.true;
expect(module.has('d'), 'does not have').to.be.false;
});
it('should get the same instance each time', async () => {
class TestModule extends Module {
public async configure(moduleContainer: Container) {
this.bind('c').toInstance({});
}
}
const module = new TestModule();
const container = Container.from(module);
await container.configure();
const check = await container.get('c');
const again = await container.get('c');
expect(check).to.equal(again);
});
it('should throw when the contract is missing', async () => {
class TestModule extends Module {
public async configure(moduleContainer: Container) {
this.bind('c').toInstance({});
}
}
const module = new TestModule();
const container = Container.from(module);
await container.configure();
expect(container.get('d')).to.be.rejected;
});
it('should convert contract names', async () => {
class TestClass { /* noop */ }
class TestModule extends Module {
public async configure(moduleContainer: Container) {
this.bind(TestClass).toConstructor(TestClass);
}
}
const module = new TestModule();
const container = Container.from(module);
await container.configure();
expect(module.has('test-class'), 'has a constructor').to.be.true;
});
it('should invoke complex factories', async () => {
class TestInstance { }
let instance: TestInstance;
class TestModule extends Module {
public async configure(moduleContainer: Container) {
this.bind('a').toFactory(async (options) => this.getInstance(options));
}
public async getInstance(options: BaseOptions): Promise<TestInstance> {
if (!instance) {
instance = await options.container.get(TestInstance);
}
return instance;
}
}
const module = new TestModule();
spy(module, 'getInstance');
const container = Container.from(module);
await container.configure();
const ref = await container.get('a');
expect(module.has('a')).to.be.true;
expect(module.get('a').type).to.equal(ProviderType.Factory);
expect(module.getInstance).to.have.been.calledOnce;
expect(module.getInstance).to.have.been.calledWith({container});
expect(ref, 'return the same instance').to.equal(await container.get('a'));
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment