diff --git a/docs/site/Concepts.md b/docs/site/Concepts.md index 47a4cc99ab18..f3893955682a 100644 --- a/docs/site/Concepts.md +++ b/docs/site/Concepts.md @@ -37,6 +37,9 @@ LoopBack 4 introduces some new concepts that are important to understand: Controller operates only on processed input and abstractions of backend services / databases. +- [**Interceptors**](Interceptors.md): A function that intercepts static or + instance method invocations on a class or object. + - [**Route**](Routes.md): The mapping between your API specification and an Operation. It tells LoopBack which Operation to `invoke()` when given an HTTP request. diff --git a/docs/site/Interceptors.md b/docs/site/Interceptors.md new file mode 100644 index 000000000000..21200ff4e9a6 --- /dev/null +++ b/docs/site/Interceptors.md @@ -0,0 +1,657 @@ +--- +lang: en +title: 'Interceptors' +keywords: LoopBack 4.0, LoopBack 4 +sidebar: lb4_sidebar +permalink: /doc/en/lb4/Interceptors.html +--- + +## Overview + +Interceptors are reusable functions to provide aspect-oriented logic around +method invocations. There are many use cases for interceptors, such as: + +- Add extra logic before / after method invocation, for example, logging or + measuring method invocations. +- Validate/transform arguments +- Validate/transform return values +- Catch/transform errors, for example, normalize error objects +- Override the method invocation, for example, return from cache + +The following diagram illustrates how interceptors can be applied to the +invocation of a method on the controller class. + +![Interceptors](imgs/interceptors.png) + +## Basic use + +### Interceptors on controllers + +Interceptors are supported for public controller methods (including both static +and prototype) and handler functions for REST routes. + +Controller methods decorated with `@intercept` are invoked with applied +interceptors for corresponding routes upon API requests. + +```ts +import {intercept} from '@loopback/context'; + +@intercept(log) // `log` is an interceptor function +export class OrderController { + @intercept('caching-interceptor') // `caching-interceptor` is a binding key + async listOrders(userId: string) { + // ... + } +} +``` + +**NOTE**: `log` and `'caching-interceptor'` are illustrated in +[Example interceptors](#example-interceptors). + +It's also possible to configure global interceptors that are invoked before +method level interceptors. For example, the following code registers a global +`caching-interceptor` for all methods. + +```ts +app + .bind('caching-interceptor') + .toProvider(CachingInterceptorProvider) + .apply(asInterceptor); +``` + +Global interceptors are also executed for route handler functions without a +controller class. See an example in +[Route Handler](Routes.md#using-partial-openapi-spec-fragments). + +### Create a proxy to apply interceptors + +A +[proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) +can be created from the target class or object to apply interceptors. This is +useful for the case that a controller declares dependencies of repositories or +services and would like to allow repository or service methods to be +intercepted. + +```ts +import {createProxyWithInterceptors} from '@loopback/context'; + +const proxy = createProxyWithInterceptors(controllerInstance, ctx); +const msg = await proxy.greet('John'); +``` + +There is also an `asProxyWithInterceptors` option for binding resolution or +dependency injection to return a proxy for the class to apply interceptors when +methods are invoked. + +```ts +class DummyController { + constructor( + @inject('my-controller', {asProxyWithInterceptors: true}) + public readonly myController: MyController, + ) {} +} +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'); +); +``` + +Or: + +```ts +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 +return `ValueOrPromise` (synchronous or 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): ValueOrPromise; // the return type becomes `ValueOrPromise` + hello(name: string): Promise; // the same as MyController +} +``` + +The return value of `greet` now has two possible types: + +- `string`: No async interceptor is applied +- `Promise`: At least one async interceptor is applied + +### Use `invokeMethod` to apply interceptors + +To explicitly invoke a method with interceptors, use `invokeMethod` from +`@loopback/context`. Please note `invokeMethod` is used internally by +`RestServer` for controller methods. + +```ts +import {Context, invokeMethod} from '@loopback/context'; + +const ctx: Context = new Context(); + +ctx.bind('name').to('John'); + +// Invoke a static method +let msg = await invokeMethod(MyController, 'greetStaticWithDI', ctx); + +// Invoke an instance method +const controller = new MyController(); +msg = await invokeMethod(controller, 'greetWithDI', ctx); +``` + +Please note that `invokeMethod` internally uses `invokeMethodWithInterceptors` +to support both injection of method parameters and application of interceptors. + +## Apply interceptors + +Interceptors form a cascading chain of handlers around the target method +invocation. We can apply interceptors by decorating methods/classes with +`@intercept`. Please note `@intercept` does **NOT** return a new method wrapping +the target one. Instead, it adds some metadata instead and such information is +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` + +Syntax: `@intercept(...interceptorFunctionsOrBindingKeys)` + +The `@intercept` decorator adds interceptors to a class or its methods including +static and instance methods. Two flavors are accepted: + +- An interceptor function + + ```ts + class MyController { + @intercept(log) // Use the `log` function + greet(name: string) { + return `Hello, ${name}`; + } + } + ``` + +- A binding key that can be resolved to an interface function + + ```ts + class MyController { + @intercept('name-validator') // Use the `name-validator` binding + async helloWithNameValidation(name: string) { + return `Hello, ${name}`; + } + } + + // Bind options and provider for `NameValidator` + ctx.bind('valid-names').to(['John', 'Mary']); + ctx.bind('name-validator').toProvider(NameValidator); + ``` + +### Method level interceptors + +A **public** static or prototype method on a class can be decorated with +`@intercept` to attach interceptors to the target method. Please note +interceptors don't apply to protected or private methods. + +#### Static methods + +```ts +class MyControllerWithStaticMethods { + // Apply `log` to a static method + @intercept(log) + static async greetStatic(name: string) { + return `Hello, ${name}`; + } + + // Apply `log` to a static method with parameter injection + @intercept(log) + static async greetStaticWithDI(@inject('name') name: string) { + return `Hello, ${name}`; + } +} +``` + +#### Prototype methods + +```ts +class MyController { + // Apply `logSync` to a sync instance method + @intercept(logSync) + greetSync(name: string) { + return `Hello, ${name}`; + } + + // Apply `log` to a sync instance method + @intercept(log) + greet(name: string) { + return `Hello, ${name}`; + } + + // Apply `log` as a binding key to an async instance method + @intercept('log') + async greetWithABoundInterceptor(name: string) { + return `Hello, ${name}`; + } + + // Apply `log` to an async instance method with parameter injection + @intercept(log) + async greetWithDI(@inject('name') name: string) { + return `Hello, ${name}`; + } + + // Apply `log` and `logSync` to an async instance method + @intercept('log', logSync) + async greetWithTwoInterceptors(name: string) { + return `Hello, ${name}`; + } + + // No interceptors are attached + async greetWithoutInterceptors(name: string) { + return `Hello, ${name}`; + } + + // Apply `convertName` to convert `name` arg to upper case + @intercept(convertName) + async greetWithUpperCaseName(name: string) { + return `Hello, ${name}`; + } + + // Apply `name-validator` backed by a provider class + @intercept('name-validator') + async greetWithNameValidation(name: string) { + return `Hello, ${name}`; + } + + // Apply `logError` to catch errors + @intercept(logError) + async greetWithError(name: string) { + throw new Error('error: ' + name); + } +} +``` + +### Class level interceptors + +To apply interceptors to be invoked for all methods on a class, we can use +`@intercept` to decorate the class. When a method is invoked, class level +interceptors (if not explicitly listed at method level) are invoked before +method level ones. + +```ts +// Apply `log` to all methods on the class +@intercept(log) +class MyControllerWithClassLevelInterceptors { + // The class level `log` will be applied + static async greetStatic(name: string) { + return `Hello, ${name}`; + } + + // A static method with parameter injection + @intercept(log) + static async greetStaticWithDI(@inject('name') name: string) { + return `Hello, ${name}`; + } + + // We can apply `@intercept` multiple times on the same method + // This is needed if a custom decorator is created for `@intercept` + @intercept(log) + @intercept(logSync) + greetSync(name: string) { + return `Hello, ${name}`; + } + + // 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}`; + } +} +``` + +### Global interceptors + +Global interceptors are discovered from the `InvocationContext`. They are +registered as bindings with `interceptor` tag. For example, + +```ts +import {asInterceptor} from '@loopback/context'; + +app + .bind('interceptors.MetricsInterceptor') + .toProvider(MetricsInterceptorProvider) + .apply(asInterceptor); +``` + +### Order of invocation for interceptors + +Multiple `@intercept` decorators can be applied to a class or a method. The +order of invocation is determined by how `@intercept` is specified. The list of +interceptors is created from top to bottom and from left to right. Duplicate +entries are removed from their first occurrences. + +Let's examine the list of interceptors invoked for each method on +`MyController`, which has a class level `log` decorator: + +1. A static method on the class - `greetStatic` + + ```ts + @intercept(log) + class MyController { + // No explicit `@intercept` at method level. The class level `log` will + // be applied + static async greetStatic(name: string) { + return `Hello, ${name}`; + } + } + ``` + + Interceptors to apply: [`log`] + +2. A static method that requires parameter injection: `greetStaticWithDI` + + ```ts + @intercept(log) + class MyController { + // The method level `log` overrides the class level one + @intercept(log) + static async greetStaticWithDI(@inject('name') name: string) { + return `Hello, ${name}`; + } + } + ``` + + Interceptors to apply: [`log`] + +3. A prototype method with multiple `@intercept` - `greetSync` + + ```ts + @intercept(log) + class MyController { + // We can apply `@intercept` multiple times on the same method + // This is needed if a custom decorator is created for `@intercept` + @intercept(log) // The method level `log` overrides the class level one + @intercept(logSync) + greetSync(name: string) { + return `Hello, ${name}`; + } + } + ``` + + Interceptors to apply: [`log`, `logSync`] + +4. A prototype method that preserves the order of an interceptor - `greet` + + ```ts + @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}`; + } + } + ``` + + Interceptors to apply: [`convertName`, `log`] + +Global interceptors are invoked before class/method level ones unless they are +explicitly overridden by `@intercept`. + +Global interceptors can be sorted as follows: + +1. Tag global interceptor binding with `ContextTags.GLOBAL_INTERCEPTOR_GROUP`. + The tag value will be treated as the `group` name of the interceptor. For + example: + + ```ts + app + .bind('globalInterceptors.authInterceptor') + .to(authInterceptor) + .apply(asGlobalInterceptor('auth')); + ``` + + If the group tag does not exist, the value is default to `''`. + +2. Control the ordered groups for global interceptors + + ```ts + app + .bind(ContextBindings.GLOBAL_INTERCEPTOR_ORDERED_GROUPS) + .to(['log', 'auth']); + ``` + +If ordered groups is not bound to +`ContextBindings.GLOBAL_INTERCEPTOR_ORDERED_GROUPS`, global interceptors will be +sorted by their group names alphabetically. Interceptors with unknown groups are +invoked before those listed in ordered groups. + +## Create your own interceptors + +Interceptors can be made available by LoopBack itself, extension modules, or +applications. They can be a function that implements `Interceptor` signature or +a binding that is resolved to an `Interceptor` function. + +### Interceptor functions + +The interceptor function is invoked to intercept a method invocation with two +parameters: + +- `context`: the [invocation context](#invocation-context) +- `next`: a function to invoke next interceptor or the target method. It returns + a value or promise depending on whether downstream interceptors and the target + method are synchronous or asynchronous. + +```ts +/** + * 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; +} +``` + +An interceptor can be asynchronous (returning a promise) or synchronous +(returning a value). If one of the interceptors or the target method is +asynchronous, the invocation will be asynchronous. The following table show how +the final return type is determined. + +| Interceptor | Target method | Return type | +| ----------- | ------------- | ----------- | +| async | async | promise | +| async | sync | promise | +| sync | async | promise | +| sync | sync | value | + +To keep things simple and consistent, we recommend that interceptors function to +be asynchronous as much as possible. + +### Invocation context + +The `InvocationContext` object provides access to metadata for the given +invocation in addition to the parent `Context` that can be used to locate other +bindings. It extends `Context` with additional properties as follows: + +- `target` (`object`): Target class (for static methods) or prototype/object + (for instance methods) +- `methodName` (`string`): Method name +- `args` (`InvocationArgs`, i.e., `any[]`): An array of arguments + +```ts +/** + * InvocationContext for method invocations + */ +export class InvocationContext extends Context { + /** + * Construct a new instance + * @param parent Parent context, such as the RequestContext + * @param target Target class (for static methods) or prototype/object + * (for instance methods) + * @param methodName Method name + * @param args An array of arguments + */ + constructor( + parent: Context, + public readonly target: object, + public readonly methodName: string, + public readonly args: InvocationArgs, // any[] + ) { + super(parent); + } +} +``` + +It's possible for an interceptor to mutate items in the `args` array to pass in +transformed input to downstream interceptors and the target method. + +### Logic around `next` + +An interceptor will receive the `next` parameter, which is a function to execute +the downstream interceptors and the target method. + +The interceptor function is responsible for calling `next()` if it wants to +proceed with next interceptor or the target method invocation. A typical +interceptor implementation looks like the following: + +```ts +async function intercept( + invocationCtx: InvocationContext, + next: () => ValueOrPromise, +) { + // Pre-process the request + try { + const result = await next(); + // Post-process the response + return result; + } catch (err) { + // Handle errors + throw err; + } +} +``` + +If `next()` is not invoked, neither downstream interceptors nor the target +method be executed. It's valid to skip `next()` if it's by intention, for +example, an interceptor can fail the invocation early due to validation errors +or return a response from cache without invoking the target method. + +### Example interceptors + +Here are some example interceptor functions: + +1. An asynchronous interceptor to log method invocations: + +```ts +const log: Interceptor = async (invocationCtx, next) => { + console.log('log: before-' + invocationCtx.methodName); + // Wait until the interceptor/method chain returns + const result = await next(); + console.log('log: after-' + invocationCtx.methodName); + return result; +}; +``` + +2. An interceptor to catch and log errors: + +```ts +const logError: Interceptor = async (invocationCtx, next) => { + console.log('logError: before-' + invocationCtx.methodName); + try { + const result = await next(); + console.log('logError: after-' + invocationCtx.methodName); + return result; + } catch (err) { + console.log('logError: error-' + invocationCtx.methodName); + throw err; + } +}; +``` + +3. An interceptor to convert `name` arg to upper case: + +```ts +const convertName: Interceptor = async (invocationCtx, next) => { + console.log('convertName:before-' + invocationCtx.methodName); + invocationCtx.args[0] = (invocationCtx.args[0] as string).toUpperCase(); + const result = await next(); + console.log('convertName: after-' + invocationCtx.methodName); + return result; +}; +``` + +4. An provider class for an interceptor that performs parameter validation + +To leverage dependency injection, a provider class can be defined as the +interceptor: + +```ts +/** + * A binding provider class to produce an interceptor that validates the + * `name` argument + */ +class NameValidator implements Provider { + constructor(@inject('valid-names') private validNames: string[]) {} + + value() { + const interceptor: Interceptor = (invocationCtx, next) => + this.validateName(invocationCtx, next); + return interceptor; + } + + async validateName( + invocationCtx: InvocationContext, + next: () => ValueOrPromise, + ) { + const name = invocationCtx.args[0]; + if (!this.validNames.includes(name)) { + throw new Error( + `Name '${name}' is not on the list of '${this.validNames}`, + ); + } + return await next(); + } +} +``` + +5. A synchronous interceptor to log method invocations: + +```ts +const logSync: Interceptor = (invocationCtx, next) => { + console.log('logSync: before-' + invocationCtx.methodName); + // Calling `next()` without `await` + const result = next(); + // It's possible that the statement below is executed before downstream + // interceptors or the target method finish + console.log('logSync: after-' + invocationCtx.methodName); + return result; +}; +``` diff --git a/docs/site/imgs/interceptors.png b/docs/site/imgs/interceptors.png new file mode 100644 index 000000000000..72691efd5893 Binary files /dev/null and b/docs/site/imgs/interceptors.png differ diff --git a/docs/site/sidebars/lb4_sidebar.yml b/docs/site/sidebars/lb4_sidebar.yml index fd0e43f8ad8d..a41a1e79bd06 100644 --- a/docs/site/sidebars/lb4_sidebar.yml +++ b/docs/site/sidebars/lb4_sidebar.yml @@ -148,6 +148,10 @@ children: - title: 'Components' url: Components.html output: 'web, pdf' + + - title: 'Interceptors' + url: Interceptors.html + output: 'web, pdf' - title: 'DataSources' url: DataSources.html 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..e774f8bb99c5 --- /dev/null +++ b/packages/context/src/__tests__/acceptance/interception-proxy.acceptance.ts @@ -0,0 +1,203 @@ +// 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, + ValueOrPromise, +} 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', + ]); + + // Make sure `greet` always return Promise now + expect(proxy.greet('Jane')).to.be.instanceOf(Promise); + }); + + it('creates async methods for the proxy', () => { + class MyController { + name: string; + + greet(name: string): string { + return `Hello, ${name}`; + } + + async hello(name: string) { + return `Hello, ${name}`; + } + } + + interface ExpectedAsyncProxyForMyController { + name: string; + greet(name: string): ValueOrPromise; // the return type becomes `Promise` + hello(name: string): Promise; // the same as MyController + } + + const proxy = createProxyWithInterceptors(new MyController(), ctx); + + // Enforce compile time check to ensure the AsyncProxy typing works for TS + // tslint:disable-next-line:no-unused + const check: ExpectedAsyncProxyForMyController = proxy; + }); + + 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('reports error when asProxyWithInterceptors is set for non-Class binding', async () => { + ctx.bind('my-value').toDynamicValue(() => 'my-value'); + await expect( + ctx.get('my-value', { + asProxyWithInterceptors: true, + }), + ).to.be.rejectedWith( + `Binding 'my-value' (DynamicValue) does not support 'asProxyWithInterceptors'`, + ); + }); + + 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 new file mode 100644 index 000000000000..44e152fc979f --- /dev/null +++ b/packages/context/src/__tests__/acceptance/interceptor.acceptance.ts @@ -0,0 +1,529 @@ +// 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 { + asGlobalInterceptor, + Context, + inject, + intercept, + Interceptor, + InvocationContext, + invokeMethod, + invokeMethodWithInterceptors, + Provider, + ValueOrPromise, +} from '../..'; + +describe('Interceptor', () => { + let ctx: Context; + + beforeEach(givenContextAndEvents); + + it('invokes sync interceptors on a sync method', () => { + class MyController { + // Apply `logSync` to a sync instance method + @intercept(logSync) + greetSync(name: string) { + return `Hello, ${name}`; + } + } + const controller = new MyController(); + + const msg = invokeMethodWithInterceptors(ctx, controller, 'greetSync', [ + 'John', + ]); + expect(msg).to.equal('Hello, John'); + expect(events).to.eql([ + 'logSync: before-greetSync', + 'logSync: after-greetSync', + ]); + }); + + it('invokes async interceptors on a sync method', async () => { + class MyController { + // Apply `log` to a sync instance method + @intercept(log) + greet(name: string) { + return `Hello, ${name}`; + } + } + + const controller = new MyController(); + const msg = await invokeMethodWithInterceptors(ctx, controller, 'greet', [ + 'John', + ]); + expect(msg).to.equal('Hello, John'); + expect(events).to.eql(['log: before-greet', 'log: after-greet']); + }); + + it('supports interceptor bindings', async () => { + class MyController { + // Apply `log` as a binding key to an async instance method + @intercept('log') + async greet(name: string) { + const hello = await Promise.resolve(`Hello, ${name}`); + return hello; + } + } + + const controller = new MyController(); + ctx.bind('log').to(log); + const msg = await invokeMethodWithInterceptors(ctx, controller, 'greet', [ + 'John', + ]); + expect(msg).to.equal('Hello, John'); + expect(events).to.eql(['log: before-greet', 'log: after-greet']); + }); + + it('supports interceptor bindings from a provider', async () => { + class MyController { + // Apply `name-validator` backed by a provider class + @intercept('name-validator') + async greet(name: string) { + return `Hello, ${name}`; + } + } + const controller = new MyController(); + ctx.bind('valid-names').to(['John', 'Mary']); + ctx.bind('name-validator').toProvider(NameValidator); + const msg = await invokeMethodWithInterceptors(ctx, controller, 'greet', [ + 'John', + ]); + expect(msg).to.equal('Hello, John'); + await expect( + invokeMethodWithInterceptors(ctx, controller, 'greet', ['Smith']), + ).to.be.rejectedWith(/Name 'Smith' is not on the list/); + }); + + it('invokes a method with two interceptors', async () => { + class MyController { + // Apply `log` and `logSync` to an async instance method + @intercept('log', logSync) + async greet(name: string) { + return `Hello, ${name}`; + } + } + const controller = new MyController(); + + ctx.bind('log').to(log); + const msg = await invokeMethodWithInterceptors(ctx, controller, 'greet', [ + 'John', + ]); + expect(msg).to.equal('Hello, John'); + expect(events).to.eql([ + 'log: before-greet', + 'logSync: before-greet', + 'logSync: after-greet', + 'log: after-greet', + ]); + }); + + it('invokes a method without interceptors', async () => { + class MyController { + // No interceptors + async greet(name: string) { + return `Hello, ${name}`; + } + } + const controller = new MyController(); + + const msg = await invokeMethodWithInterceptors(ctx, controller, 'greet', [ + 'John', + ]); + expect(msg).to.equal('Hello, John'); + expect(events).to.eql([]); + }); + + it('allows interceptors to modify args', async () => { + class MyController { + // Apply `convertName` to convert `name` arg to upper case + @intercept(convertName) + async greet(name: string) { + return `Hello, ${name}`; + } + } + const controller = new MyController(); + const msg = await invokeMethodWithInterceptors(ctx, controller, 'greet', [ + 'John', + ]); + expect(msg).to.equal('Hello, JOHN'); + expect(events).to.eql([ + 'convertName: before-greet', + 'convertName: after-greet', + ]); + }); + + it('allows interceptors to catch errors', async () => { + class MyController { + // Apply `logError` to catch errors + @intercept(logError) + async greet(name: string) { + throw new Error('error: ' + name); + } + } + const controller = new MyController(); + await expect( + invokeMethodWithInterceptors(ctx, controller, 'greet', ['John']), + ).to.be.rejectedWith('error: John'); + expect(events).to.eql(['logError: before-greet', 'logError: error-greet']); + }); + + it('invokes static interceptors', async () => { + class MyController { + // Apply `log` to a static method + @intercept(log) + static async greetStatic(name: string) { + return `Hello, ${name}`; + } + } + + const msg = await invokeMethodWithInterceptors( + ctx, + MyController, + 'greetStatic', + ['John'], + ); + expect(msg).to.equal('Hello, John'); + expect(events).to.eql([ + 'log: before-greetStatic', + 'log: after-greetStatic', + ]); + }); + + it('does not allow @intercept on properties', () => { + expect(() => { + // tslint:disable-next-line:no-unused + class MyControllerWithProps { + @intercept(log) + private status: string; + } + }).to.throw(/@intercept cannot be used on a property/); + }); + + context('method dependency injection', () => { + it('invokes interceptors on a static method', async () => { + class MyController { + // Apply `log` to a static method with parameter injection + @intercept(log) + static async greetStaticWithDI(@inject('name') name: string) { + return `Hello, ${name}`; + } + } + ctx.bind('name').to('John'); + const msg = await invokeMethod(MyController, 'greetStaticWithDI', ctx); + expect(msg).to.equal('Hello, John'); + expect(events).to.eql([ + 'log: before-greetStaticWithDI', + 'log: after-greetStaticWithDI', + ]); + }); + + it('invokes interceptors on an instance method', async () => { + class MyController { + // Apply `log` to an async instance method with parameter injection + @intercept(log) + async greetWithDI(@inject('name') name: string) { + return `Hello, ${name}`; + } + } + const controller = new MyController(); + + ctx.bind('name').to('John'); + const msg = await invokeMethod(controller, 'greetWithDI', ctx); + expect(msg).to.equal('Hello, John'); + expect(events).to.eql([ + 'log: before-greetWithDI', + 'log: after-greetWithDI', + ]); + }); + }); + + context('class level interceptors', () => { + it('invokes sync and async interceptors', async () => { + // Apply `log` to all methods on the class + @intercept(log) + class MyController { + // We can apply `@intercept` multiple times on the same method + // This is needed if a custom decorator is created for `@intercept` + @intercept(log) + @intercept(logSync) + greetSync(name: string) { + return `Hello, ${name}`; + } + } + + const msg = await invokeMethodWithInterceptors( + ctx, + new MyController(), + 'greetSync', + ['John'], + ); + expect(msg).to.equal('Hello, John'); + expect(events).to.eql([ + 'log: before-greetSync', + 'logSync: before-greetSync', + 'logSync: after-greetSync', + 'log: after-greetSync', + ]); + }); + + 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 msg = await invokeMethodWithInterceptors( + ctx, + new 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', + ]); + }); + + 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 async greetStatic(name: string) { + return `Hello, ${name}`; + } + } + const msg = await invokeMethodWithInterceptors( + ctx, + MyController, + 'greetStatic', + ['John'], + ); + expect(msg).to.equal('Hello, John'); + expect(events).to.eql([ + 'log: before-greetStatic', + 'log: after-greetStatic', + ]); + }); + + it('invokes interceptors on a static method with DI', async () => { + // Apply `log` to all methods on the class + @intercept(log) + class MyController { + @intercept(log) + static async greetStaticWithDI(@inject('name') name: string) { + return `Hello, ${name}`; + } + } + ctx.bind('name').to('John'); + const msg = await invokeMethod(MyController, 'greetStaticWithDI', ctx); + expect(msg).to.equal('Hello, John'); + expect(events).to.eql([ + 'log: before-greetStaticWithDI', + 'log: after-greetStaticWithDI', + ]); + }); + }); + + context('global interceptors', () => { + beforeEach(givenGlobalInterceptor); + + it('invokes sync and async interceptors', async () => { + // Apply `log` to all methods on the class + @intercept(log) + class MyController { + // We can apply `@intercept` multiple times on the same method + // This is needed if a custom decorator is created for `@intercept` + @intercept(log) + @intercept(logSync) + greetSync(name: string) { + return `Hello, ${name}`; + } + } + const msg = await invokeMethodWithInterceptors( + ctx, + new MyController(), + 'greetSync', + ['John'], + ); + expect(msg).to.equal('Hello, John'); + expect(events).to.eql([ + 'globalLog: before-greetSync', + 'log: before-greetSync', + 'logSync: before-greetSync', + 'logSync: after-greetSync', + 'log: after-greetSync', + 'globalLog: after-greetSync', + ]); + }); + + 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 msg = await invokeMethodWithInterceptors( + ctx, + new MyController(), + 'greet', + ['John'], + ); + expect(msg).to.equal('Hello, JOHN'); + expect(events).to.eql([ + 'globalLog: before-greet', + 'convertName: before-greet', + 'log: before-greet', + 'log: after-greet', + 'convertName: after-greet', + 'globalLog: 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 async greetStatic(name: string) { + return `Hello, ${name}`; + } + } + const msg = await invokeMethodWithInterceptors( + ctx, + MyController, + 'greetStatic', + ['John'], + ); + expect(msg).to.equal('Hello, John'); + expect(events).to.eql([ + 'globalLog: before-greetStatic', + 'log: before-greetStatic', + 'log: after-greetStatic', + 'globalLog: after-greetStatic', + ]); + }); + + it('invokes interceptors on a static method with DI', async () => { + // Apply `log` to all methods on the class + @intercept(log) + class MyController { + @intercept(log) + static async greetStaticWithDI(@inject('name') name: string) { + return `Hello, ${name}`; + } + } + ctx.bind('name').to('John'); + const msg = await invokeMethod(MyController, 'greetStaticWithDI', ctx); + expect(msg).to.equal('Hello, John'); + expect(events).to.eql([ + 'globalLog: before-greetStaticWithDI', + 'log: before-greetStaticWithDI', + 'log: after-greetStaticWithDI', + 'globalLog: after-greetStaticWithDI', + ]); + }); + + function givenGlobalInterceptor() { + const globalLog: Interceptor = async (invocationCtx, next) => { + events.push('globalLog: before-' + invocationCtx.methodName); + const result = await next(); + events.push('globalLog: after-' + invocationCtx.methodName); + return result; + }; + ctx + .bind('globalLog') + .to(globalLog) + .apply(asGlobalInterceptor()); + } + }); + + let events: string[]; + + const logSync: Interceptor = (invocationCtx, next) => { + events.push('logSync: before-' + invocationCtx.methodName); + // Calling `next()` without `await` + const result = next(); + // It's possible that the statement below is executed before downstream + // interceptors or the target method finish + events.push('logSync: after-' + invocationCtx.methodName); + return result; + }; + + const log: Interceptor = async (invocationCtx, next) => { + events.push('log: before-' + invocationCtx.methodName); + const result = await next(); + events.push('log: after-' + invocationCtx.methodName); + return result; + }; + + const logError: Interceptor = async (invocationCtx, next) => { + events.push('logError: before-' + invocationCtx.methodName); + try { + const result = await next(); + events.push('logError: after-' + invocationCtx.methodName); + return result; + } catch (err) { + events.push('logError: error-' + invocationCtx.methodName); + throw err; + } + }; + + // 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; + }; + + /** + * A binding provider class to produce an interceptor that validates the + * `name` argument + */ + class NameValidator implements Provider { + constructor(@inject('valid-names') private validNames: string[]) {} + + value() { + const interceptor: Interceptor = (invocationCtx, next) => + this.validateName(invocationCtx, next); + return interceptor; + } + + async validateName( + invocationCtx: InvocationContext, + next: () => ValueOrPromise, + ) { + const name = invocationCtx.args[0]; + if (!this.validNames.includes(name)) { + throw new Error( + `Name '${name}' is not on the list of '${this.validNames}`, + ); + } + return await next(); + } + } + + function givenContextAndEvents() { + ctx = new Context(); + events = []; + } +}); 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..a8420b337c6e --- /dev/null +++ b/packages/context/src/__tests__/unit/interceptor.unit.ts @@ -0,0 +1,156 @@ +// 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 { + asGlobalInterceptor, + Context, + ContextBindings, + Interceptor, + InterceptorOrKey, + InvocationContext, + 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); + } +}); + +describe('globalInterceptors', () => { + let ctx: Context; + + const logInterceptor: Interceptor = async (context, next) => { + await next(); + }; + const authInterceptor: Interceptor = async (context, next) => { + await next(); + }; + + beforeEach(givenContext); + + it('sorts by group', () => { + ctx + .bind(ContextBindings.GLOBAL_INTERCEPTOR_ORDERED_GROUPS) + .to(['log', 'auth']); + + ctx + .bind('globalInterceptors.authInterceptor') + .to(authInterceptor) + .apply(asGlobalInterceptor('auth')); + + ctx + .bind('globalInterceptors.logInterceptor') + .to(logInterceptor) + .apply(asGlobalInterceptor('log')); + + const invocationCtx = givenInvocationContext(); + + const keys = invocationCtx.getGlobalInterceptorBindingKeys(); + expect(keys).to.eql([ + 'globalInterceptors.logInterceptor', + 'globalInterceptors.authInterceptor', + ]); + }); + + it('sorts by group - unknown group comes before known ones', () => { + ctx + .bind(ContextBindings.GLOBAL_INTERCEPTOR_ORDERED_GROUPS) + .to(['log', 'auth']); + + ctx + .bind('globalInterceptors.authInterceptor') + .to(authInterceptor) + .apply(asGlobalInterceptor('auth')); + + ctx + .bind('globalInterceptors.logInterceptor') + .to(logInterceptor) + .apply(asGlobalInterceptor('unknown')); + + const invocationCtx = givenInvocationContext(); + + const keys = invocationCtx.getGlobalInterceptorBindingKeys(); + expect(keys).to.eql([ + 'globalInterceptors.logInterceptor', + 'globalInterceptors.authInterceptor', + ]); + }); + + it('sorts by group alphabetically without ordered group', () => { + ctx + .bind('globalInterceptors.authInterceptor') + .to(authInterceptor) + .apply(asGlobalInterceptor('auth')); + + ctx + .bind('globalInterceptors.logInterceptor') + .to(logInterceptor) + .apply(asGlobalInterceptor('log')); + + const invocationCtx = givenInvocationContext(); + + const keys = invocationCtx.getGlobalInterceptorBindingKeys(); + expect(keys).to.eql([ + 'globalInterceptors.authInterceptor', + 'globalInterceptors.logInterceptor', + ]); + }); + + it('sorts by binding order without group tags', () => { + ctx + .bind('globalInterceptors.authInterceptor') + .to(authInterceptor) + .apply(asGlobalInterceptor()); + + ctx + .bind('globalInterceptors.logInterceptor') + .to(logInterceptor) + .apply(asGlobalInterceptor()); + + const invocationCtx = givenInvocationContext(); + + const keys = invocationCtx.getGlobalInterceptorBindingKeys(); + expect(keys).to.eql([ + 'globalInterceptors.authInterceptor', + 'globalInterceptors.logInterceptor', + ]); + }); + + class MyController { + greet(name: string) { + return `Hello, ${name}`; + } + } + + function givenContext() { + ctx = new Context(); + } + + function givenInvocationContext() { + return new InvocationContext(ctx, new MyController(), 'greet', ['John']); + } +}); diff --git a/packages/context/src/binding.ts b/packages/context/src/binding.ts index b7077d5c84c1..f1066e93150f 100644 --- a/packages/context/src/binding.ts +++ b/packages/context/src/binding.ts @@ -6,6 +6,7 @@ import * as debugFactory from 'debug'; import {BindingAddress, BindingKey} from './binding-key'; import {Context} from './context'; +import {createProxyWithInterceptors} from './interception-proxy'; import {Provider} from './provider'; import { asResolutionOptions, @@ -384,7 +385,16 @@ export class Binding { private _setValueGetter(getValue: ValueGetter) { // Clear the cache this._clearCache(); - this._getValue = getValue; + this._getValue = (ctx: Context, options: ResolutionOptions) => { + if (options.asProxyWithInterceptors && this._type !== BindingType.CLASS) { + throw new Error( + `Binding '${this.key}' (${ + this._type + }) does not support 'asProxyWithInterceptors'`, + ); + } + return getValue(ctx, options); + }; } /** @@ -504,9 +514,11 @@ export class Binding { debug('Bind %s to class %s', this.key, ctor.name); } this._type = BindingType.CLASS; - this._setValueGetter((ctx, options) => - instantiateClass(ctor, ctx, options.session), - ); + this._setValueGetter((ctx, options) => { + const instOrPromise = instantiateClass(ctor, ctx, options.session); + if (!options.asProxyWithInterceptors) return instOrPromise; + return createInterceptionProxyFromInstance(instOrPromise, ctx); + }); this._valueConstructor = ctor; return this; } @@ -522,8 +534,7 @@ export class Binding { debug('Bind %s to alias %s', this.key, keyWithPath); } this._type = BindingType.ALIAS; - this._setValueGetter((ctx, optionsOrSession) => { - const options = asResolutionOptions(optionsOrSession); + this._setValueGetter((ctx, options) => { return ctx.getValueOrPromise(keyWithPath, options); }); return this; @@ -582,3 +593,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 106924e0b991..70eb7613aab3 100644 --- a/packages/context/src/index.ts +++ b/packages/context/src/index.ts @@ -6,13 +6,15 @@ export * from '@loopback/metadata'; export * from './binding'; export * from './binding-decorator'; +export * from './binding-filter'; export * from './binding-inspector'; export * from './binding-key'; -export * from './binding-filter'; 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'; export * from './resolution-session'; diff --git a/packages/context/src/inject.ts b/packages/context/src/inject.ts index 43ce0e800e43..10c540549048 100644 --- a/packages/context/src/inject.ts +++ b/packages/context/src/inject.ts @@ -22,7 +22,7 @@ import { import {BindingAddress} from './binding-key'; import {BindingCreationPolicy, Context} from './context'; import {ContextView, createViewGetter} from './context-view'; -import {ResolutionSession} from './resolution-session'; +import {ResolutionOptions, ResolutionSession} from './resolution-session'; import {BoundValue, ValueOrPromise} from './value-promise'; const PARAMETERS_KEY = MetadataAccessor.create( @@ -46,16 +46,12 @@ export interface ResolverFunction { /** * An object to provide metadata for `@inject` */ -export interface InjectionMetadata { +export interface InjectionMetadata extends ResolutionOptions { /** * Name of the decorator function, such as `@inject` or `@inject.setter`. * It's usually set by the decorator implementation. */ decorator?: string; - /** - * Control if the dependency is optional, default to false - */ - optional?: boolean; /** * Other attributes */ @@ -411,11 +407,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, - }); + 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..72dfabf1f53b --- /dev/null +++ b/packages/context/src/interception-proxy.ts @@ -0,0 +1,97 @@ +// 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'; +import {ValueOrPromise} from './value-promise'; + +/** + * Create the Promise type for `T`. If `T` extends `Promise`, the type is `T`, + * otherwise the type is `ValueOrPromise`. + */ +export type AsValueOrPromise = T extends Promise + ? T + : ValueOrPromise; + +/** + * The intercepted variant of a function to return `ValueOrPromise`. + * If `T` is not a function, the type is `T`. + */ +export type AsInterceptedFunction = T extends ( + ...args: InvocationArgs +) => // tslint:disable-next-line:no-unused (possible tslint bug to treat `R` as unused) +infer R + ? (...args: InvocationArgs) => AsValueOrPromise + : T; + +/** + * The proxy type for `T`. The return type for any method of `T` with original + * return type `R` becomes `ValueOrPromise` 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): ValueOrPromise; // the return type becomes `ValueOrPromise` + * hello(name: string): Promise; // the same as MyController + * } + * ``` + */ +export type AsyncProxy = {[P in keyof T]: AsInterceptedFunction}; + +/** + * 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 new file mode 100644 index 000000000000..9e242ae58c48 --- /dev/null +++ b/packages/context/src/interceptor.ts @@ -0,0 +1,432 @@ +// 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 { + ClassDecoratorFactory, + DecoratorFactory, + MetadataAccessor, + MetadataInspector, + MetadataMap, + MethodDecoratorFactory, +} from '@loopback/metadata'; +import * as assert from 'assert'; +import * as debugFactory from 'debug'; +import {Binding, BindingTemplate} from './binding'; +import {filterByTag} from './binding-filter'; +import {BindingAddress} from './binding-key'; +import {Context} from './context'; +import {ContextBindings, ContextTags} from './keys'; +import {transformValueOrPromise, ValueOrPromise} from './value-promise'; +const debug = debugFactory('loopback:context:interceptor'); +const getTargetName = DecoratorFactory.getTargetName; + +/** + * 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 + */ +// tslint:disable-next-line:no-any +type ClassOrPrototype = any; + +/** + * InvocationContext represents the context to invoke interceptors for a method. + * The context can be used to access metadata about the invocation as well as + * other dependencies. + */ +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 prototype/object + * (for instance methods) + * @param methodName Method name + * @param args An array of arguments + */ + constructor( + parent: Context, + public readonly target: object, + public readonly methodName: string, + public readonly args: InvocationArgs, + ) { + super(parent); + } + + /** + * Discover all binding keys for global interceptors (tagged by + * ContextTags.GLOBAL_INTERCEPTOR) + */ + getGlobalInterceptorBindingKeys(): string[] { + const bindings: Readonly>[] = this.find( + filterByTag(ContextTags.GLOBAL_INTERCEPTOR), + ); + this.sortGlobalInterceptorBindings(bindings); + return bindings.map(b => b.key); + } + + /** + * Sort global interceptor bindings by `globalInterceptorGroup` tags + * @param bindings An array of global interceptor bindings + */ + private sortGlobalInterceptorBindings( + bindings: Readonly>[], + ) { + // Get predefined ordered groups for global interceptors + const orderedGroups = + this.getSync(ContextBindings.GLOBAL_INTERCEPTOR_ORDERED_GROUPS, { + optional: true, + }) || []; + bindings.sort((a, b) => { + const g1: string = a.tagMap[ContextTags.GLOBAL_INTERCEPTOR_GROUP] || ''; + const g2: string = b.tagMap[ContextTags.GLOBAL_INTERCEPTOR_GROUP] || ''; + const i1 = orderedGroups.indexOf(g1); + const i2 = orderedGroups.indexOf(g2); + if (i1 !== -1 || i2 !== -1) { + // Honor the group order + return i1 - i2; + } else { + // Neither group is in the pre-defined order + // Use alphabetical order instead so that `1-group` is invoked before + // `2-group` + return g1 < g2 ? -1 : g1 > g2 ? 1 : 0; + } + }); + } + + /** + * 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 + */ + loadInterceptors() { + let interceptors = + MetadataInspector.getMethodMetadata( + INTERCEPT_METHOD_KEY, + this.target, + this.methodName, + ) || []; + const targetClass = + typeof this.target === 'function' ? this.target : this.target.constructor; + const classInterceptors = + MetadataInspector.getClassMetadata(INTERCEPT_CLASS_KEY, targetClass) || + []; + // Inserting class level interceptors before method level ones + interceptors = mergeInterceptors(classInterceptors, interceptors); + const globalInterceptors = this.getGlobalInterceptorBindingKeys(); + // Inserting global interceptors + interceptors = mergeInterceptors(globalInterceptors, interceptors); + return interceptors; + } + + /** + * Assert the method exists on the target. An error will be thrown if otherwise. + * @param context Invocation context + */ + assertMethodExists() { + const targetWithMethods = this.target as Record; + if (typeof targetWithMethods[this.methodName] !== 'function') { + const targetName = getTargetName(this.target, this.methodName); + assert(false, `Method ${targetName} not found`); + } + return targetWithMethods; + } + + /** + * Invoke the target method with the given context + * @param context Invocation context + */ + invokeTargetMethod() { + const targetWithMethods = this.assertMethodExists(); + /* istanbul ignore if */ + if (debug.enabled) { + debug( + 'Invoking method %s', + getTargetName(this.target, this.methodName), + this.args, + ); + } + // Invoke the target method + const result = targetWithMethods[this.methodName](...this.args); + /* istanbul ignore if */ + if (debug.enabled) { + debug( + 'Method invoked: %s', + getTargetName(this.target, this.methodName), + result, + ); + } + return result; + } +} + +/** + * The `BindingTemplate` function to configure a binding as a global interceptor + * by tagging it with `ContextTags.INTERCEPTOR` + * @param binding Binding object + */ +export function asGlobalInterceptor( + group?: string, +): BindingTemplate { + return binding => { + binding.tag(ContextTags.GLOBAL_INTERCEPTOR); + if (group) binding.tag({[ContextTags.GLOBAL_INTERCEPTOR_GROUP]: group}); + }; +} + +/** + * 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; +} + +/** + * Interceptor function or binding key that can be used as parameters for + * `@intercept()` + */ +export type InterceptorOrKey = BindingAddress | Interceptor; + +/** + * Metadata key for method-level interceptors + */ +export const INTERCEPT_METHOD_KEY = MetadataAccessor.create< + InterceptorOrKey[], + MethodDecorator +>('intercept:method'); + +/** + * Adding interceptors from the spec to the front of existing ones. Duplicate + * entries are eliminated from the spec side. + * + * For example: + * + * - [log] + [cache, log] => [cache, log] + * - [log] + [log, cache] => [log, cache] + * - [] + [cache, log] => [cache, log] + * - [cache, log] + [] => [cache, log] + * - [log] + [cache] => [log, cache] + * + * @param interceptorsFromSpec Interceptors from `@intercept` + * @param existingInterceptors Interceptors already applied for the method + */ +export function mergeInterceptors( + interceptorsFromSpec: InterceptorOrKey[], + existingInterceptors: InterceptorOrKey[], +) { + const interceptorsToApply = new Set(interceptorsFromSpec); + const appliedInterceptors = new Set(existingInterceptors); + // Remove interceptors that already exist + for (const i of interceptorsToApply) { + if (appliedInterceptors.has(i)) { + interceptorsToApply.delete(i); + } + } + // Add existing interceptors after ones from the spec + for (const i of appliedInterceptors) { + interceptorsToApply.add(i); + } + return Array.from(interceptorsToApply); +} + +/** + * Metadata key for method-level interceptors + */ +export const INTERCEPT_CLASS_KEY = MetadataAccessor.create< + InterceptorOrKey[], + 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[] +> { + protected mergeWithOwn(ownMetadata: InterceptorOrKey[], target: Object) { + ownMetadata = ownMetadata || []; + return mergeInterceptors(this.spec, ownMetadata); + } +} + +/** + * A factory to define `@intercept` for methods. It allows `@intercept` to be + * used multiple times on the same method. + */ +class InterceptMethodDecoratorFactory extends MethodDecoratorFactory< + InterceptorOrKey[] +> { + protected mergeWithOwn( + ownMetadata: MetadataMap, + target: Object, + methodName: string, + methodDescriptor: TypedPropertyDescriptor, + ) { + ownMetadata = ownMetadata || {}; + const interceptors = ownMetadata[methodName] || []; + + // Adding interceptors to the list + ownMetadata[methodName] = mergeInterceptors(this.spec, interceptors); + + return ownMetadata; + } +} + +/** + * Decorator function `@intercept` for classes/methods to apply interceptors. It + * can be applied on a class and its public methods. Multiple occurrences of + * `@intercept` are allowed on the same target class or method. The decorator + * takes a list of `interceptor` functions or binding keys. For example: + * + * ```ts + * @intercept(log, metrics) + * class MyController { + * @intercept('caching-interceptor') + * @intercept('name-validation-interceptor') + * greet(name: string) { + * return `Hello, ${name}`; + * } + * } + * ``` + * + * @param interceptorOrKeys One or more interceptors or binding keys that are + * resolved to be interceptors + */ +export function intercept(...interceptorOrKeys: InterceptorOrKey[]) { + return function interceptDecoratorForClassOrMethod( + target: ClassOrPrototype, + method?: string, + // Use `any` to for `TypedPropertyDescriptor` + // See https://github.com/strongloop/loopback-next/pull/2704 + // tslint:disable-next-line:no-any + methodDescriptor?: TypedPropertyDescriptor, + ) { + if (method && methodDescriptor) { + // Method + return InterceptMethodDecoratorFactory.createDecorator( + INTERCEPT_METHOD_KEY, + interceptorOrKeys, + )(target, method, methodDescriptor!); + } + if (typeof target === 'function' && !method && !methodDescriptor) { + // Class + return InterceptClassDecoratorFactory.createDecorator( + INTERCEPT_CLASS_KEY, + interceptorOrKeys, + )(target); + } + // Not on a class or method + throw new Error( + '@intercept cannot be used on a property: ' + + DecoratorFactory.getTargetName(target, method, methodDescriptor), + ); + }; +} + +/** + * Invoke a method with the given context + * @param context Context object + * @param target Target class (for static methods) or object (for instance methods) + * @param methodName Method name + * @param args An array of argument values + */ +export function invokeMethodWithInterceptors( + context: Context, + target: object, + methodName: string, + args: InvocationArgs, +): ValueOrPromise { + const invocationCtx = new InvocationContext( + context, + target, + methodName, + args, + ); + + invocationCtx.assertMethodExists(); + try { + const interceptors = invocationCtx.loadInterceptors(); + return invokeInterceptors(invocationCtx, interceptors); + } finally { + invocationCtx.close(); + } +} + +/** + * Invoke the interceptor chain + * @param context Context object + * @param interceptors An array of interceptors + */ +function invokeInterceptors( + context: InvocationContext, + interceptors: InterceptorOrKey[], +): ValueOrPromise { + let index = 0; + return next(); + + /** + * Invoke downstream interceptors or the target method + */ + function next(): ValueOrPromise { + // No more interceptors + if (index === interceptors.length) { + return context.invokeTargetMethod(); + } + return invokeNextInterceptor(); + } + + /** + * Invoke downstream interceptors + */ + function invokeNextInterceptor(): ValueOrPromise { + const interceptor = interceptors[index++]; + const interceptorFn = loadInterceptor(interceptor); + return transformValueOrPromise(interceptorFn, fn => { + /* istanbul ignore if */ + if (debug.enabled) { + debug( + 'Invoking interceptor %d (%s) on %s', + index - 1, + fn.name, + getTargetName(context.target, context.methodName), + context.args, + ); + } + return fn(context, next); + }); + } + + /** + * 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 2ab2e3a6fc25..b8c653a0399c 100644 --- a/packages/context/src/keys.ts +++ b/packages/context/src/keys.ts @@ -3,6 +3,11 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {BindingKey} from './binding-key'; + +/** + * Namespace for context tags + */ export namespace ContextTags { export const CLASS = 'class'; export const PROVIDER = 'provider'; @@ -23,4 +28,25 @@ export namespace ContextTags { * Binding key for the artifact */ export const KEY = 'key'; + + /** + * Binding tag for global interceptors + */ + export const GLOBAL_INTERCEPTOR = 'globalInterceptor'; + /** + * Binding tag for group name of global interceptors + */ + export const GLOBAL_INTERCEPTOR_GROUP = 'globalInterceptorGroup'; +} + +/** + * Namespace for context bindings + */ +export namespace ContextBindings { + /** + * Binding key for ordered groups of global interceptors + */ + export const GLOBAL_INTERCEPTOR_ORDERED_GROUPS = BindingKey.create( + 'globalInterceptor.orderedGroups', + ); } diff --git a/packages/context/src/resolution-session.ts b/packages/context/src/resolution-session.ts index adfeeab6d919..5d591684c6d0 100644 --- a/packages/context/src/resolution-session.ts +++ b/packages/context/src/resolution-session.ts @@ -338,6 +338,13 @@ export interface ResolutionOptions { * will return `undefined` instead of throwing an error. */ optional?: boolean; + + /** + * A boolean flag to control if a proxy should be created to apply + * interceptors for the resolved value. It's only honored for bindings backed + * by a class. + */ + asProxyWithInterceptors?: boolean; } /** diff --git a/packages/context/src/resolver.ts b/packages/context/src/resolver.ts index c4430f41e6a8..fc1c620da3d9 100644 --- a/packages/context/src/resolver.ts +++ b/packages/context/src/resolver.ts @@ -15,7 +15,8 @@ import { describeInjectedProperties, Injection, } from './inject'; -import {ResolutionSession} from './resolution-session'; +import {invokeMethodWithInterceptors} from './interceptor'; +import {ResolutionOptions, ResolutionSession} from './resolution-session'; import { BoundValue, Constructor, @@ -148,12 +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, - }); + ...injection.metadata, + }; + return ctx.getValueOrPromise(key, options); } }, injection, @@ -288,7 +288,7 @@ export function invokeMethod( if (debug.enabled) { debug('Injected arguments for %s:', methodName, args); } - return targetWithMethods[method](...args); + return invokeMethodWithInterceptors(ctx, targetWithMethods, method, args); }); } diff --git a/packages/rest/src/__tests__/acceptance/caching-interceptor/caching-interceptor.acceptance.ts b/packages/rest/src/__tests__/acceptance/caching-interceptor/caching-interceptor.acceptance.ts new file mode 100644 index 000000000000..9477ed0755aa --- /dev/null +++ b/packages/rest/src/__tests__/acceptance/caching-interceptor/caching-interceptor.acceptance.ts @@ -0,0 +1,98 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {intercept} from '@loopback/context'; +import {get, param} from '@loopback/openapi-v3'; +import { + Client, + createRestAppClient, + expect, + givenHttpServerConfig, +} from '@loopback/testlab'; +import {RestApplication} from '../../..'; +import { + cache, + CachingInterceptorProvider, + clearCache, + status, +} from './caching-interceptor'; + +describe('caching interceptor', () => { + let client: Client; + let app: RestApplication; + + beforeEach(clearCache); + + context('as a binding key', () => { + class ControllerWithInterceptorBinding { + @intercept('caching-interceptor') + @get('/toUpperCase/{text}') + toUpperCase(@param.path.string('text') text: string) { + return text.toUpperCase(); + } + } + + before(givenAClient); + after(async () => { + await app.stop(); + }); + + it('invokes the controller method if not cached', async () => { + await client.get('/toUpperCase/Hello').expect(200, 'HELLO'); + expect(status.returnFromCache).to.be.false(); + }); + + it('returns from cache without invoking the controller method', async () => { + await client.get('/toUpperCase/Hello').expect(200, 'HELLO'); + for (let i = 0; i <= 5; i++) { + await client.get('/toUpperCase/Hello').expect(200, 'HELLO'); + expect(status.returnFromCache).to.be.true(); + } + }); + + async function givenAClient() { + app = new RestApplication({rest: givenHttpServerConfig()}); + app.bind('caching-interceptor').toProvider(CachingInterceptorProvider); + app.controller(ControllerWithInterceptorBinding); + await app.start(); + client = createRestAppClient(app); + } + }); + + context('as an interceptor function', () => { + class ControllerWithInterceptorFunction { + @intercept(cache) + @get('/toLowerCase/{text}') + toLowerCase(@param.path.string('text') text: string) { + return text.toLowerCase(); + } + } + + before(givenAClient); + after(async () => { + await app.stop(); + }); + + it('invokes the controller method if not cached', async () => { + await client.get('/toLowerCase/Hello').expect(200, 'hello'); + expect(status.returnFromCache).to.be.false(); + }); + + it('returns from cache without invoking the controller method', async () => { + await client.get('/toLowerCase/Hello').expect(200, 'hello'); + for (let i = 0; i <= 5; i++) { + await client.get('/toLowerCase/Hello').expect(200, 'hello'); + expect(status.returnFromCache).to.be.true(); + } + }); + + async function givenAClient() { + app = new RestApplication({rest: givenHttpServerConfig()}); + app.controller(ControllerWithInterceptorFunction); + await app.start(); + client = createRestAppClient(app); + } + }); +}); diff --git a/packages/rest/src/__tests__/acceptance/caching-interceptor/caching-interceptor.ts b/packages/rest/src/__tests__/acceptance/caching-interceptor/caching-interceptor.ts new file mode 100644 index 000000000000..641df275e98c --- /dev/null +++ b/packages/rest/src/__tests__/acceptance/caching-interceptor/caching-interceptor.ts @@ -0,0 +1,80 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + inject, + Interceptor, + InvocationContext, + Provider, + ValueOrPromise, +} from '@loopback/context'; +import {Request, RestBindings} from '../../..'; + +/** + * Execution status + */ +export const status = { + returnFromCache: false, +}; + +/** + * In-memory cache + */ +export const cachedResults = new Map(); + +/** + * Reset the cache + */ +export function clearCache() { + status.returnFromCache = false; + cachedResults.clear(); +} + +/** + * A provider class for caching interceptor that leverages dependency + * injection + */ +export class CachingInterceptorProvider implements Provider { + constructor( + @inject(RestBindings.Http.REQUEST, {optional: true}) + private request: Request | undefined, + ) {} + value() { + return ( + invocationCtx: InvocationContext, + next: () => ValueOrPromise, + ) => cache(invocationCtx, next); + } +} + +/** + * An interceptor function that caches results. It uses `invocationContext` + * to locate the http request + * + * @param invocationCtx + * @param next + */ +export async function cache( + invocationCtx: InvocationContext, + next: () => ValueOrPromise, +) { + status.returnFromCache = false; + const req = await invocationCtx.get(RestBindings.Http.REQUEST, { + optional: true, + }); + if (!req || req.method.toLowerCase() !== 'get') { + // The method is not invoked by an http request, no caching + return await next(); + } + const url = req.url; + const cachedValue = cachedResults.get(url); + if (cachedValue) { + status.returnFromCache = true; + return cachedValue as T; + } + const result = await next(); + cachedResults.set(url, result); + return result; +} diff --git a/packages/rest/src/__tests__/acceptance/caching-interceptor/global-caching-interceptor.acceptance.ts b/packages/rest/src/__tests__/acceptance/caching-interceptor/global-caching-interceptor.acceptance.ts new file mode 100644 index 000000000000..23fca210ee61 --- /dev/null +++ b/packages/rest/src/__tests__/acceptance/caching-interceptor/global-caching-interceptor.acceptance.ts @@ -0,0 +1,122 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {asGlobalInterceptor} from '@loopback/context'; +import {anOperationSpec} from '@loopback/openapi-spec-builder'; +import {get, param} from '@loopback/openapi-v3'; +import { + Client, + createRestAppClient, + expect, + givenHttpServerConfig, +} from '@loopback/testlab'; +import {RestApplication} from '../../..'; +import { + cachedResults, + CachingInterceptorProvider, + clearCache, + status, +} from './caching-interceptor'; + +describe('global caching interceptor', () => { + let client: Client; + let app: RestApplication; + + before(givenAClient); + after(async () => { + await app.stop(); + }); + + context('caching invocation for controller methods', () => { + it('invokes the controller method if not cached', async () => { + await client.get('/toUpperCase/Hello').expect(200, 'HELLO'); + expect(status.returnFromCache).to.be.false(); + }); + + it('returns from cache without invoking the controller method', async () => { + for (let i = 0; i <= 5; i++) { + await client.get('/toUpperCase/Hello').expect(200, 'HELLO'); + expect(status.returnFromCache).to.be.true(); + } + }); + + it('invokes the controller method after cache is cleared', async () => { + clearCache(); + await client.get('/toUpperCase/Hello').expect(200, 'HELLO'); + expect(status.returnFromCache).to.be.false(); + }); + }); + + context('caching invocation for route handler functions', () => { + it('invokes the handler function if not cached', async () => { + await client.get('/toLowerCase/Hello').expect(200, 'hello'); + expect(status.returnFromCache).to.be.false(); + }); + + it('returns from cache without invoking the handler function', async () => { + for (let i = 0; i <= 5; i++) { + await client.get('/toLowerCase/Hello').expect(200, 'hello'); + expect(status.returnFromCache).to.be.true(); + } + }); + + it('invokes the handler function after cache is cleared', async () => { + cachedResults.clear(); + await client.get('/toLowerCase/Hello').expect(200, 'hello'); + expect(status.returnFromCache).to.be.false(); + }); + }); + + /** + * OpenAPI operation spec for `toLowerCase(text: string)` + */ + const toLowerCaseOperationSpec = anOperationSpec() + .withOperationName('toLowerCase') + .withParameter({ + name: 'text', + in: 'path', + schema: { + type: 'string', + }, + }) + .withStringResponse() + .build(); + + /** + * A plain function to convert `text` to lower case + * @param text + */ + function toLowerCase(text: string) { + return text.toLowerCase(); + } + + async function givenAClient() { + clearCache(); + app = new RestApplication({rest: givenHttpServerConfig()}); + app + .bind('caching-interceptor') + .toProvider(CachingInterceptorProvider) + .apply(asGlobalInterceptor()); + app.controller(StringCaseController); + app.route( + 'get', + '/toLowerCase/{text}', + toLowerCaseOperationSpec, + toLowerCase, + ); + await app.start(); + client = createRestAppClient(app); + } + + /** + * A controller using interceptors for caching + */ + class StringCaseController { + @get('/toUpperCase/{text}') + toUpperCase(@param.path.string('text') text: string) { + return text.toUpperCase(); + } + } +}); diff --git a/packages/rest/src/router/handler-route.ts b/packages/rest/src/router/handler-route.ts index 999f443fd632..5d972fa16823 100644 --- a/packages/rest/src/router/handler-route.ts +++ b/packages/rest/src/router/handler-route.ts @@ -3,7 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Context} from '@loopback/context'; +import {Context, invokeMethodWithInterceptors} from '@loopback/context'; import {OperationObject} from '@loopback/openapi-v3-types'; import {OperationArgs, OperationRetval} from '../types'; import {BaseRoute} from './base-route'; @@ -30,6 +30,13 @@ export class Route extends BaseRoute { requestContext: Context, args: OperationArgs, ): Promise { - return await this._handler(...args); + // Use `invokeMethodWithInterceptors` to invoke the handler function so + // that global interceptors are applied + return await invokeMethodWithInterceptors( + requestContext, + this, + '_handler', + args, + ); } }