diff --git a/packages/context/docs.json b/packages/context/docs.json index 857bea3c1f0b..a6a744fca073 100644 --- a/packages/context/docs.json +++ b/packages/context/docs.json @@ -8,6 +8,7 @@ "src/is-promise.ts", "src/provider.ts", "src/reflect.ts", + "src/resolution-session.ts", "src/resolver.ts" ], "codeSectionDepth": 4, diff --git a/packages/context/package.json b/packages/context/package.json index 56dca39df3ce..665af4b8bb0c 100644 --- a/packages/context/package.json +++ b/packages/context/package.json @@ -22,12 +22,14 @@ "author": "IBM", "license": "MIT", "dependencies": { - "@loopback/metadata": "^4.0.0-alpha.4" + "@loopback/metadata": "^4.0.0-alpha.4", + "debug": "^3.1.0" }, "devDependencies": { "@loopback/build": "^4.0.0-alpha.8", "@loopback/testlab": "^4.0.0-alpha.18", "@types/bluebird": "^3.5.18", + "@types/debug": "0.0.30", "bluebird": "^3.5.0" }, "keywords": [ diff --git a/packages/context/src/binding.ts b/packages/context/src/binding.ts index e4ce3d66ec23..eef6e1c1f23d 100644 --- a/packages/context/src/binding.ts +++ b/packages/context/src/binding.ts @@ -4,10 +4,14 @@ // License text available at https://opensource.org/licenses/MIT import {Context} from './context'; +import {ResolutionSession} from './resolution-session'; import {Constructor, instantiateClass} from './resolver'; import {isPromise} from './is-promise'; import {Provider} from './provider'; +import * as debugModule from 'debug'; +const debug = debugModule('loopback:context:binding'); + // tslint:disable-next-line:no-any export type BoundValue = any; @@ -147,7 +151,10 @@ export class Binding { public type: BindingType; private _cache: BoundValue; - private _getValue: (ctx?: Context) => BoundValue | Promise; + private _getValue: ( + ctx?: Context, + session?: ResolutionSession, + ) => BoundValue | Promise; // For bindings bound via toClass, this property contains the constructor // function @@ -224,8 +231,18 @@ export class Binding { * doSomething(result); * } * ``` + * + * @param ctx Context for the resolution + * @param session Optional session for binding and dependency resolution */ - getValue(ctx: Context): BoundValue | Promise { + getValue( + ctx: Context, + session?: ResolutionSession, + ): BoundValue | Promise { + /* istanbul ignore if */ + if (debug.enabled) { + debug('Get value for binding %s', this.key); + } // First check cached value for non-transient if (this._cache !== undefined) { if (this.scope === BindingScope.SINGLETON) { @@ -237,7 +254,11 @@ export class Binding { } } if (this._getValue) { - const result = this._getValue(ctx); + let result = ResolutionSession.runWithBinding( + s => this._getValue(ctx, s), + this, + session, + ); return this._cacheValue(ctx, result); } return Promise.reject( @@ -300,6 +321,10 @@ export class Binding { 'via ".toDynamicValue()" instead.', ); } + /* istanbul ignore if */ + if (debug.enabled) { + debug('Bind %s to constant:', this.key, value); + } this.type = BindingType.CONSTANT; this._getValue = () => value; return this; @@ -324,6 +349,10 @@ export class Binding { * ``` */ toDynamicValue(factoryFn: () => BoundValue | Promise): this { + /* istanbul ignore if */ + if (debug.enabled) { + debug('Bind %s to dynamic value:', this.key, factoryFn); + } this.type = BindingType.DYNAMIC_VALUE; this._getValue = ctx => factoryFn(); return this; @@ -346,11 +375,16 @@ export class Binding { * @param provider The value provider to use. */ public toProvider(providerClass: Constructor>): this { + /* istanbul ignore if */ + if (debug.enabled) { + debug('Bind %s to provider %s', this.key, providerClass.name); + } this.type = BindingType.PROVIDER; - this._getValue = ctx => { + this._getValue = (ctx, session) => { const providerOrPromise = instantiateClass>( providerClass, ctx!, + session, ); if (isPromise(providerOrPromise)) { return providerOrPromise.then(p => p.value()); @@ -369,8 +403,12 @@ export class Binding { * we can resolve them from the context. */ toClass(ctor: Constructor): this { + /* istanbul ignore if */ + if (debug.enabled) { + debug('Bind %s to class %s', this.key, ctor.name); + } this.type = BindingType.CLASS; - this._getValue = ctx => instantiateClass(ctor, ctx!); + this._getValue = (ctx, session) => instantiateClass(ctor, ctx!, session); this.valueConstructor = ctor; return this; } diff --git a/packages/context/src/context.ts b/packages/context/src/context.ts index 3a4f2b6626f0..0fc75c0dc275 100644 --- a/packages/context/src/context.ts +++ b/packages/context/src/context.ts @@ -5,6 +5,10 @@ import {Binding, BoundValue, ValueOrPromise} from './binding'; import {isPromise} from './is-promise'; +import {ResolutionSession} from './resolution-session'; + +import * as debugModule from 'debug'; +const debug = debugModule('loopback:context'); /** * Context provides an implementation of Inversion of Control (IoC) container @@ -27,6 +31,10 @@ export class Context { * @param key Binding key */ bind(key: string): Binding { + /* istanbul ignore if */ + if (debug.enabled) { + debug('Adding binding: %s', key); + } Binding.validateKey(key); const keyExists = this.registry.has(key); if (keyExists) { @@ -143,9 +151,13 @@ export class Context { * (deeply) nested property to retrieve. * @returns A promise of the bound value. */ - get(key: string): Promise { + get(key: string, session?: ResolutionSession): Promise { + /* istanbul ignore if */ + if (debug.enabled) { + debug('Resolving binding: %s', key); + } try { - return Promise.resolve(this.getValueOrPromise(key)); + return Promise.resolve(this.getValueOrPromise(key, session)); } catch (err) { return Promise.reject(err); } @@ -173,8 +185,12 @@ export class Context { * (deeply) nested property to retrieve. * @returns A promise of the bound value. */ - getSync(key: string): BoundValue { - const valueOrPromise = this.getValueOrPromise(key); + getSync(key: string, session?: ResolutionSession): BoundValue { + /* istanbul ignore if */ + if (debug.enabled) { + debug('Resolving binding synchronously: %s', key); + } + const valueOrPromise = this.getValueOrPromise(key, session); if (isPromise(valueOrPromise)) { throw new Error( @@ -227,13 +243,17 @@ export class Context { * * @param keyWithPath The binding key, optionally suffixed with a path to the * (deeply) nested property to retrieve. + * @param session An object to keep states of the resolution * @returns The bound value or a promise of the bound value, depending * on how the binding was configured. * @internal */ - getValueOrPromise(keyWithPath: string): ValueOrPromise { + getValueOrPromise( + keyWithPath: string, + session?: ResolutionSession, + ): ValueOrPromise { const {key, path} = Binding.parseKeyWithPath(keyWithPath); - const boundValue = this.getBinding(key).getValue(this); + const boundValue = this.getBinding(key).getValue(this, session); if (path === undefined || path === '') { return boundValue; } diff --git a/packages/context/src/index.ts b/packages/context/src/index.ts index 653089723952..6a0cc6f94461 100644 --- a/packages/context/src/index.ts +++ b/packages/context/src/index.ts @@ -13,6 +13,7 @@ export { export {Context} from './context'; export {Constructor} from './resolver'; +export {ResolutionSession} from './resolution-session'; export {inject, Setter, Getter} from './inject'; export {Provider} from './provider'; export {isPromise} from './is-promise'; diff --git a/packages/context/src/inject.ts b/packages/context/src/inject.ts index 9ae4a48cd5cc..16214e300c9e 100644 --- a/packages/context/src/inject.ts +++ b/packages/context/src/inject.ts @@ -5,12 +5,14 @@ import { MetadataInspector, + DecoratorFactory, ParameterDecoratorFactory, PropertyDecoratorFactory, MetadataMap, } from '@loopback/metadata'; import {BoundValue, ValueOrPromise} from './binding'; import {Context} from './context'; +import {ResolutionSession} from './resolution-session'; const PARAMETERS_KEY = 'inject:parameters'; const PROPERTIES_KEY = 'inject:properties'; @@ -19,13 +21,23 @@ const PROPERTIES_KEY = 'inject:properties'; * A function to provide resolution of injected values */ export interface ResolverFunction { - (ctx: Context, injection: Injection): ValueOrPromise; + ( + ctx: Context, + injection: Injection, + session?: ResolutionSession, + ): ValueOrPromise; } /** * Descriptor for an injection point */ export interface Injection { + target: Object; + member?: string | symbol; + methodDescriptorOrParameterIndex?: + | TypedPropertyDescriptor + | number; + bindingKey: string; // Binding key metadata?: {[attribute: string]: BoundValue}; // Related metadata resolve?: ResolverFunction; // A custom resolve function @@ -63,9 +75,8 @@ export function inject( resolve?: ResolverFunction, ) { return function markParameterOrPropertyAsInjected( - // tslint:disable-next-line:no-any - target: any, - propertyKey: string | symbol, + target: Object, + member: string | symbol, methodDescriptorOrParameterIndex?: | TypedPropertyDescriptor | number, @@ -73,38 +84,47 @@ export function inject( if (typeof methodDescriptorOrParameterIndex === 'number') { // The decorator is applied to a method parameter // Please note propertyKey is `undefined` for constructor - const paramDecorator: ParameterDecorator = ParameterDecoratorFactory.createDecorator( - PARAMETERS_KEY, - { - bindingKey, - metadata, - resolve, - }, - ); - paramDecorator(target, propertyKey!, methodDescriptorOrParameterIndex); - } else if (propertyKey) { + const paramDecorator: ParameterDecorator = ParameterDecoratorFactory.createDecorator< + Injection + >(PARAMETERS_KEY, { + target, + member, + methodDescriptorOrParameterIndex, + bindingKey, + metadata, + resolve, + }); + paramDecorator(target, member!, methodDescriptorOrParameterIndex); + } else if (member) { // Property or method - if (typeof Object.getPrototypeOf(target) === 'function') { - const prop = target.name + '.' + propertyKey.toString(); + if (target instanceof Function) { throw new Error( - '@inject is not supported for a static property: ' + prop, + '@inject is not supported for a static property: ' + + DecoratorFactory.getTargetName(target, member), ); } if (methodDescriptorOrParameterIndex) { // Method throw new Error( - '@inject cannot be used on a method: ' + propertyKey.toString(), + '@inject cannot be used on a method: ' + + DecoratorFactory.getTargetName( + target, + member, + methodDescriptorOrParameterIndex, + ), ); } - const propDecorator: PropertyDecorator = PropertyDecoratorFactory.createDecorator( - PROPERTIES_KEY, - { - bindingKey, - metadata, - resolve, - }, - ); - propDecorator(target, propertyKey!); + const propDecorator: PropertyDecorator = PropertyDecoratorFactory.createDecorator< + Injection + >(PROPERTIES_KEY, { + target, + member, + methodDescriptorOrParameterIndex, + bindingKey, + metadata, + resolve, + }); + propDecorator(target, member!); } else { // It won't happen here as `@inject` is not compatible with ClassDecorator /* istanbul ignore next */ @@ -167,13 +187,20 @@ export namespace inject { }; } -function resolveAsGetter(ctx: Context, injection: Injection) { +function resolveAsGetter( + ctx: Context, + injection: Injection, + session?: ResolutionSession, +) { + // We need to clone the session for the getter as it will be resolved later + session = ResolutionSession.fork(session); return function getter() { - return ctx.get(injection.bindingKey); + return ctx.get(injection.bindingKey, session); }; } function resolveAsSetter(ctx: Context, injection: Injection) { + // No resolution session should be propagated into the setter return function setter(value: BoundValue) { ctx.bind(injection.bindingKey).to(value); }; @@ -186,8 +213,7 @@ function resolveAsSetter(ctx: Context, injection: Injection) { * @param method Method name, undefined for constructor */ export function describeInjectedArguments( - // tslint:disable-next-line:no-any - target: any, + target: Object, method?: string | symbol, ): Injection[] { method = method || ''; @@ -205,8 +231,7 @@ export function describeInjectedArguments( * prototype for instance properties. */ export function describeInjectedProperties( - // tslint:disable-next-line:no-any - target: any, + target: Object, ): MetadataMap { const metadata = MetadataInspector.getAllPropertyMetadata( diff --git a/packages/context/src/resolution-session.ts b/packages/context/src/resolution-session.ts new file mode 100644 index 000000000000..9c5f4ae8c448 --- /dev/null +++ b/packages/context/src/resolution-session.ts @@ -0,0 +1,338 @@ +// Copyright IBM Corp. 2013, 2018. All Rights Reserved. +// Node module: @loopback/context +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Binding, ValueOrPromise, BoundValue} from './binding'; +import {Injection} from './inject'; +import {isPromise} from './is-promise'; +import * as debugModule from 'debug'; +import {DecoratorFactory} from '@loopback/metadata'; + +const debugSession = debugModule('loopback:context:resolver:session'); +const getTargetName = DecoratorFactory.getTargetName; + +/** + * A function to be executed with the resolution session + */ +export type ResolutionAction = ( + session?: ResolutionSession, +) => ValueOrPromise; + +/** + * Try to run an action that returns a promise or a value + * @param action A function that returns a promise or a value + * @param finalAction A function to be called once the action + * is fulfilled or rejected (synchronously or asynchronously) + */ +function tryWithFinally( + action: () => ValueOrPromise, + finalAction: () => void, +) { + let result: ValueOrPromise; + try { + result = action(); + } catch (err) { + finalAction(); + throw err; + } + if (isPromise(result)) { + // Once (promise.finally)[https://github.com/tc39/proposal-promise-finally + // is supported, the following can be simplifed as + // `result = result.finally(finalAction);` + result = result.then( + val => { + finalAction(); + return val; + }, + err => { + finalAction(); + throw err; + }, + ); + } else { + finalAction(); + } + return result; +} + +/** + * Wrapper for bindings tracked by resolution sessions + */ +export interface BindingElement { + type: 'binding'; + value: Binding; +} + +/** + * Wrapper for injections tracked by resolution sessions + */ +export interface InjectionElement { + type: 'injection'; + value: Injection; +} + +/** + * Binding or injection elements tracked by resolution sessions + */ +export type ResolutionElement = BindingElement | InjectionElement; + +/** + * Object to keep states for a session to resolve bindings and their + * dependencies within a context + */ +export class ResolutionSession { + /** + * A stack of bindings for the current resolution session. It's used to track + * the path of dependency resolution and detect circular dependencies. + */ + readonly stack: ResolutionElement[] = []; + + /** + * Fork the current session so that a new one with the same stack can be used + * in parallel or future resolutions, such as multiple method arguments, + * multiple properties, or a getter function + * @param session The current session + */ + static fork(session?: ResolutionSession): ResolutionSession | undefined { + if (session === undefined) return undefined; + const copy = new ResolutionSession(); + copy.stack.push(...session.stack); + return copy; + } + + /** + * Start to resolve a binding within the session + * @param binding The current binding + * @param session The current resolution session + */ + private static enterBinding( + binding: Binding, + session?: ResolutionSession, + ): ResolutionSession { + session = session || new ResolutionSession(); + session.pushBinding(binding); + return session; + } + + /** + * Run the given action with the given binding and session + * @param action A function to do some work with the resolution session + * @param binding The current binding + * @param session The current resolution session + */ + static runWithBinding( + action: ResolutionAction, + binding: Binding, + session?: ResolutionSession, + ) { + const resolutionSession = ResolutionSession.enterBinding(binding, session); + return tryWithFinally( + () => action(resolutionSession), + () => resolutionSession.popBinding(), + ); + } + + /** + * Push an injection into the session + * @param injection The current injection + * @param session The current resolution session + */ + private static enterInjection( + injection: Injection, + session?: ResolutionSession, + ): ResolutionSession { + session = session || new ResolutionSession(); + session.pushInjection(injection); + return session; + } + + /** + * Run the given action with the given injection and session + * @param action A function to do some work with the resolution session + * @param binding The current injection + * @param session The current resolution session + */ + static runWithInjection( + action: ResolutionAction, + injection: Injection, + session?: ResolutionSession, + ) { + const resolutionSession = ResolutionSession.enterInjection( + injection, + session, + ); + return tryWithFinally( + () => action(resolutionSession), + () => resolutionSession.popInjection(), + ); + } + + /** + * Describe the injection for debugging purpose + * @param injection Injection object + */ + static describeInjection(injection?: Injection) { + /* istanbul ignore if */ + if (injection == null) return injection; + const name = getTargetName( + injection.target, + injection.member, + injection.methodDescriptorOrParameterIndex, + ); + return { + targetName: name, + bindingKey: injection.bindingKey, + metadata: injection.metadata, + }; + } + + /** + * Push the injection onto the session + * @param injection Injection The current injection + */ + pushInjection(injection: Injection) { + /* istanbul ignore if */ + if (debugSession.enabled) { + debugSession( + 'Enter injection:', + ResolutionSession.describeInjection(injection), + ); + } + this.stack.push({type: 'injection', value: injection}); + /* istanbul ignore if */ + if (debugSession.enabled) { + debugSession('Resolution path:', this.getResolutionPath()); + } + } + + /** + * Pop the last injection + */ + popInjection() { + const top = this.stack.pop(); + if (top === undefined || top.type !== 'injection') { + throw new Error('The top element must be an injection'); + } + + const injection = top.value; + /* istanbul ignore if */ + if (debugSession.enabled) { + debugSession( + 'Exit injection:', + ResolutionSession.describeInjection(injection), + ); + debugSession('Resolution path:', this.getResolutionPath() || ''); + } + return injection; + } + + /** + * Getter for the current injection + */ + get currentInjection(): Injection | undefined { + for (let i = this.stack.length - 1; i >= 0; i--) { + const element = this.stack[i]; + switch (element.type) { + case 'injection': + return element.value; + } + } + return undefined; + } + + /** + * Getter for the current binding + */ + get currentBinding(): Binding | undefined { + for (let i = this.stack.length - 1; i >= 0; i--) { + const element = this.stack[i]; + switch (element.type) { + case 'binding': + return element.value; + } + } + return undefined; + } + + /** + * Enter the resolution of the given binding. If + * @param binding Binding + */ + pushBinding(binding: Binding) { + /* istanbul ignore if */ + if (debugSession.enabled) { + debugSession('Enter binding:', binding.toJSON()); + } + if (this.stack.find(i => i.type === 'binding' && i.value === binding)) { + throw new Error( + `Circular dependency detected on path '${this.getBindingPath()} --> ${ + binding.key + }'`, + ); + } + this.stack.push({type: 'binding', value: binding}); + /* istanbul ignore if */ + if (debugSession.enabled) { + debugSession('Resolution path:', this.getResolutionPath()); + } + } + + /** + * Exit the resolution of a binding + */ + popBinding() { + const top = this.stack.pop(); + if (top === undefined || top.type !== 'binding') { + throw new Error('The top element must be a binding'); + } + const binding = top.value; + /* istanbul ignore if */ + if (debugSession.enabled) { + debugSession('Exit binding:', binding && binding.toJSON()); + debugSession('Resolution path:', this.getResolutionPath() || ''); + } + return binding; + } + + /** + * Get the binding path as `bindingA --> bindingB --> bindingC`. + */ + getBindingPath() { + return this.stack + .filter(i => i.type === 'binding') + .map(b => (b.value).key) + .join(' --> '); + } + + /** + * Get the injection path as `injectionA --> injectionB --> injectionC`. + */ + getInjectionPath() { + return this.stack + .filter(i => i.type === 'injection') + .map( + i => + ResolutionSession.describeInjection(i.value)!.targetName, + ) + .join(' --> '); + } + + private static describe(e: ResolutionElement) { + switch (e.type) { + case 'injection': + return '@' + ResolutionSession.describeInjection(e.value)!.targetName; + case 'binding': + return e.value.key; + } + } + + /** + * Get the resolution path including bindings and injections, for example: + * `bindingA --> @ClassA[0] --> bindingB --> @ClassB.prototype.prop1 + * --> bindingC`. + */ + getResolutionPath() { + return this.stack.map(i => ResolutionSession.describe(i)).join(' --> '); + } +} diff --git a/packages/context/src/resolver.ts b/packages/context/src/resolver.ts index a200971bfa2e..710c5c6990cf 100644 --- a/packages/context/src/resolver.ts +++ b/packages/context/src/resolver.ts @@ -3,6 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {DecoratorFactory} from '@loopback/metadata'; import {Context} from './context'; import {BoundValue, ValueOrPromise} from './binding'; import {isPromise} from './is-promise'; @@ -11,7 +12,13 @@ import { describeInjectedProperties, Injection, } from './inject'; +import {ResolutionSession} from './resolution-session'; + import * as assert from 'assert'; +import * as debugModule from 'debug'; + +const debug = debugModule('loopback:context:resolver'); +const getTargetName = DecoratorFactory.getTargetName; /** * A class constructor accepting arbitrary arguments. @@ -29,26 +36,49 @@ export type Constructor = * * @param ctor The class constructor to call. * @param ctx The context containing values for `@inject` resolution + * @param session Optional session for binding and dependency resolution * @param nonInjectedArgs Optional array of args for non-injected parameters */ export function instantiateClass( ctor: Constructor, ctx: Context, + session?: ResolutionSession, // tslint:disable-next-line:no-any nonInjectedArgs?: any[], ): T | Promise { - const argsOrPromise = resolveInjectedArguments(ctor, ctx, ''); - const propertiesOrPromise = resolveInjectedProperties(ctor, ctx); + /* istanbul ignore if */ + if (debug.enabled) { + debug('Instantiating %s', getTargetName(ctor)); + if (nonInjectedArgs && nonInjectedArgs.length) { + debug('Non-injected arguments:', nonInjectedArgs); + } + } + const argsOrPromise = resolveInjectedArguments(ctor, '', ctx, session); + const propertiesOrPromise = resolveInjectedProperties(ctor, ctx, session); let inst: T | Promise; if (isPromise(argsOrPromise)) { // Instantiate the class asynchronously - inst = argsOrPromise.then(args => new ctor(...args)); + inst = argsOrPromise.then(args => { + /* istanbul ignore if */ + if (debug.enabled) { + debug('Injected arguments for %s():', ctor.name, args); + } + return new ctor(...args); + }); } else { + /* istanbul ignore if */ + if (debug.enabled) { + debug('Injected arguments for %s():', ctor.name, argsOrPromise); + } // Instantiate the class synchronously inst = new ctor(...argsOrPromise); } if (isPromise(propertiesOrPromise)) { return propertiesOrPromise.then(props => { + /* istanbul ignore if */ + if (debug.enabled) { + debug('Injected properties for %s:', ctor.name, props); + } if (isPromise(inst)) { // Inject the properties asynchronously return inst.then(obj => Object.assign(obj, props)); @@ -59,6 +89,10 @@ export function instantiateClass( }); } else { if (isPromise(inst)) { + /* istanbul ignore if */ + if (debug.enabled) { + debug('Injected properties for %s:', ctor.name, propertiesOrPromise); + } // Inject the properties asynchronously return inst.then(obj => Object.assign(obj, propertiesOrPromise)); } else { @@ -72,14 +106,34 @@ export function instantiateClass( * Resolve the value or promise for a given injection * @param ctx Context * @param injection Descriptor of the injection + * @param session Optional session for binding and dependency resolution */ -function resolve(ctx: Context, injection: Injection): ValueOrPromise { - if (injection.resolve) { - // A custom resolve function is provided - return injection.resolve(ctx, injection); +function resolve( + ctx: Context, + injection: Injection, + session?: ResolutionSession, +): ValueOrPromise { + /* istanbul ignore if */ + if (debug.enabled) { + debug( + 'Resolving an injection:', + ResolutionSession.describeInjection(injection), + ); } - // Default to resolve the value from the context by binding key - return ctx.getValueOrPromise(injection.bindingKey); + let resolved = ResolutionSession.runWithInjection( + s => { + if (injection.resolve) { + // A custom resolve function is provided + return injection.resolve(ctx, injection, s); + } else { + // Default to resolve the value from the context by binding key + return ctx.getValueOrPromise(injection.bindingKey, s); + } + }, + injection, + session, + ); + return resolved; } /** @@ -92,21 +146,30 @@ function resolve(ctx: Context, injection: Injection): ValueOrPromise { * * @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 ctx The context containing values for `@inject` resolution + * @param session Optional session for binding and dependency resolution * @param nonInjectedArgs Optional array of args for non-injected parameters */ export function resolveInjectedArguments( - // tslint:disable-next-line:no-any - target: any, - ctx: Context, + target: Object, method: string, + ctx: Context, + session?: ResolutionSession, // tslint:disable-next-line:no-any nonInjectedArgs?: any[], ): BoundValue[] | Promise { + /* istanbul ignore if */ + if (debug.enabled) { + debug('Resolving injected arguments for %s', getTargetName(target, method)); + } + const targetWithMethods = <{[method: string]: Function}>target; if (method) { - assert(typeof target[method] === 'function', `Method ${method} not found`); + assert( + typeof targetWithMethods[method] === 'function', + `Method ${method} not found`, + ); } // NOTE: the array may be sparse, i.e. // Object.keys(injectedArgs).length !== injectedArgs.length @@ -115,7 +178,7 @@ export function resolveInjectedArguments( const injectedArgs = describeInjectedArguments(target, method); nonInjectedArgs = nonInjectedArgs || []; - const argLength = method ? target[method].length : target.length; + const argLength = DecoratorFactory.getNumberOfParameters(target, method); const args: BoundValue[] = new Array(argLength); let asyncResolvers: Promise[] | undefined = undefined; @@ -123,21 +186,26 @@ export function resolveInjectedArguments( 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 { + const name = getTargetName(target, method, ix); throw new Error( - `Cannot resolve injected arguments for function ${name}: ` + + `Cannot resolve injected arguments for ${name}: ` + `The arguments[${ix}] is not decorated for dependency injection, ` + `but a value is not supplied`, ); } } - const valueOrPromise = resolve(ctx, injection); + // Clone the session so that multiple arguments can be resolved in parallel + const valueOrPromise = resolve( + ctx, + injection, + ResolutionSession.fork(session), + ); if (isPromise(valueOrPromise)) { if (!asyncResolvers) asyncResolvers = []; asyncResolvers.push( @@ -164,36 +232,75 @@ export function resolveInjectedArguments( * @param nonInjectedArgs Optional array of args for non-injected parameters */ export function invokeMethod( - // tslint:disable-next-line:no-any - target: any, + target: Object, method: string, ctx: Context, // tslint:disable-next-line:no-any nonInjectedArgs?: any[], ): ValueOrPromise { + const methodName = getTargetName(target, method); + /* istanbul ignore if */ + if (debug.enabled) { + debug('Invoking method %s', methodName); + if (nonInjectedArgs && nonInjectedArgs.length) { + debug('Non-injected arguments:', nonInjectedArgs); + } + } const argsOrPromise = resolveInjectedArguments( target, - ctx, method, + ctx, + undefined, nonInjectedArgs, ); - assert(typeof target[method] === 'function', `Method ${method} not found`); + const targetWithMethods = <{[method: string]: Function}>target; + assert( + typeof targetWithMethods[method] === 'function', + `Method ${method} not found`, + ); if (isPromise(argsOrPromise)) { // Invoke the target method asynchronously - return argsOrPromise.then(args => target[method](...args)); + return argsOrPromise.then(args => { + /* istanbul ignore if */ + if (debug.enabled) { + debug('Injected arguments for %s:', methodName, args); + } + return targetWithMethods[method](...args); + }); } else { + /* istanbul ignore if */ + if (debug.enabled) { + debug('Injected arguments for %s:', methodName, argsOrPromise); + } // Invoke the target method synchronously - return target[method](...argsOrPromise); + return targetWithMethods[method](...argsOrPromise); } } export type KV = {[p: string]: BoundValue}; +/** + * Given a class with properties decorated with `@inject`, + * return the map of properties resolved using the values + * bound in `ctx`. + + * The function returns an argument array when all dependencies were + * resolved synchronously, or a Promise otherwise. + * + * @param constructor The class for which properties should be resolved. + * @param ctx The context containing values for `@inject` resolution + * @param session Optional session for binding and dependency resolution + */ export function resolveInjectedProperties( - fn: Function, + constructor: Function, ctx: Context, + session?: ResolutionSession, ): KV | Promise { - const injectedProperties = describeInjectedProperties(fn.prototype); + /* istanbul ignore if */ + if (debug.enabled) { + debug('Resolving injected properties for %s', getTargetName(constructor)); + } + const injectedProperties = describeInjectedProperties(constructor.prototype); const properties: KV = {}; let asyncResolvers: Promise[] | undefined = undefined; @@ -204,12 +311,19 @@ export function resolveInjectedProperties( for (const p in injectedProperties) { const injection = injectedProperties[p]; if (!injection.bindingKey && !injection.resolve) { + const name = getTargetName(constructor, p); throw new Error( - `Cannot resolve injected property for class ${fn.name}: ` + - `The property ${p} was not decorated for dependency injection.`, + `Cannot resolve injected property ${name}: ` + + `The property ${p} is not decorated for dependency injection.`, ); } - const valueOrPromise = resolve(ctx, injection); + + // Clone the session so that multiple properties can be resolved in parallel + const valueOrPromise = resolve( + ctx, + injection, + ResolutionSession.fork(session), + ); if (isPromise(valueOrPromise)) { if (!asyncResolvers) asyncResolvers = []; asyncResolvers.push(valueOrPromise.then(propertyResolver(p))); diff --git a/packages/context/test/unit/binding.ts b/packages/context/test/unit/binding.ts index ef7786ab09a0..63d0d60860c1 100644 --- a/packages/context/test/unit/binding.ts +++ b/packages/context/test/unit/binding.ts @@ -111,9 +111,14 @@ describe('Binding', () => { }); it('rejects rejected promise values', () => { - expect(() => binding.to(Promise.reject('error'))).to.throw( + const p = Promise.reject('error'); + expect(() => binding.to(p)).to.throw( /Promise instances are not allowed.*toDynamicValue/, ); + // Catch the rejected promise to avoid + // (node:60994) UnhandledPromiseRejectionWarning: Unhandled promise + // rejection (rejection id: 1): error + p.catch(e => {}); }); }); diff --git a/packages/context/test/unit/resolver.test.ts b/packages/context/test/unit/resolver.test.ts index bdad238ff2af..0e446796192c 100644 --- a/packages/context/test/unit/resolver.test.ts +++ b/packages/context/test/unit/resolver.test.ts @@ -10,6 +10,8 @@ import { instantiateClass, invokeMethod, Injection, + Getter, + ResolutionSession, } from '../..'; describe('constructor injection', () => { @@ -88,6 +90,190 @@ describe('constructor injection', () => { const t = instantiateClass(TestClass, ctx) as TestClass; expect(t.fooBar).to.eql('FOO:BAR'); }); + + it('reports circular dependencies of two bindings', () => { + const context = new Context(); + interface XInterface {} + interface YInterface {} + + class XClass implements XInterface { + @inject('y') public y: YInterface; + } + + class YClass implements YInterface { + @inject('x') public x: XInterface; + } + + context.bind('x').toClass(XClass); + context.bind('y').toClass(YClass); + expect(() => context.getSync('x')).to.throw( + "Circular dependency detected on path 'x --> y --> x'", + ); + expect(() => context.getSync('y')).to.throw( + "Circular dependency detected on path 'y --> x --> y'", + ); + }); + + it('reports circular dependencies of three bindings', () => { + const context = new Context(); + + // Declare interfaces so that they can be used for typing + interface XInterface {} + interface YInterface {} + interface ZInterface {} + + class XClass { + constructor(@inject('y') public y: YInterface) {} + } + + class YClass { + constructor(@inject('z') public z: ZInterface) {} + } + + class ZClass { + constructor(@inject('x') public x: XInterface) {} + } + + context.bind('x').toClass(XClass); + context.bind('y').toClass(YClass); + context.bind('z').toClass(ZClass); + expect(() => context.getSync('x')).to.throw( + "Circular dependency detected on path 'x --> y --> z --> x'", + ); + expect(() => context.getSync('y')).to.throw( + "Circular dependency detected on path 'y --> z --> x --> y'", + ); + expect(() => context.getSync('z')).to.throw( + "Circular dependency detected on path 'z --> x --> y --> z'", + ); + }); + + // tslint:disable-next-line:max-line-length + it('will not report circular dependencies if a binding is injected twice', () => { + const context = new Context(); + class XClass {} + + class YClass { + constructor( + @inject('x') public a: XClass, + @inject('x') public b: XClass, + ) {} + } + + context.bind('x').toClass(XClass); + context.bind('y').toClass(YClass); + const y: YClass = context.getSync('y'); + expect(y.a).to.be.instanceof(XClass); + expect(y.b).to.be.instanceof(XClass); + }); + + it('tracks path of bindings', () => { + const context = new Context(); + let bindingPath = ''; + let resolutionPath = ''; + + class ZClass { + @inject( + 'p', + {}, + // Set up a custom resolve() to access information from the session + (c: Context, injection: Injection, session: ResolutionSession) => { + bindingPath = session.getBindingPath(); + resolutionPath = session.getResolutionPath(); + }, + ) + myProp: string; + } + + class YClass { + constructor(@inject('z') public z: ZClass) {} + } + + class XClass { + constructor(@inject('y') public y: YClass) {} + } + + context.bind('x').toClass(XClass); + context.bind('y').toClass(YClass); + context.bind('z').toClass(ZClass); + context.getSync('x'); + expect(bindingPath).to.eql('x --> y --> z'); + expect(resolutionPath).to.eql( + 'x --> @XClass.constructor[0] --> y --> @YClass.constructor[0]' + + ' --> z --> @ZClass.prototype.myProp', + ); + }); + + it('tracks path of bindings for @inject.getter', async () => { + const context = new Context(); + let bindingPath = ''; + let resolutionPath = ''; + + class ZClass { + @inject( + 'p', + {}, + // Set up a custom resolve() to access information from the session + (c: Context, injection: Injection, session: ResolutionSession) => { + bindingPath = session.getBindingPath(); + resolutionPath = session.getResolutionPath(); + }, + ) + myProp: string; + } + + class YClass { + constructor(@inject.getter('z') public z: Getter) {} + } + + class XClass { + constructor(@inject('y') public y: YClass) {} + } + + context.bind('x').toClass(XClass); + context.bind('y').toClass(YClass); + context.bind('z').toClass(ZClass); + const x: XClass = context.getSync('x'); + await x.y.z(); + expect(bindingPath).to.eql('x --> y --> z'); + expect(resolutionPath).to.eql( + 'x --> @XClass.constructor[0] --> y --> @YClass.constructor[0]' + + ' --> z --> @ZClass.prototype.myProp', + ); + }); + + it('tracks path of injections', () => { + const context = new Context(); + let injectionPath = ''; + + class ZClass { + @inject( + 'p', + {}, + // Set up a custom resolve() to access information from the session + (c: Context, injection: Injection, session: ResolutionSession) => { + injectionPath = session.getInjectionPath(); + }, + ) + myProp: string; + } + + class YClass { + constructor(@inject('z') public z: ZClass) {} + } + + class XClass { + constructor(@inject('y') public y: YClass) {} + } + + context.bind('x').toClass(XClass); + context.bind('y').toClass(YClass); + context.bind('z').toClass(ZClass); + context.getSync('x'); + expect(injectionPath).to.eql( + 'XClass.constructor[0] --> YClass.constructor[0] --> ZClass.prototype.myProp', + ); + }); }); describe('async constructor injection', () => { @@ -285,6 +471,53 @@ describe('async constructor & sync property injection', () => { }); }); +describe('async constructor injection with errors', () => { + let ctx: Context; + + before(function() { + ctx = new Context(); + ctx.bind('foo').toDynamicValue( + () => + new Promise((resolve, reject) => { + setImmediate(() => { + reject(new Error('foo: error')); + }); + }), + ); + }); + + it('resolves properties and constructor arguments', async () => { + class TestClass { + constructor(@inject('foo') public foo: string) {} + } + + await expect(instantiateClass(TestClass, ctx)).to.be.rejectedWith( + 'foo: error', + ); + }); +}); + +describe('async property injection with errors', () => { + let ctx: Context; + + before(function() { + ctx = new Context(); + ctx.bind('bar').toDynamicValue(async () => { + throw new Error('bar: error'); + }); + }); + + it('resolves properties and constructor arguments', async () => { + class TestClass { + @inject('bar') bar: string; + } + + await expect(instantiateClass(TestClass, ctx)).to.be.rejectedWith( + 'bar: error', + ); + }); +}); + describe('sync constructor & async property injection', () => { let ctx: Context; @@ -308,7 +541,6 @@ describe('sync constructor & async property injection', () => { }); function customDecorator(def: Object) { - // tslint:disable-next-line:no-any return inject('foo', def, (c: Context, injection: Injection) => { const barKey = injection.metadata && injection.metadata.x; const b = c.getSync(barKey); @@ -318,7 +550,6 @@ function customDecorator(def: Object) { } function customAsyncDecorator(def: Object) { - // tslint:disable-next-line:no-any return inject('foo', def, async (c: Context, injection: Injection) => { const barKey = injection.metadata && injection.metadata.x; const b = await c.get(barKey); diff --git a/packages/metadata/src/decorator-factory.ts b/packages/metadata/src/decorator-factory.ts index bbd9a5fd3450..ebb08a9ec84a 100644 --- a/packages/metadata/src/decorator-factory.ts +++ b/packages/metadata/src/decorator-factory.ts @@ -123,12 +123,22 @@ export class DecoratorFactory< } /** - * Get name of a decoration target + * Get the qualified name of a decoration target. For example: + * ``` + * class MyClass + * MyClass.constructor[0] // First parameter of the constructor + * MyClass.myStaticProperty + * MyClass.myStaticMethod() + * MyClass.myStaticMethod[0] // First parameter of the myStaticMethod + * MyClass.prototype.myProperty + * MyClass.prototype.myMethod() + * MyClass.prototype.myMethod[1] // Second parameter of myMethod + * ``` * @param target Class or prototype of a class * @param member Optional property/method name * @param descriptorOrIndex Optional method descriptor or parameter index */ - getTargetName( + static getTargetName( target: Object, member?: string | symbol, descriptorOrIndex?: TypedPropertyDescriptor | number, @@ -136,25 +146,18 @@ export class DecoratorFactory< let name = target instanceof Function ? target.name - : target.constructor.name + '.prototype'; + : `${target.constructor.name}.prototype`; if (member == null && descriptorOrIndex == null) { - return 'class ' + name; + return `class ${name}`; } if (member == null) member = 'constructor'; if (typeof descriptorOrIndex === 'number') { // Parameter - name = - 'parameter ' + - name + - '.' + - member.toString() + - '[' + - descriptorOrIndex + - ']'; + name = `${name}.${member}[${descriptorOrIndex}]`; } else if (descriptorOrIndex != null) { - name = 'method ' + name + '.' + member.toString(); + name = `${name}.${member}()`; } else { - name = 'property ' + name + '.' + member.toString(); + name = `${name}.${member}`; } return name; } @@ -164,8 +167,8 @@ export class DecoratorFactory< * @param target Class or the prototype * @param member Method name */ - getNumberOfParameters(target: Object, member?: string | symbol) { - if (target instanceof Function && member == null) { + static getNumberOfParameters(target: Object, member?: string | symbol) { + if (target instanceof Function && !member) { // constructor return target.length; } else { @@ -268,7 +271,11 @@ export class DecoratorFactory< member?: string | symbol, descriptorOrIndex?: TypedPropertyDescriptor | number, ) { - const targetName = this.getTargetName(target, member, descriptorOrIndex); + const targetName = DecoratorFactory.getTargetName( + target, + member, + descriptorOrIndex, + ); let meta: M = Reflector.getOwnMetadata(this.key, target); if (meta == null && this.allowInheritance()) { // Clone the base metadata so that it won't be accidentally @@ -349,7 +356,7 @@ export class ClassDecoratorFactory extends DecoratorFactory< if (ownMetadata != null) { throw new Error( 'Decorator cannot be applied more than once on ' + - this.getTargetName(target), + DecoratorFactory.getTargetName(target), ); } return this.withTarget(this.spec, target); @@ -401,7 +408,7 @@ export class PropertyDecoratorFactory extends DecoratorFactory< ) { ownMetadata = ownMetadata || {}; if (ownMetadata[propertyName!] != null) { - const targetName = this.getTargetName(target, propertyName); + const targetName = DecoratorFactory.getTargetName(target, propertyName); throw new Error( 'Decorator cannot be applied more than once on ' + targetName, ); @@ -464,7 +471,7 @@ export class MethodDecoratorFactory extends DecoratorFactory< if (this.getTarget(methodMeta) === target) { throw new Error( 'Decorator cannot be applied more than once on ' + - this.getTargetName(target, methodName, methodDescriptor), + DecoratorFactory.getTargetName(target, methodName, methodDescriptor), ); } // Set the method metadata @@ -513,7 +520,7 @@ export class ParameterDecoratorFactory extends DecoratorFactory< if (methodMeta == null) { // Initialize the method metadata methodMeta = new Array( - this.getNumberOfParameters(target, methodName), + DecoratorFactory.getNumberOfParameters(target, methodName), ).fill(undefined); meta[method] = methodMeta; } @@ -553,7 +560,7 @@ export class ParameterDecoratorFactory extends DecoratorFactory< if (this.getTarget(methodMeta[index]) === target) { throw new Error( 'Decorator cannot be applied more than once on ' + - this.getTargetName(target, methodName, parameterIndex), + DecoratorFactory.getTargetName(target, methodName, parameterIndex), ); } // Set the parameter metadata @@ -614,10 +621,13 @@ export class MethodParameterDecoratorFactory extends DecoratorFactory< methodName?: string | symbol, methodDescriptor?: TypedPropertyDescriptor | number, ) { - const numOfParams = this.getNumberOfParameters(target, methodName); + const numOfParams = DecoratorFactory.getNumberOfParameters( + target, + methodName, + ); // Fetch the cached parameter index let index = Reflector.getOwnMetadata( - this.key + ':index', + `${this.key}:index`, target, methodName, ); @@ -625,7 +635,11 @@ export class MethodParameterDecoratorFactory extends DecoratorFactory< if (index == null) index = numOfParams - 1; if (index < 0) { // Excessive decorations than the number of parameters detected - const method = this.getTargetName(target, methodName, methodDescriptor); + const method = DecoratorFactory.getTargetName( + target, + methodName, + methodDescriptor, + ); throw new Error( `The decorator is used more than ${numOfParams} time(s) on ${method}`, ); @@ -653,7 +667,7 @@ export class MethodParameterDecoratorFactory extends DecoratorFactory< } // Cache the index to help us position the next parameter Reflector.defineMetadata( - this.key + ':index', + `${this.key}:index`, index - 1, target, methodName, @@ -677,7 +691,7 @@ export class MethodParameterDecoratorFactory extends DecoratorFactory< ownMetadata[methodName!] = params; // Cache the index to help us position the next parameter Reflector.defineMetadata( - this.key + ':index', + `${this.key}:index`, index - 1, target, methodName, diff --git a/packages/metadata/test/unit/decorator-factory.test.ts b/packages/metadata/test/unit/decorator-factory.test.ts index e668ede2bca9..6870fac96c1c 100644 --- a/packages/metadata/test/unit/decorator-factory.test.ts +++ b/packages/metadata/test/unit/decorator-factory.test.ts @@ -329,7 +329,7 @@ describe('PropertyDecoratorFactory', () => { myProp: string; } }).to.throw( - /Decorator cannot be applied more than once on property MyController\.prototype\.myProp/, + /Decorator cannot be applied more than once on MyController\.prototype\.myProp/, ); }); }); @@ -377,7 +377,7 @@ describe('PropertyDecoratorFactory for static properties', () => { static myProp: string; } }).to.throw( - /Decorator cannot be applied more than once on property MyController\.myProp/, + /Decorator cannot be applied more than once on MyController\.myProp/, ); }); }); @@ -425,7 +425,7 @@ describe('MethodDecoratorFactory', () => { myMethod() {} } }).to.throw( - /Decorator cannot be applied more than once on method MyController\.prototype\.myMethod/, + /Decorator cannot be applied more than once on MyController\.prototype\.myMethod\(\)/, ); }); }); @@ -473,7 +473,7 @@ describe('MethodDecoratorFactory for static methods', () => { static myMethod() {} } }).to.throw( - /Decorator cannot be applied more than once on method MyController\.myMethod/, + /Decorator cannot be applied more than once on MyController\.myMethod\(\)/, ); }); }); @@ -530,7 +530,7 @@ describe('ParameterDecoratorFactory', () => { ) {} } }).to.throw( - /Decorator cannot be applied more than once on parameter MyController\.prototype\.myMethod\[0\]/, + /Decorator cannot be applied more than once on MyController\.prototype\.myMethod\[0\]/, ); }); }); @@ -631,7 +631,7 @@ describe('ParameterDecoratorFactory for a static method', () => { ) {} } }).to.throw( - /Decorator cannot be applied more than once on parameter MyController\.myMethod\[0\]/, + /Decorator cannot be applied more than once on MyController\.myMethod\[0\]/, ); }); }); @@ -691,7 +691,7 @@ describe('MethodParameterDecoratorFactory with invalid decorations', () => { myMethod(a: string, b: number) {} } }).to.throw( - /The decorator is used more than 2 time\(s\) on method MyController\.prototype\.myMethod/, + /The decorator is used more than 2 time\(s\) on MyController\.prototype\.myMethod\(\)/, ); }); });