Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for mixed sync/async bindings #193

Merged
merged 2 commits into from
Apr 26, 2017
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,10 @@ const USERS = {
};

// my get user function
app.bind('authentication.user').to(() => {
app.bind('authentication.user').to(async () => {
const ctx = this;
const username = ctx.get('authentication.credentials.username');
const password = ctx.get('authentication.credentials.password');
const username = await ctx.get('authentication.credentials.username');
const password = await ctx.get('authentication.credentials.password');
const user = USERS[username];
if (!user) return null;
if (!verifyPassword(user.password, password)) return null;
Expand Down
4 changes: 3 additions & 1 deletion packages/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@
// License text available at https://opensource.org/licenses/MIT

export {Binding, BoundValue} from './src/binding';
export {Context, Constructor} from './src/context';
export {Context} from './src/context';
export {Constructor} from './src/resolver';
export {inject} from './src/inject';
export const isPromise = require('is-promise');
2 changes: 2 additions & 0 deletions packages/context/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
},
"devDependencies": {
"@loopback/testlab": "^4.0.0-alpha.1",
"@types/bluebird": "^3.5.2",
"bluebird": "^3.5.0",
"mocha": "^3.2.0"
},
"keywords": [
Expand Down
71 changes: 65 additions & 6 deletions packages/context/src/binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {Context, Constructor} from './context';
import {Context} from './context';
import {Constructor, createClassInstance} from './resolver';

// tslint:disable-next-line:no-any
export type BoundValue = any;

// FIXME(bajtos) The binding class should be parameterized by the value type stored
export class Binding {
// FIXME(bajtos) The binding class should be parameterized by the value type stored
public value: BoundValue;
public getValue: () => BoundValue = () => { throw new Error(`No value was configured for binding ${this._key}.`); };
private _tagName: string;

// For bindings bound via toClass, this property contains the constructor function
Expand All @@ -21,6 +20,30 @@ export class Binding {
get key() { return this._key; }
get tagName() { return this._tagName; }

/**
* This is an internal function optimized for performance.
* Users should use `@inject(key)` or `ctx.get(key)` instead.
*
* Get the value bound to this key. Depending on `isSync`, this function returns either:
* - the bound value
* - a promise of the bound value
*
* Consumers wishing to consume sync values directly should use `isPromise`
* to check the type of the returned value to decide how to handle it.
*
* ```
* const result = binding.getValue();
* if (isPromise(result)) {
* result.then(doSomething)
* } else {
* doSomething(result);
* }
* ```
*/
getValue(): BoundValue | Promise<BoundValue> {
return Promise.reject(new Error(`No value was configured for binding ${this._key}.`));
}

lock() {
this.isLocked = true;
}
Expand All @@ -30,18 +53,54 @@ export class Binding {
return this;
}

/**
* Bind the key to a constant value.
*
* @param value The bound value.
*
* @example
*
* ```ts
* ctx.bind('appName').to('CodeHub');
* ```
*/
to(value: BoundValue): this {
this.getValue = () => value;
return this;
}

toDynamicValue(factoryFn: () => BoundValue): this {
/**
* Bind the key to a computed (dynamic) value.
*
* @param factoryFn The factory function creating the value.
* Both sync and async functions are supported.
*
* @example
*
* ```ts
* // synchronous
* ctx.bind('now').toDynamicValue(() => Date.now());
*
* // asynchronous
* ctx.bind('something').toDynamicValue(
* async () => Promise.delay(10).then(doSomething)
* );
* ```
*/
toDynamicValue(factoryFn: () => BoundValue | Promise<BoundValue>): this {
this.getValue = factoryFn;
return this;
}

/**
* Bind the key to an instance of the given class.
*
* @param ctor The class constructor to call. Any constructor
* arguments must be annotated with `@inject` so that
* we can resolve them from the context.
*/
toClass<T>(ctor: Constructor<T>): this {
this.getValue = () => this._context.createClassInstance(ctor);
this.getValue = () => createClassInstance(ctor, this._context);
this.valueConstructor = ctor;
return this;
}
Expand Down
58 changes: 26 additions & 32 deletions packages/context/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@
// License text available at https://opensource.org/licenses/MIT

import {Binding, BoundValue} from './binding';
import {inject, describeInjectedArguments} from './inject';

// tslint:disable-next-line:no-any
export type Constructor<T> = new(...args: any[]) => T;
import {inject} from './inject';
import {isPromise} from './isPromise';

export class Context {
private registry: Map<string, Binding>;
Expand All @@ -30,32 +28,6 @@ export class Context {
return binding;
}

createClassInstance<T>(ctor: Constructor<T>) : T {
const args = this._resolveInjectedArguments(ctor);
return new ctor(...args);
}

private _resolveInjectedArguments(fn: Function): BoundValue[] {
const args: BoundValue[] = [];
// NOTE: the array may be sparse, i.e.
// Object.keys(injectedArgs).length !== injectedArgs.length
// Example value:
// [ , 'key1', , 'key2']
const injectedArgs = describeInjectedArguments(fn);

for (let ix = 0; ix < fn.length; ix++) {
const bindingKey = injectedArgs[ix];
if (!bindingKey) {
throw new Error(
`Cannot resolve injected arguments for function ${fn.name}: ` +
`The argument ${ix+1} was not decorated for dependency injection.`);
}

args.push(this.get(bindingKey));
}
return args;
}

contains(key: string): boolean {
return this.registry.has(key);
}
Expand Down Expand Up @@ -89,10 +61,32 @@ export class Context {
return bindings;
}

get(key: string) {
get(key: string): Promise<BoundValue> {
try {
const binding = this.getBinding(key);
return Promise.resolve(binding.getValue());
} catch (err) {
return Promise.reject(err);
}
}

getSync(key: string): BoundValue {
const binding = this.getBinding(key);
const valueOrPromise = binding.getValue();

if (isPromise(valueOrPromise)) {
throw new Error(
`Cannot get ${key} synchronously: ` +
`the value requires async computation`);
}

return valueOrPromise;
}

getBinding(key: string): Binding {
const binding = this.registry.get(key);
if (!binding)
throw new Error(`The key ${key} was not bound to any value.`);
return binding.getValue();
return binding;
}
}
12 changes: 12 additions & 0 deletions packages/context/src/isPromise.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// 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

export function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
if (!value)
return false;
if (typeof value !== 'object' && typeof value !== 'function')
return false;
return typeof (value as Promise<T>).then === 'function';
}
77 changes: 77 additions & 0 deletions packages/context/src/resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// 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 { Context } from './context';
import { Binding, BoundValue } from './binding';
import { isPromise } from './isPromise';
import { describeInjectedArguments } from './inject';

// tslint:disable-next-line:no-any
export type Constructor<T> = new(...args: any[]) => T;

/**
* Create an instance of a class which constructor has arguments
* decorated with `@inject`.
*
* The function returns a class when all dependencies were
* resolved synchronously, or a Promise otherwise.
*
* @param ctor The class constructor to call.
* @param ctx The context containing values for `@inject` resolution
*/
export function createClassInstance<T>(ctor: Constructor<T>, ctx: Context): T | Promise<T> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name can be instantiate.

const argsOrPromise = resolveInjectedArguments(ctor, ctx);
if (isPromise(argsOrPromise)) {
return argsOrPromise.then(args => new ctor(...args));
} else {
return new ctor(...argsOrPromise);
}
}

/**
* Given a function with arguments decorated with `@inject`,
* return the list of arguments resolved using the values
* bound in `ctx`.

* 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 ctx The context containing values for `@inject` resolution
*/
export function resolveInjectedArguments(fn: Function, ctx: Context): BoundValue[] | Promise<BoundValue[]> {
// NOTE: the array may be sparse, i.e.
// Object.keys(injectedArgs).length !== injectedArgs.length
// Example value:
// [ , 'key1', , 'key2']
const injectedArgs = describeInjectedArguments(fn);

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

for (let ix = 0; ix < fn.length; ix++) {
const bindingKey = injectedArgs[ix];
if (!bindingKey) {
throw new Error(
`Cannot resolve injected arguments for function ${fn.name}: ` +
`The argument ${ix + 1} was not decorated for dependency injection.`);
}

const binding = ctx.getBinding(bindingKey);
const valueOrPromise = binding.getValue();
if (isPromise(valueOrPromise)) {
if (!asyncResolvers) asyncResolvers = [];
asyncResolvers.push(valueOrPromise.then((v: BoundValue) => args[ix] = v));
} else {
args[ix] = valueOrPromise as BoundValue;
}
}

if (asyncResolvers) {
return Promise.all(asyncResolvers).then(() => args);
} else {
return args;
}
}
12 changes: 6 additions & 6 deletions packages/context/test/acceptance/_feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ child.get('foo'); // => 'bar'
let ctx = new Context();
ctx.bind(':name').to('hello world')

ctx.get('foo'); // => hello world
ctx.get('bat'); // => hello world
await ctx.get('foo'); // => hello world
await ctx.get('bat'); // => hello world
```

## Scenario: Simple Dynamic Paramaterized Binding
Expand All @@ -47,8 +47,8 @@ ctx.bind(':name').to((name) => {
return data[name];
});

ctx.get('foo'); // => bar
ctx.get('bat'); // => baz
await ctx.get('foo'); // => bar
await ctx.get('bat'); // => baz
```

## Scenario: Namespaced Paramaterized Binding
Expand All @@ -61,6 +61,6 @@ ctx.get('bat'); // => baz
let ctx = new Context();
ctx.bind('foo.:name').to('hello world');

ctx.get('foo.bar'); // => hello world
ctx.get('foo.bat'); // => hello world
await ctx.get('foo.bar'); // => hello world
await ctx.get('foo.bat'); // => hello world
```
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@
}
ctx.bind('controllers.info').toClass(InfoController);

const instance = ctx.get('controllers.info');
const instance = await ctx.get('controllers.info');
instance.appName; // => CodeHub
```
Loading