Skip to content

Commit

Permalink
Add support for property dependency injection (#292)
Browse files Browse the repository at this point in the history
  • Loading branch information
raymondfeng committed May 25, 2017
1 parent 3e25c78 commit 8ae39b0
Show file tree
Hide file tree
Showing 5 changed files with 349 additions and 17 deletions.
60 changes: 49 additions & 11 deletions packages/context/src/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,15 @@

import * as assert from 'assert';
import 'reflect-metadata';
import {BoundValue} from './binding';

const REFLECTION_KEY = 'loopback.inject';
const REFLECTION_CDI_KEY = 'loopback:inject:constructor';
const REFLECTION_PDI_KEY = 'loopback:inject:properties';

export interface Injection {
bindingKey: string;
metadata?: { [attribute: string]: BoundValue; };
}

/**
* A decorator to annotate method arguments for automatic injection
Expand All @@ -16,6 +23,8 @@ const REFLECTION_KEY = 'loopback.inject';
*
* ```ts
* class InfoController {
* @inject('authentication.user') public userName: string;
*
* constructor(@inject('application.name') public appName: string) {
* }
* // ...
Expand All @@ -26,19 +35,48 @@ const REFLECTION_KEY = 'loopback.inject';
*
* - TODO(bajtos)
*
* @param bindingKey What binding to use in order to resolve the value
* of the annotated argument.
* @param bindingKey What binding to use in order to resolve the value of the
* decorated constructor parameter or property.
* @param metadata Optional metadata to help the injection
*
*/
export function inject(bindingKey: string) {
return function markArgumentAsInjected(target: Object, propertyKey: string | symbol, parameterIndex: number) {
assert(parameterIndex != undefined, '@inject decorator can be used on function arguments only!');
export function inject(bindingKey: string, metadata?: Object) {
// tslint:disable-next-line:no-any
return function markArgumentAsInjected(target: any, propertyKey?: string | symbol,
propertyDescriptorOrParameterIndex?: TypedPropertyDescriptor<BoundValue> | number) {

const injectedArgs: string[] = Reflect.getOwnMetadata(REFLECTION_KEY, target, propertyKey) || [];
injectedArgs[parameterIndex] = bindingKey;
Reflect.defineMetadata(REFLECTION_KEY, injectedArgs, target, propertyKey);
if (typeof propertyDescriptorOrParameterIndex === 'number') {
// The decorator is applied to a method parameter
// Please note propertyKey is `undefined` for constructor
const injectedArgs: Injection[] =
Reflect.getOwnMetadata(REFLECTION_CDI_KEY, target, propertyKey!) || [];
injectedArgs[propertyDescriptorOrParameterIndex] = {bindingKey, metadata};
Reflect.defineMetadata(REFLECTION_CDI_KEY, injectedArgs, target, propertyKey!);
} else if (propertyKey) {
// The decorator is applied to a property
const injections: { [p: string]: Injection } =
Reflect.getOwnMetadata(REFLECTION_PDI_KEY, target) || {};
injections[propertyKey] = {bindingKey, metadata};
Reflect.defineMetadata(REFLECTION_PDI_KEY, injections, target);
} else {
throw new Error('@inject can be used on properties or method parameters.');
}
};
}

export function describeInjectedArguments(target: Function): string[] {
return Reflect.getOwnMetadata(REFLECTION_KEY, target) || [];
/**
* Return an array of injection objects for constructor parameters
* @param target The target class
*/
export function describeInjectedArguments(target: Function): Injection[] {
return Reflect.getOwnMetadata(REFLECTION_CDI_KEY, target) || [];
}

/**
* Return a map of injection objects for properties
* @param target The target class. Please note a property decorator function receives
* the target.prototype
*/
export function describeInjectedProperties(target: Function): { [p: string]: Injection } {
return Reflect.getOwnMetadata(REFLECTION_PDI_KEY, target.prototype) || {};
}
66 changes: 62 additions & 4 deletions packages/context/src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { Context } from './context';
import { Binding, BoundValue } from './binding';
import { isPromise } from './isPromise';
import { describeInjectedArguments } from './inject';
import { describeInjectedArguments, describeInjectedProperties } from './inject';

// tslint:disable-next-line:no-any
export type Constructor<T> = new(...args: any[]) => T;
Expand All @@ -23,10 +23,33 @@ export type Constructor<T> = new(...args: any[]) => T;
*/
export function instantiateClass<T>(ctor: Constructor<T>, ctx: Context): T | Promise<T> {
const argsOrPromise = resolveInjectedArguments(ctor, ctx);
const propertiesOrPromise = resolveInjectedProperties(ctor, ctx);
let inst: T | Promise<T>;
if (isPromise(argsOrPromise)) {
return argsOrPromise.then(args => new ctor(...args));
// Instantiate the class asynchronously
inst = argsOrPromise.then(args => new ctor(...args));
} else {
return new ctor(...argsOrPromise);
// Instantiate the class synchronously
inst = new ctor(...argsOrPromise);
}
if (isPromise(propertiesOrPromise)) {
return propertiesOrPromise.then((props) => {
if (isPromise(inst)) {
// Inject the properties asynchrounously
return inst.then(obj => Object.assign(obj, props));
} else {
// Inject the properties synchrounously
return Object.assign(inst, props);
}
});
} else {
if (isPromise(inst)) {
// Inject the properties asynchrounously
return inst.then(obj => Object.assign(obj, propertiesOrPromise));
} else {
// Inject the properties synchrounously
return Object.assign(inst, propertiesOrPromise);
}
}
}

Expand All @@ -52,7 +75,7 @@ export function resolveInjectedArguments(fn: Function, ctx: Context): BoundValue
let asyncResolvers: Promise<void>[] | undefined = undefined;

for (let ix = 0; ix < fn.length; ix++) {
const bindingKey = injectedArgs[ix];
const bindingKey = injectedArgs[ix].bindingKey;
if (!bindingKey) {
throw new Error(
`Cannot resolve injected arguments for function ${fn.name}: ` +
Expand All @@ -75,3 +98,38 @@ export function resolveInjectedArguments(fn: Function, ctx: Context): BoundValue
return args;
}
}

export type KV = { [p: string]: BoundValue };

export function resolveInjectedProperties(fn: Function, ctx: Context): KV | Promise<KV> {
const injectedProperties = describeInjectedProperties(fn);

const properties: KV = {};
let asyncResolvers: Promise<void>[] | undefined = undefined;

const propertyResolver = (p: string) => ((v: BoundValue) => properties[p] = v);

for (const p in injectedProperties) {
const bindingKey = injectedProperties[p].bindingKey;
if (!bindingKey) {
throw new Error(
`Cannot resolve injected property for class ${fn.name}: ` +
`The property ${p} was not decorated for dependency injection.`);
}
const binding = ctx.getBinding(bindingKey);
const valueOrPromise = binding.getValue(ctx);
if (isPromise(valueOrPromise)) {
if (!asyncResolvers) asyncResolvers = [];
asyncResolvers.push(valueOrPromise.then(propertyResolver(p)));
} else {
properties[p] = valueOrPromise as BoundValue;
}
}

if (asyncResolvers) {
return Promise.all(asyncResolvers).then(() => properties);
} else {
return properties;
}
}

36 changes: 36 additions & 0 deletions packages/context/test/acceptance/class-level-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,42 @@ describe('Context bindings - Injecting dependencies of classes', () => {
expect(valueOrPromise as InfoController).to.have.property('appName', 'CodeHub');
});

it('resolves promises before injecting properties', async () => {
ctx.bind('authenticated').toDynamicValue(async () => {
// Emulate asynchronous database call
await Promise.resolve();
// Return the authentication result
return false;
});

class InfoController {
@inject('authenticated')
public isAuthenticated: boolean;
}
ctx.bind(INFO_CONTROLLER).toClass(InfoController);

const instance = await ctx.get(INFO_CONTROLLER);
expect(instance).to.have.property('isAuthenticated', false);
});

it('creates instance synchronously when property/constructor dependencies are sync too', () => {
ctx.bind('appName').to('CodeHub');
ctx.bind('authenticated').to(false);
class InfoController {
constructor(@inject('appName') public appName: string) {
}

@inject('authenticated')
public isAuthenticated: boolean;
}
const b = ctx.bind(INFO_CONTROLLER).toClass(InfoController);

const valueOrPromise = b.getValue(ctx);
expect(valueOrPromise).to.not.be.Promise();
expect(valueOrPromise as InfoController).to.have.property('appName', 'CodeHub');
expect(valueOrPromise as InfoController).to.have.property('isAuthenticated', false);
});

function createContext() {
ctx = new Context();
}
Expand Down
31 changes: 29 additions & 2 deletions packages/context/test/unit/inject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// License text available at https://opensource.org/licenses/MIT

import {expect} from '@loopback/testlab';
import {inject, describeInjectedArguments} from '../../src/inject';
import {inject, describeInjectedArguments, describeInjectedProperties} from '../../src/inject';

describe('function argument injection', () => {
it('can decorate class constructor arguments', () => {
Expand All @@ -22,7 +22,7 @@ describe('function argument injection', () => {
}

const meta = describeInjectedArguments(TestClass);
expect(meta).to.deepEqual(['foo']);
expect(meta.map(m => m.bindingKey)).to.deepEqual(['foo']);
});

it('returns an empty array when no ctor arguments are decorated', () => {
Expand All @@ -35,3 +35,30 @@ describe('function argument injection', () => {
expect(meta).to.deepEqual([]);
});
});

describe('property injection', () => {
it('can decorate properties', () => {
class TestClass {
@inject('foo') foo: string;
}
// the test passes when TypeScript Compiler is happy
});

it('can retrieve information about injected properties', () => {
class TestClass {
@inject('foo') foo: string;
}

const meta = describeInjectedProperties(TestClass);
expect(meta.foo.bindingKey).to.eql('foo');
});

it('returns an empty object when no properties are decorated', () => {
class TestClass {
foo: string;
}

const meta = describeInjectedProperties(TestClass);
expect(meta).to.deepEqual({});
});
});
Loading

0 comments on commit 8ae39b0

Please sign in to comment.