Skip to content

Commit

Permalink
feat(context): Add support for method dependency injection
Browse files Browse the repository at this point in the history
Add `invokeMethod` to invoke a prototype method of a given class
with dependency injection

Add support for static methods

Allow non-injected args
  • Loading branch information
Raymond Feng authored and raymondfeng committed Nov 11, 2017
1 parent c506b26 commit df1c879
Show file tree
Hide file tree
Showing 9 changed files with 375 additions and 42 deletions.
2 changes: 1 addition & 1 deletion packages/context/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export {Provider} from './provider';
export {isPromise} from './is-promise';

// internals for testing
export {instantiateClass} from './resolver';
export {instantiateClass, invokeMethod} from './resolver';
export {
describeInjectedArguments,
describeInjectedProperties,
Expand Down
2 changes: 1 addition & 1 deletion packages/context/src/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ function resolveAsSetter(ctx: Context, injection: Injection) {
* Return an array of injection objects for parameters
* @param target The target class for constructor or static methods,
* or the prototype for instance methods
* @param methodName Method name, undefined for constructor
* @param method Method name, undefined for constructor
*/
export function describeInjectedArguments(
// tslint:disable-next-line:no-any
Expand Down
90 changes: 74 additions & 16 deletions packages/context/src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
describeInjectedProperties,
Injection,
} from './inject';
import * as assert from 'assert';

/**
* A class constructor accepting arbitrary arguments.
Expand All @@ -28,12 +29,15 @@ export type Constructor<T> =
*
* @param ctor The class constructor to call.
* @param ctx The context containing values for `@inject` resolution
* @param nonInjectedArgs Optional array of args for non-injected parameters
*/
export function instantiateClass<T>(
ctor: Constructor<T>,
ctx: Context,
// tslint:disable-next-line:no-any
nonInjectedArgs?: any[],
): T | Promise<T> {
const argsOrPromise = resolveInjectedArguments(ctor, ctx);
const argsOrPromise = resolveInjectedArguments(ctor, ctx, '');
const propertiesOrPromise = resolveInjectedProperties(ctor, ctx);
let inst: T | Promise<T>;
if (isPromise(argsOrPromise)) {
Expand All @@ -46,19 +50,19 @@ export function instantiateClass<T>(
if (isPromise(propertiesOrPromise)) {
return propertiesOrPromise.then(props => {
if (isPromise(inst)) {
// Inject the properties asynchrounously
// Inject the properties asynchronously
return inst.then(obj => Object.assign(obj, props));
} else {
// Inject the properties synchrounously
// Inject the properties synchronously
return Object.assign(inst, props);
}
});
} else {
if (isPromise(inst)) {
// Inject the properties asynchrounously
// Inject the properties asynchronously
return inst.then(obj => Object.assign(obj, propertiesOrPromise));
} else {
// Inject the properties synchrounously
// Inject the properties synchronously
return Object.assign(inst, propertiesOrPromise);
}
}
Expand Down Expand Up @@ -86,29 +90,51 @@ function resolve<T>(ctx: Context, injection: Injection): ValueOrPromise<T> {
* The function returns an argument array when all dependencies were
* resolved synchronously, or a Promise otherwise.
*
* @param fn The function for which the arguments should be resolved.
* @param target The class for constructor injection or prototype for method
* injection
* @param ctx The context containing values for `@inject` resolution
* @param method The method name. If set to '', the constructor will
* be used.
* @param nonInjectedArgs Optional array of args for non-injected parameters
*/
export function resolveInjectedArguments(
fn: Function,
// tslint:disable-next-line:no-any
target: any,
ctx: Context,
method: string,
// tslint:disable-next-line:no-any
nonInjectedArgs?: any[],
): BoundValue[] | Promise<BoundValue[]> {
if (method) {
assert(typeof target[method] === 'function', `Method ${method} not found`);
}
// NOTE: the array may be sparse, i.e.
// Object.keys(injectedArgs).length !== injectedArgs.length
// Example value:
// [ , 'key1', , 'key2']
const injectedArgs = describeInjectedArguments(fn);
const injectedArgs = describeInjectedArguments(target, method);
nonInjectedArgs = nonInjectedArgs || [];

const args: BoundValue[] = new Array(fn.length);
const argLength = method ? target[method].length : target.length;
const args: BoundValue[] = new Array(argLength);
let asyncResolvers: Promise<void>[] | undefined = undefined;

for (let ix = 0; ix < fn.length; ix++) {
const injection = injectedArgs[ix];
if (!injection.bindingKey && !injection.resolve) {
throw new Error(
`Cannot resolve injected arguments for function ${fn.name}: ` +
`The argument ${ix + 1} was not decorated for dependency injection.`,
);
let nonInjectedIndex = 0;
for (let ix = 0; ix < argLength; ix++) {
const injection = ix < injectedArgs.length ? injectedArgs[ix] : undefined;
if (injection == null || (!injection.bindingKey && !injection.resolve)) {
const name = method || target.name;
if (nonInjectedIndex < nonInjectedArgs.length) {
// Set the argument from the non-injected list
args[ix] = nonInjectedArgs[nonInjectedIndex++];
continue;
} else {
throw new Error(
`Cannot resolve injected arguments for function ${name}: ` +
`The arguments[${ix}] is not decorated for dependency injection, ` +
`but a value is not supplied`,
);
}
}

const valueOrPromise = resolve(ctx, injection);
Expand All @@ -129,6 +155,38 @@ export function resolveInjectedArguments(
}
}

/**
* Invoke an instance method with dependency injection
* @param target Target of the method, it will be the class for a static
* method, and instance or class prototype for a prototype method
* @param method Name of the method
* @param ctx Context
* @param nonInjectedArgs Optional array of args for non-injected parameters
*/
export function invokeMethod(
// tslint:disable-next-line:no-any
target: any,
method: string,
ctx: Context,
// tslint:disable-next-line:no-any
nonInjectedArgs?: any[],
): ValueOrPromise<BoundValue> {
const argsOrPromise = resolveInjectedArguments(
target,
ctx,
method,
nonInjectedArgs,
);
assert(typeof target[method] === 'function', `Method ${method} not found`);
if (isPromise(argsOrPromise)) {
// Invoke the target method asynchronously
return argsOrPromise.then(args => target[method](...args));
} else {
// Invoke the target method synchronously
return target[method](...argsOrPromise);
}
}

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

export function resolveInjectedProperties(
Expand Down
12 changes: 6 additions & 6 deletions packages/context/test/acceptance/_feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ ctx.bind('foo').to('bar');
child.get('foo'); // => 'bar'
```

## Scenario: Simple Paramaterized Binding
## Scenario: Simple Parameterized Binding

- Given a simple parameterized binding
- When I get the value with a specific key
- The binding is resovled
- The binding is resolved

```ts
let ctx = new Context();
Expand All @@ -30,11 +30,11 @@ await ctx.get('foo'); // => hello world
await ctx.get('bat'); // => hello world
```

## Scenario: Simple Dynamic Paramaterized Binding
## Scenario: Simple Dynamic Parameterized Binding

- Given a simple parameterized binding
- When I get the value with a specific key
- The binding is resovled with the corresponding value
- The binding is resolved with the corresponding value

```ts
let ctx = new Context();
Expand All @@ -51,11 +51,11 @@ await ctx.get('foo'); // => bar
await ctx.get('bat'); // => baz
```

## Scenario: Namespaced Paramaterized Binding
## Scenario: Namespaced Parameterized Binding

- Given a complex parameterized binding
- When I get the value with a specific key
- The binding is resovled
- The binding is resolved

```ts
let ctx = new Context();
Expand Down
26 changes: 26 additions & 0 deletions packages/context/test/acceptance/class-level-bindings.feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,29 @@
const instance = await ctx.get('controllers.info');
instance.appName; // => CodeHub
```

## Scenario: Inject instance properties

- Given a context
- Given class `InfoController` with a `appName: string` property
- Given `InfoController` with `appName` property decorated
with `@inject('application.name')`
- Given a static binding named `application.name` with value `CodeHub`
- Given a class binding named `controllers.info` bound to class `InfoController`
- When I resolve the binding for `controllers.info`
- Then I get a new instance of `InfoController`
- And the instance was created with `appName` set to `CodeHub`

```ts
const ctx = new Context();
ctx.bind('application.name').to('CodeHub');

class InfoController {
@inject('application.name')
appName: string;
}
ctx.bind('controllers.info').toClass(InfoController);

const instance = await ctx.get('controllers.info');
instance.appName; // => CodeHub
```
55 changes: 55 additions & 0 deletions packages/context/test/acceptance/method-level-bindings.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Feature: Context bindings - injecting dependencies of methods

- In order to receive information from the context for a method
- As a developer
- I want to setup bindings for my method
- So that method dependencies are injected by the IoC framework

## Scenario: Inject method arguments

- Given a context
- Given class `InfoController`
- Given a class binding named `controllers.info` bound to class `InfoController`
- When I resolve the binding for `controllers.info`
- Then I get a new instance of `InfoController`
- When I invoke the `hello` method, the parameter `user` is resolved to the
- value bound to `user` key in the context

```ts
class InfoController {

static say(@inject('user') user: string):string {
const msg = `Hello ${user}`;
console.log(msg);
return msg;
}

hello(@inject('user') user: string):string {
const msg = `Hello ${user}`;
console.log(msg);
return msg;
}

greet(prefix: string, @inject('user') user: string):string {
const msg = `[${prefix}] Hello ${user}`;
console.log(msg);
return msg;
}
}

const ctx = new Context();
// Mock up user authentication
ctx.bind('user').toDynamicValue(() => Promise.resolve('John'));
ctx.bind('controllers.info').toClass(InfoController);

const instance = await ctx.get('controllers.info');
// Invoke the `hello` method => Hello John
const helloMsg = await invokeMethod(instance, 'hello', ctx);
// Invoke the `greet` method with non-injected args => [INFO] Hello John
const greetMsg = await invokeMethod(instance, 'greet', ctx, ['INFO']);

// Invoke the static `sayHello` method => [INFO] Hello John
const greetMsg = await invokeMethod(InfoController, 'sayHello', ctx);
```


67 changes: 67 additions & 0 deletions packages/context/test/acceptance/method-level-bindings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright IBM Corp. 2013,2017. All Rights Reserved.
// Node module: loopback
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {expect} from '@loopback/testlab';
import {Context, inject, invokeMethod} from '../..';

class InfoController {
static sayHello(@inject('user') user: string): string {
const msg = `Hello ${user}`;
console.log(msg);
return msg;
}

hello(@inject('user') user: string): string {
const msg = `Hello ${user}`;
console.log(msg);
return msg;
}

greet(prefix: string, @inject('user') user: string): string {
const msg = `[${prefix}] Hello ${user}`;
console.log(msg);
return msg;
}
}

const INFO_CONTROLLER = 'controllers.info';

describe('Context bindings - Injecting dependencies of method', () => {
let ctx: Context;
beforeEach('given a context', createContext);

it('injects prototype method args', async () => {
const instance = await ctx.get(INFO_CONTROLLER);
// Invoke the `hello` method => Hello John
const msg = await invokeMethod(instance, 'hello', ctx);
expect(msg).to.eql('Hello John');
});

it('injects prototype method args with non-injected ones', async () => {
const instance = await ctx.get(INFO_CONTROLLER);
// Invoke the `hello` method => Hello John
const msg = await invokeMethod(instance, 'greet', ctx, ['INFO']);
expect(msg).to.eql('[INFO] Hello John');
});

it('injects static method args', async () => {
// Invoke the `sayHello` method => Hello John
const msg = await invokeMethod(InfoController, 'sayHello', ctx);
expect(msg).to.eql('Hello John');
});

it('throws error if not all args can be resolved', async () => {
const instance = await ctx.get(INFO_CONTROLLER);
expect(() => {
invokeMethod(instance, 'greet', ctx);
}).to.throw(/The arguments\[0\] is not decorated for dependency injection/);
});

function createContext() {
ctx = new Context();
ctx.bind('user').toDynamicValue(() => Promise.resolve('John'));
ctx.bind(INFO_CONTROLLER).toClass(InfoController);
}
});
Loading

0 comments on commit df1c879

Please sign in to comment.