Created
July 17, 2017 00:10
-
-
Save ssube/8709d4ba49b53eb27114b921f3c67c4d to your computer and use it in GitHub Desktop.
fairly basic named DI for TS
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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))); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {Contract} from 'main/utility/inject/Container'; | |
export interface Dependency { | |
name: string; | |
contract: Contract<any>; | |
} | |
export interface Descriptor { | |
dependencies: Array<Dependency>; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 */ | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
}; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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