diff --git a/docs/site/Interceptors.md b/docs/site/Interceptors.md index 2853253c2dd0..3bbb1fe0ffec 100644 --- a/docs/site/Interceptors.md +++ b/docs/site/Interceptors.md @@ -72,8 +72,8 @@ const msg = await proxy.greet('John'); ``` There is also an `asProxyWithInterceptors` option for binding resolution or -dependency injection to return a proxy to apply interceptors as methods are -invoked. +dependency injection to return a proxy for the class to apply interceptors when +methods are invoked. ```ts class DummyController { @@ -92,12 +92,40 @@ const msg = await dummyController.myController.greet('John'); Or: ```ts -const proxy = await ctx.get( - 'my-controller', - {asProxyWithInterceptors: true}, +const proxy = await ctx.get('my-controller', { + asProxyWithInterceptors: true, +}); const msg = await proxy.greet('John'); ``` +Please note synchronous methods (which don't return `Promise`) are converted to +be asynchronous in the proxy so that interceptors can be applied. For example, + +```ts +class MyController { + name: string; + + greet(name: string): string { + return `Hello, ${name}`; + } + + async hello(name: string) { + return `Hello, ${name}`; + } +} +``` + +The proxy from an instance of `MyController` has the `AsyncProxy` +type: + +```ts +{ + name: string; // the same as MyController + greet(name: string): Promise; // the return type becomes `Promise` + hello(name: string): Promise; // the same as MyController +} +``` + ### Use `invokeMethod` to apply interceptors To explicitly invoke a method with interceptors, use `invokeMethod` from @@ -129,7 +157,7 @@ used by `invokeMethod` or `invokeWithMethodWithInterceptors` functions to trigger interceptors around the target method. The original method stays intact. Invoking it directly won't apply any interceptors. -### @intercept +### `@intercept` Syntax: `@intercept(...interceptorFunctionsOrBindingKeys)` diff --git a/packages/context/src/__tests__/acceptance/interception-proxy.acceptance.ts b/packages/context/src/__tests__/acceptance/interception-proxy.acceptance.ts new file mode 100644 index 000000000000..80f51be56db2 --- /dev/null +++ b/packages/context/src/__tests__/acceptance/interception-proxy.acceptance.ts @@ -0,0 +1,162 @@ +// Copyright IBM Corp. 2019. 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 {expect} from '@loopback/testlab'; +import { + AsyncProxy, + Context, + createProxyWithInterceptors, + inject, + intercept, + Interceptor, +} from '../..'; + +describe('Interception proxy', () => { + let ctx: Context; + + beforeEach(givenContextAndEvents); + + it('invokes async interceptors on an async method', async () => { + // Apply `log` to all methods on the class + @intercept(log) + class MyController { + // Apply multiple interceptors. The order of `log` will be preserved as it + // explicitly listed at method level + @intercept(convertName, log) + async greet(name: string) { + return `Hello, ${name}`; + } + } + const proxy = createProxyWithInterceptors(new MyController(), ctx); + const msg = await proxy.greet('John'); + expect(msg).to.equal('Hello, JOHN'); + expect(events).to.eql([ + 'convertName: before-greet', + 'log: before-greet', + 'log: after-greet', + 'convertName: after-greet', + ]); + }); + + it('creates a proxy that converts sync method to be async', async () => { + // Apply `log` to all methods on the class + @intercept(log) + class MyController { + // Apply multiple interceptors. The order of `log` will be preserved as it + // explicitly listed at method level + @intercept(convertName, log) + greet(name: string) { + return `Hello, ${name}`; + } + } + const proxy = createProxyWithInterceptors(new MyController(), ctx); + const msg = await proxy.greet('John'); + expect(msg).to.equal('Hello, JOHN'); + expect(events).to.eql([ + 'convertName: before-greet', + 'log: before-greet', + 'log: after-greet', + 'convertName: after-greet', + ]); + }); + + it('invokes interceptors on a static method', async () => { + // Apply `log` to all methods on the class + @intercept(log) + class MyController { + // The class level `log` will be applied + static greetStatic(name: string) { + return `Hello, ${name}`; + } + } + ctx.bind('name').to('John'); + const proxy = createProxyWithInterceptors(MyController, ctx); + const msg = await proxy.greetStatic('John'); + expect(msg).to.equal('Hello, John'); + expect(events).to.eql([ + 'log: before-greetStatic', + 'log: after-greetStatic', + ]); + }); + + it('supports asProxyWithInterceptors resolution option', async () => { + // Apply `log` to all methods on the class + @intercept(log) + class MyController { + // Apply multiple interceptors. The order of `log` will be preserved as it + // explicitly listed at method level + @intercept(convertName, log) + async greet(name: string) { + return `Hello, ${name}`; + } + } + ctx.bind('my-controller').toClass(MyController); + const proxy = await ctx.get('my-controller', { + asProxyWithInterceptors: true, + }); + const msg = await proxy!.greet('John'); + expect(msg).to.equal('Hello, JOHN'); + expect(events).to.eql([ + 'convertName: before-greet', + 'log: before-greet', + 'log: after-greet', + 'convertName: after-greet', + ]); + }); + + it('supports asProxyWithInterceptors resolution option for @inject', async () => { + // Apply `log` to all methods on the class + @intercept(log) + class MyController { + // Apply multiple interceptors. The order of `log` will be preserved as it + // explicitly listed at method level + @intercept(convertName, log) + async greet(name: string) { + return `Hello, ${name}`; + } + } + + class DummyController { + constructor( + @inject('my-controller', {asProxyWithInterceptors: true}) + public readonly myController: AsyncProxy, + ) {} + } + ctx.bind('my-controller').toClass(MyController); + ctx.bind('dummy-controller').toClass(DummyController); + const dummyController = await ctx.get('dummy-controller'); + const msg = await dummyController.myController.greet('John'); + expect(msg).to.equal('Hello, JOHN'); + expect(events).to.eql([ + 'convertName: before-greet', + 'log: before-greet', + 'log: after-greet', + 'convertName: after-greet', + ]); + }); + + let events: string[]; + + const log: Interceptor = async (invocationCtx, next) => { + events.push('log: before-' + invocationCtx.methodName); + const result = await next(); + events.push('log: after-' + invocationCtx.methodName); + return result; + }; + + // An interceptor to convert the 1st arg to upper case + const convertName: Interceptor = async (invocationCtx, next) => { + events.push('convertName: before-' + invocationCtx.methodName); + invocationCtx.args[0] = (invocationCtx.args[0] as string).toUpperCase(); + const result = await next(); + events.push('convertName: after-' + invocationCtx.methodName); + return result; + }; + + function givenContextAndEvents() { + ctx = new Context(); + events = []; + } +}); diff --git a/packages/context/src/__tests__/acceptance/interceptor.acceptance.ts b/packages/context/src/__tests__/acceptance/interceptor.acceptance.ts index 8e87f3db006c..c90bd0cd8f34 100644 --- a/packages/context/src/__tests__/acceptance/interceptor.acceptance.ts +++ b/packages/context/src/__tests__/acceptance/interceptor.acceptance.ts @@ -5,10 +5,8 @@ import {expect} from '@loopback/testlab'; import { - asInterceptor, - AsyncProxy, + asGlobalInterceptor, Context, - createProxyWithInterceptors, inject, intercept, Interceptor, @@ -95,7 +93,7 @@ describe('Interceptor', () => { 'John', ]); expect(msg).to.equal('Hello, John'); - expect( + await expect( invokeMethodWithInterceptors(ctx, controller, 'greet', ['Smith']), ).to.be.rejectedWith(/Name 'Smith' is not on the list/); }); @@ -339,129 +337,6 @@ describe('Interceptor', () => { }); }); - context('proxy with interceptors', () => { - it('invokes async interceptors on an async method', async () => { - // Apply `log` to all methods on the class - @intercept(log) - class MyController { - // Apply multiple interceptors. The order of `log` will be preserved as it - // explicitly listed at method level - @intercept(convertName, log) - async greet(name: string) { - return `Hello, ${name}`; - } - } - const proxy = createProxyWithInterceptors(new MyController(), ctx); - const msg = await proxy.greet('John'); - expect(msg).to.equal('Hello, JOHN'); - expect(events).to.eql([ - 'convertName: before-greet', - 'log: before-greet', - 'log: after-greet', - 'convertName: after-greet', - ]); - }); - - it('creates a proxy that converts sync method to be async', async () => { - // Apply `log` to all methods on the class - @intercept(log) - class MyController { - // Apply multiple interceptors. The order of `log` will be preserved as it - // explicitly listed at method level - @intercept(convertName, log) - greet(name: string) { - return `Hello, ${name}`; - } - } - const proxy = createProxyWithInterceptors(new MyController(), ctx); - const msg = await proxy.greet('John'); - expect(msg).to.equal('Hello, JOHN'); - expect(events).to.eql([ - 'convertName: before-greet', - 'log: before-greet', - 'log: after-greet', - 'convertName: after-greet', - ]); - }); - - it('invokes interceptors on a static method', async () => { - // Apply `log` to all methods on the class - @intercept(log) - class MyController { - // The class level `log` will be applied - static greetStatic(name: string) { - return `Hello, ${name}`; - } - } - ctx.bind('name').to('John'); - const proxy = createProxyWithInterceptors(MyController, ctx); - const msg = await proxy.greetStatic('John'); - expect(msg).to.equal('Hello, John'); - expect(events).to.eql([ - 'log: before-greetStatic', - 'log: after-greetStatic', - ]); - }); - - it('supports asProxyWithInterceptors resolution option', async () => { - // Apply `log` to all methods on the class - @intercept(log) - class MyController { - // Apply multiple interceptors. The order of `log` will be preserved as it - // explicitly listed at method level - @intercept(convertName, log) - async greet(name: string) { - return `Hello, ${name}`; - } - } - ctx.bind('my-controller').toClass(MyController); - const proxy = await ctx.get('my-controller', { - asProxyWithInterceptors: true, - }); - const msg = await proxy!.greet('John'); - expect(msg).to.equal('Hello, JOHN'); - expect(events).to.eql([ - 'convertName: before-greet', - 'log: before-greet', - 'log: after-greet', - 'convertName: after-greet', - ]); - }); - - it('supports asProxyWithInterceptors resolution option for @inject', async () => { - // Apply `log` to all methods on the class - @intercept(log) - class MyController { - // Apply multiple interceptors. The order of `log` will be preserved as it - // explicitly listed at method level - @intercept(convertName, log) - async greet(name: string) { - return `Hello, ${name}`; - } - } - - class DummyController { - constructor( - @inject('my-controller', {asProxyWithInterceptors: true}) - public readonly myController: AsyncProxy, - ) {} - } - ctx.bind('my-controller').toClass(MyController); - ctx.bind('dummy-controller').toClass(DummyController); - const dummyController = await ctx.get( - 'dummy-controller', - ); - const msg = await dummyController.myController.greet('John'); - expect(msg).to.equal('Hello, JOHN'); - expect(events).to.eql([ - 'convertName: before-greet', - 'log: before-greet', - 'log: after-greet', - 'convertName: after-greet', - ]); - }); - }); - context('global interceptors', () => { beforeEach(givenGlobalInterceptor); @@ -576,7 +451,7 @@ describe('Interceptor', () => { ctx .bind('globalLog') .to(globalLog) - .apply(asInterceptor); + .apply(asGlobalInterceptor); } }); diff --git a/packages/context/src/__tests__/unit/interceptor.unit.ts b/packages/context/src/__tests__/unit/interceptor.unit.ts new file mode 100644 index 000000000000..f4577a4f765c --- /dev/null +++ b/packages/context/src/__tests__/unit/interceptor.unit.ts @@ -0,0 +1,33 @@ +// Copyright IBM Corp. 2019. 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 {expect} from '@loopback/testlab'; +import {InterceptorOrKey, mergeInterceptors} from '../..'; + +describe('mergeInterceptors', () => { + it('removes duplicate entries from the spec', () => { + assertMergeAsExpected(['log'], ['cache', 'log'], ['cache', 'log']); + assertMergeAsExpected(['log'], ['log', 'cache'], ['log', 'cache']); + }); + + it('allows empty array for interceptors', () => { + assertMergeAsExpected([], ['cache', 'log'], ['cache', 'log']); + assertMergeAsExpected(['cache', 'log'], [], ['cache', 'log']); + }); + + it('joins two arrays for interceptors', () => { + assertMergeAsExpected(['cache'], ['log'], ['cache', 'log']); + }); + + function assertMergeAsExpected( + interceptorsFromSpec: InterceptorOrKey[], + existingInterceptors: InterceptorOrKey[], + expectedResult: InterceptorOrKey[], + ) { + expect( + mergeInterceptors(interceptorsFromSpec, existingInterceptors), + ).to.eql(expectedResult); + } +}); diff --git a/packages/context/src/binding.ts b/packages/context/src/binding.ts index 2b2d0c7f7279..821640c57a52 100644 --- a/packages/context/src/binding.ts +++ b/packages/context/src/binding.ts @@ -6,7 +6,7 @@ import * as debugFactory from 'debug'; import {BindingAddress, BindingKey} from './binding-key'; import {Context} from './context'; -import {createProxyWithInterceptors} from './interceptor'; +import {createProxyWithInterceptors} from './interception-proxy'; import {Provider} from './provider'; import { asResolutionOptions, @@ -507,14 +507,7 @@ export class Binding { this._setValueGetter((ctx, options) => { const instOrPromise = instantiateClass(ctor, ctx, options.session); if (!options.asProxyWithInterceptors) return instOrPromise; - return transformValueOrPromise(instOrPromise, inst => { - if (typeof inst !== 'object') return inst; - return (createProxyWithInterceptors( - // Cast inst from `T` to `object` - (inst as unknown) as object, - ctx, - ) as unknown) as T; - }); + return createInterceptionProxyFromInstance(instOrPromise, ctx); }); this._valueConstructor = ctor; return this; @@ -590,3 +583,17 @@ export class Binding { return new Binding(key.toString()); } } + +function createInterceptionProxyFromInstance( + instOrPromise: ValueOrPromise, + context: Context, +) { + return transformValueOrPromise(instOrPromise, inst => { + if (typeof inst !== 'object') return inst; + return (createProxyWithInterceptors( + // Cast inst from `T` to `object` + (inst as unknown) as object, + context, + ) as unknown) as T; + }); +} diff --git a/packages/context/src/index.ts b/packages/context/src/index.ts index dc0f3e4da90b..70eb7613aab3 100644 --- a/packages/context/src/index.ts +++ b/packages/context/src/index.ts @@ -13,6 +13,7 @@ export * from './context'; export * from './context-observer'; export * from './context-view'; export * from './inject'; +export * from './interception-proxy'; export * from './interceptor'; export * from './keys'; export * from './provider'; diff --git a/packages/context/src/inject.ts b/packages/context/src/inject.ts index 16e15eb3735e..6e4bdcf641a1 100644 --- a/packages/context/src/inject.ts +++ b/packages/context/src/inject.ts @@ -405,12 +405,12 @@ function resolveAsGetter( const bindingSelector = injection.bindingSelector as BindingAddress; // We need to clone the session for the getter as it will be resolved later const forkedSession = ResolutionSession.fork(session); + const options: ResolutionOptions = { + session: forkedSession, + ...injection.metadata, + }; return function getter() { - return ctx.get(bindingSelector, { - session: forkedSession, - optional: injection.metadata.optional, - asProxyWithInterceptors: injection.metadata.asProxyWithInterceptors, - }); + return ctx.get(bindingSelector, options); }; } diff --git a/packages/context/src/interception-proxy.ts b/packages/context/src/interception-proxy.ts new file mode 100644 index 000000000000..abd1cbfb4e4f --- /dev/null +++ b/packages/context/src/interception-proxy.ts @@ -0,0 +1,92 @@ +// Copyright IBM Corp. 2019. 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 {Context} from './context'; +import {InvocationArgs, invokeMethodWithInterceptors} from './interceptor'; + +/** + * The Promise type for `T`. If `T` extends `Promise`, the type is `T`, + * otherwise the type is `Promise`. + */ +export type PromiseType = T extends Promise ? T : Promise; + +/** + * The async variant of a function to always return Promise. If T is not a + * function, the type is `T`. + */ +// tslint:disable-next-line:no-unused (possible tslint bug to treat `R` as unused) +export type AsyncType = T extends (...args: InvocationArgs) => infer R + ? (...args: InvocationArgs) => PromiseType + : T; + +/** + * The proxy type for `T`. The return type for any method of `T` with original + * return type `R` becomes `Promise` if `R` does not extend `Promise`. + * Property types stay untouched. For example: + * + * ```ts + * class MyController { + * name: string; + * + * greet(name: string): string { + * return `Hello, ${name}`; + * } + * + * async hello(name: string) { + * return `Hello, ${name}`; + * } + * } + * ``` + * + * `AsyncProxy` will be: + * ```ts + * { + * name: string; // the same as MyController + * greet(name: string): Promise; // the return type becomes `Promise` + * hello(name: string): Promise; // the same as MyController + * } + * ``` + */ +export type AsyncProxy = {[P in keyof T]: AsyncType}; + +/** + * A proxy handler that applies interceptors + * + * See https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Proxy + */ +export class InterceptionHandler implements ProxyHandler { + constructor(private context = new Context()) {} + + get(target: T, propertyName: PropertyKey, receiver: unknown) { + // tslint:disable-next-line:no-any + const targetObj = target as any; + if (typeof propertyName !== 'string') return targetObj[propertyName]; + const propertyOrMethod = targetObj[propertyName]; + if (typeof propertyOrMethod === 'function') { + return (...args: InvocationArgs) => { + return invokeMethodWithInterceptors( + this.context, + target, + propertyName, + args, + ); + }; + } else { + return propertyOrMethod; + } + } +} + +/** + * Create a proxy that applies interceptors for method invocations + * @param target Target class or object + * @param context Context object + */ +export function createProxyWithInterceptors( + target: T, + context?: Context, +): AsyncProxy { + return new Proxy(target, new InterceptionHandler(context)) as AsyncProxy; +} diff --git a/packages/context/src/interceptor.ts b/packages/context/src/interceptor.ts index 19eec2f9c318..52afa86601d7 100644 --- a/packages/context/src/interceptor.ts +++ b/packages/context/src/interceptor.ts @@ -19,15 +19,21 @@ import {BindingAddress} from './binding-key'; import {Context} from './context'; import {ContextTags} from './keys'; import {transformValueOrPromise, ValueOrPromise} from './value-promise'; -const debug = debugFactory('loopback:context:intercept'); +const debug = debugFactory('loopback:context:interceptor'); const getTargetName = DecoratorFactory.getTargetName; /** - * Array of arguments + * Array of arguments for a method invocation */ // tslint:disable-next-line:no-any export type InvocationArgs = any[]; +/** + * Return value for a method invocation + */ +// tslint:disable-next-line:no-any +export type InvocationResult = any; + /** * A type for class or its prototype */ @@ -43,7 +49,8 @@ export class InvocationContext extends Context { /** * Construct a new instance of `InvocationContext` * @param parent Parent context, such as the RequestContext - * @param target Target class (for static methods) or object (for instance methods) + * @param target Target class (for static methods) or prototype/object + * (for instance methods) * @param methodName Method name * @param args An array of arguments */ @@ -57,10 +64,13 @@ export class InvocationContext extends Context { } /** - * Discover all binding keys for global interceptors + * Discover all binding keys for global interceptors (tagged by + * ContextTags.GLOBAL_INTERCEPTOR) */ getGlobalInterceptorBindingKeys(): string[] { - return this.find(filterByTag(ContextTags.INTERCEPTOR)).map(b => b.key); + return this.find(filterByTag(ContextTags.GLOBAL_INTERCEPTOR)).map( + b => b.key, + ); } } @@ -69,22 +79,28 @@ export class InvocationContext extends Context { * by tagging it with `ContextTags.INTERCEPTOR` * @param binding Binding object */ -export function asInterceptor(binding: Binding) { - return binding.tag(ContextTags.INTERCEPTOR); +export function asGlobalInterceptor(binding: Binding) { + return binding.tag(ContextTags.GLOBAL_INTERCEPTOR); } /** - * Interceptor function + * Interceptor function to intercept method invocations */ export interface Interceptor { - ( + /** + * @param context Invocation context + * @param next A function to invoke next interceptor or the target method + * @returns A result as value or promise + */ + ( context: InvocationContext, - next: () => ValueOrPromise, - ): ValueOrPromise; + next: () => ValueOrPromise, + ): ValueOrPromise; } /** - * Interceptor or binding key that can be used as arguments for `@intercept` + * Interceptor function or binding key that can be used as parameters for + * `@intercept()` */ export type InterceptorOrKey = BindingAddress | Interceptor; @@ -98,7 +114,7 @@ export const INTERCEPT_METHOD_KEY = MetadataAccessor.create< /** * Adding interceptors from the spec to the front of existing ones. Duplicate - * entries are eliminated. + * entries are eliminated from the spec side. * * For example: * @@ -108,10 +124,10 @@ export const INTERCEPT_METHOD_KEY = MetadataAccessor.create< * - [cache, log] + [] => [cache, log] * - [log] + [cache] => [log, cache] * - * @param interceptorsFromSpec - * @param existingInterceptors + * @param interceptorsFromSpec Interceptors from `@intercept` + * @param existingInterceptors Interceptors already applied for the method */ -function mergeInterceptors( +export function mergeInterceptors( interceptorsFromSpec: InterceptorOrKey[], existingInterceptors: InterceptorOrKey[], ) { @@ -138,6 +154,10 @@ export const INTERCEPT_CLASS_KEY = MetadataAccessor.create< ClassDecorator >('intercept:class'); +/** + * A factory to define `@intercept` for classes. It allows `@intercept` to be + * used multiple times on the same class. + */ class InterceptClassDecoratorFactory extends ClassDecoratorFactory< InterceptorOrKey[] > { @@ -147,6 +167,10 @@ class InterceptClassDecoratorFactory extends ClassDecoratorFactory< } } +/** + * A factory to define `@intercept` for methods. It allows `@intercept` to be + * used multiple times on the same method. + */ class InterceptMethodDecoratorFactory extends MethodDecoratorFactory< InterceptorOrKey[] > { @@ -217,21 +241,16 @@ export function intercept(...interceptorOrKeys: InterceptorOrKey[]) { /** * Invoke a method with the given context * @param context Context object - * @param Target Target class (for static methods) or object (for instance methods) + * @param target Target class (for static methods) or object (for instance methods) * @param methodName Method name - * @param args Argument values + * @param args An array of argument values */ export function invokeMethodWithInterceptors( context: Context, target: object, methodName: string, args: InvocationArgs, -) { - const targetWithMethods = target as Record; - assert( - typeof targetWithMethods[methodName] === 'function', - `Method ${methodName} not found`, - ); +): ValueOrPromise { const invocationCtx = new InvocationContext( context, target, @@ -239,31 +258,79 @@ export function invokeMethodWithInterceptors( args, ); + assertMethodExists(invocationCtx); + const interceptors = loadInterceptors(invocationCtx); + return invokeInterceptors(invocationCtx, interceptors); +} + +/** + * Load all interceptors for the given invocation context. It adds + * interceptors from possibly three sources: + * 1. method level `@intercept` + * 2. class level `@intercept` + * 3. global interceptors discovered in the context + * + * @param invocationCtx Invocation context + */ +function loadInterceptors(invocationCtx: InvocationContext) { let interceptors = MetadataInspector.getMethodMetadata( INTERCEPT_METHOD_KEY, - target, - methodName, + invocationCtx.target, + invocationCtx.methodName, ) || []; - - let targetClass: Function; - if (typeof target === 'function') { - targetClass = target; - } else { - targetClass = target.constructor; - } + const targetClass = + typeof invocationCtx.target === 'function' + ? invocationCtx.target + : invocationCtx.target.constructor; const classInterceptors = MetadataInspector.getClassMetadata(INTERCEPT_CLASS_KEY, targetClass) || []; - // Inserting class level interceptors before method level ones interceptors = mergeInterceptors(classInterceptors, interceptors); - const globalInterceptors = invocationCtx.getGlobalInterceptorBindingKeys(); - // Inserting global interceptors interceptors = mergeInterceptors(globalInterceptors, interceptors); + return interceptors; +} - return invokeInterceptors(invocationCtx, interceptors); +/** + * Invoke the target method with the given context + * @param context Invocation context + */ +function invokeTargetMethod(context: InvocationContext) { + const targetWithMethods = assertMethodExists(context); + /* istanbul ignore if */ + if (debug.enabled) { + debug( + 'Invoking method %s', + getTargetName(context.target, context.methodName), + context.args, + ); + } + // Invoke the target method + const result = targetWithMethods[context.methodName](...context.args); + /* istanbul ignore if */ + if (debug.enabled) { + debug( + 'Method invoked: %s', + getTargetName(context.target, context.methodName), + result, + ); + } + return result; +} + +/** + * Assert the method exists on the target. An error will be thrown if otherwise. + * @param context Invocation context + */ +function assertMethodExists(context: InvocationContext) { + const targetWithMethods = context.target as Record; + if (typeof targetWithMethods[context.methodName] !== 'function') { + const targetName = getTargetName(context.target, context.methodName); + assert(false, `Method ${targetName} not found`); + } + return targetWithMethods; } /** @@ -274,127 +341,52 @@ export function invokeMethodWithInterceptors( function invokeInterceptors( context: InvocationContext, interceptors: InterceptorOrKey[], -) { +): ValueOrPromise { let index = 0; - const next: () => ValueOrPromise = () => { + return next(); + + /** + * Invoke downstream interceptors or the target method + */ + function next(): ValueOrPromise { // No more interceptors if (index === interceptors.length) { - const targetWithMethods = context.target as Record; - assert( - typeof targetWithMethods[context.methodName] === 'function', - `Method ${context.methodName} not found`, - ); - /* istanbul ignore if */ - if (debug.enabled) { - debug( - 'Invoking method %s', - getTargetName(context.target, context.methodName), - context.args, - ); - } - // Invoke the target method - return targetWithMethods[context.methodName](...context.args); + return invokeTargetMethod(context); } + return invokeNextInterceptor(); + } + + /** + * Invoke downstream interceptors + */ + function invokeNextInterceptor(): ValueOrPromise { const interceptor = interceptors[index++]; - let interceptorFn: ValueOrPromise; - if (typeof interceptor !== 'function') { - debug('Resolving interceptor binding %s', interceptor); - interceptorFn = context.getValueOrPromise(interceptor) as ValueOrPromise< - Interceptor - >; - } else { - interceptorFn = interceptor; - } + const interceptorFn = loadInterceptor(interceptor); return transformValueOrPromise(interceptorFn, fn => { /* istanbul ignore if */ if (debug.enabled) { debug( - 'Invoking interceptor %d on %s', + 'Invoking interceptor %d (%s) on %s', index - 1, + fn.name, getTargetName(context.target, context.methodName), context.args, ); } return fn(context, next); }); - }; - return next(); -} - -/** - * The Promise type for `T`. If `T` extends `Promise`, the type is `T`, - * otherwise the type is `Promise`. - */ -export type PromiseType = T extends Promise ? T : Promise; - -/** - * The async variant of a function to always return Promise. If T is not a - * function, the type is `T`. - */ -// tslint:disable-next-line:no-unused (possible tslint bug to treat `R` as unused) -export type AsyncType = T extends (...args: InvocationArgs) => infer R - ? (...args: InvocationArgs) => PromiseType - : T; - -/** - * The proxy type for `T`. The return type for any method of `T` with original - * return type `R` becomes `Promise` if `R` does not extend `Promise`. - * Property types stay untouched. For example: - * - * ```ts - * class MyController { - * name: string; - * - * greet(name: string): string { - * return `Hello, ${name}`; - * } - * - * async hello(name: string) { - * return `Hello, ${name}`; - * } - * } - * ``` - * - * `AsyncProxy` will be: - * ```ts - * { - * name: string; // the same as MyController - * greet(name: string): Promise; // the return type becomes `Promise` - * hello(name: string): Promise; // the same as MyController - * } - * ``` - */ -export type AsyncProxy = {[P in keyof T]: AsyncType}; - -/** - * A proxy handler that applies interceptors - * - * See https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Proxy - */ -export class InterceptorHandler implements ProxyHandler { - constructor(private context = new Context()) {} - get(target: T, p: PropertyKey, receiver: unknown) { - const targetObj = target as ClassOrPrototype; - if (typeof p !== 'string') return targetObj[p]; - const propertyOrMethod = targetObj[p]; - if (typeof propertyOrMethod === 'function') { - return (...args: InvocationArgs) => { - return invokeMethodWithInterceptors(this.context, target, p, args); - }; - } else { - return propertyOrMethod; - } } -} -/** - * Create a proxy that applies interceptors for method invocations - * @param target Target class or object - * @param context Context object - */ -export function createProxyWithInterceptors( - target: T, - context?: Context, -): AsyncProxy { - return new Proxy(target, new InterceptorHandler(context)) as AsyncProxy; + /** + * Return the interceptor function or resolve the interceptor function as a + * binding from the context + * @param interceptor Interceptor function or binding key + */ + function loadInterceptor(interceptor: InterceptorOrKey) { + if (typeof interceptor === 'function') return interceptor; + debug('Resolving interceptor binding %s', interceptor); + return context.getValueOrPromise(interceptor) as ValueOrPromise< + Interceptor + >; + } } diff --git a/packages/context/src/keys.ts b/packages/context/src/keys.ts index 8d4c28199046..2f7d5a15ce93 100644 --- a/packages/context/src/keys.ts +++ b/packages/context/src/keys.ts @@ -27,5 +27,5 @@ export namespace ContextTags { /** * Binding tag for global interceptors */ - export const INTERCEPTOR = 'interceptor'; + export const GLOBAL_INTERCEPTOR = 'globalInterceptor'; } diff --git a/packages/context/src/resolver.ts b/packages/context/src/resolver.ts index 8ea949f11953..fc1c620da3d9 100644 --- a/packages/context/src/resolver.ts +++ b/packages/context/src/resolver.ts @@ -16,7 +16,7 @@ import { Injection, } from './inject'; import {invokeMethodWithInterceptors} from './interceptor'; -import {ResolutionSession} from './resolution-session'; +import {ResolutionOptions, ResolutionSession} from './resolution-session'; import { BoundValue, Constructor, @@ -149,13 +149,11 @@ function resolve( 'The binding selector must be an address (string or BindingKey)', ); const key = injection.bindingSelector as BindingAddress; - return ctx.getValueOrPromise(key, { + const options: ResolutionOptions = { session: s, - // If the `optional` flag is set for the injection, the resolution - // will return `undefined` instead of throwing an error - optional: injection.metadata.optional, - asProxyWithInterceptors: injection.metadata.asProxyWithInterceptors, - }); + ...injection.metadata, + }; + return ctx.getValueOrPromise(key, options); } }, injection,