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.
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.
import {intercept} from '@loopback/core';
@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.
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.
app.interceptor(CachingInterceptorProvider, {
global: true,
group: 'caching',
key: 'caching-interceptor',
});
Global interceptors are also executed for route handler functions without a controller class. See an example in Route Handler.
Create a proxy to apply interceptors
A 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.
import {createProxyWithInterceptors} from '@loopback/core';
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.
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<DummyController>('dummy-controller');
const msg = await dummyController.myController.greet('John');
);
Or:
const proxy = await ctx.get<MyController>('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,
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<MyController>
type:
{
name: string; // the same as MyController
greet(name: string): ValueOrPromise<string>; // the return type becomes `ValueOrPromise<string>`
hello(name: string): Promise<string>; // the same as MyController
}
The return value of greet
now has two possible types:
string
: No async interceptor is appliedPromise<string>
: 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.
import {Context, invokeMethod} from '@loopback/core';
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
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
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
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
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.
// 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 globalInterceptor
tag. For example,
import {asGlobalInterceptor} from '@loopback/core';
app
.bind('globalInterceptors.MetricsInterceptor')
.toProvider(MetricsInterceptorProvider)
.apply(asGlobalInterceptor('metrics'));
Please note asGlobalInterceptor
is a factory function to create binding
templates that mark bindings of global interceptors. It takes an optional
group
name to control the order of invocation. For example:
asGlobalInterceptor('metrics')
: mark a binding as a global interceptor in themetrics
group-
asGlobalInterceptor()
: mark a binding as a global interceptor in the default group
The registration can be further simplified as:
app.interceptor(MetricsInterceptorProvider, {global: true, group: 'metrics'});
Tip: If you need to intercept all requests, including these destinated to a LB3-mounted app, you can use a LoopBack Middleware.
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:
-
A static method on the class -
greetStatic
@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
] -
A static method that requires parameter injection:
greetStaticWithDI
@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
] -
A prototype method with multiple
@intercept
-greetSync
@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
] -
A prototype method that preserves the order of an interceptor -
greet
@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:
-
Tag global interceptor binding with
ContextTags.GLOBAL_INTERCEPTOR_GROUP
. The tag value will be treated as thegroup
name of the interceptor. For example:app.interceptor(authInterceptor, { name: 'authInterceptor', global: true, group: 'auth', });
If the group tag does not exist, the value is default to
''
. -
Control the ordered groups for global interceptors
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.
You can use lb4 interceptor
command to generate
global or local interceptors as provider classes. Such interceptors will be
automatically discovered and bound to the application by the
interceptor booter during
boot()
.
Interceptor functions
The interceptor function is invoked to intercept a method invocation with two parameters:
context
: the invocation contextnext
: 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.
/**
* 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<InvocationResult>,
): ValueOrPromise<InvocationResult>;
}
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 nameargs
(InvocationArgs
, i.e.,any[]
): An array of argumentssource
: Source information about the invoker of the invocation
/**
* 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[]
public readonly source?: InvocationSource,
) {
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.
Source for an invocation
The source
property of InvocationContext
is defined as InvocationSource
:
/**
* An interface to represent the caller of the invocation
*/
export interface InvocationSource<T = unknown> {
/**
* Type of the invoker, such as `proxy` and `route`
*/
readonly type: string;
/**
* Metadata for the source, such as `ResolutionSession`
*/
readonly value: T;
}
The source
describes the caller that invokes a method with interceptors.
Interceptors can be invoked in the following cases:
-
A route to a controller method
- The source describes the REST Route
-
A controller to a repository/service with injected proxy
- The source describes a ResolutionSession that tracks a stack of bindings and injections
-
A controller/repository/service method invoked explicitly with
invokeMethodWithInterceptors()
orinvokeMethod
- The source can be set by the caller of
invokeMethodWithInterceptors()
orinvokeMethod
- The source can be set by the caller of
The implementation of an interceptor can check source
to decide if its logic
should apply. For example, a global interceptor that provides caching for REST
APIs should only run if the source is from a REST Route.
A global interceptor can also be tagged with
ContextTags.GLOBAL_INTERCEPTOR_SOURCE
using a value of string or string array
to indicate if it should be applied to source types of invocations. If the tag
is not present, the interceptor applies to invocations of any source type. For
example:
ctx
.bind('globalInterceptors.authInterceptor')
.to(authInterceptor)
.apply(asGlobalInterceptor('auth'))
// Do not apply for `proxy` source type
.tag({[ContextTags.GLOBAL_INTERCEPTOR_SOURCE]: 'route'});
The tag can also be declared for the provider class.
@globalInterceptor('log', {
tags: {[ContextTags.GLOBAL_INTERCEPTOR_SOURCE]: ['proxy']},
})
export class LogInterceptor implements Provider<Interceptor> {
// ...
}
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:
async function intercept<T>(
invocationCtx: InvocationContext,
next: () => ValueOrPromise<T>,
) {
// 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:
-
An asynchronous interceptor to log method invocations:
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; };
-
An interceptor to catch and log errors:
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; } };
-
An interceptor to convert
name
arg to upper case: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; };
-
An provider class for an interceptor that performs parameter validation
To leverage dependency injection, a provider class can be defined as the interceptor:
/** * A binding provider class to produce an interceptor that validates the * `name` argument */ class NameValidator implements Provider<Interceptor> { constructor(@inject('valid-names') private validNames: string[]) {} value() { return this.intercept.bind(this); } async intercept<T>( invocationCtx: InvocationContext, next: () => ValueOrPromise<T>, ) { const name = invocationCtx.args[0]; if (!this.validNames.includes(name)) { throw new Error( `Name '${name}' is not on the list of '${this.validNames}`, ); } return next(); } }
-
A synchronous interceptor to log method invocations:
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; };
Compose multiple interceptors
Sometimes we want to apply more than one interceptors together as a whole. It
can be done by composeInterceptors
:
import {composeInterceptors} from '@loopback/core';
const interceptor = composeInterceptors(
interceptorFn1,
'interceptors.my-interceptor',
interceptorFn2,
);
The code above composes interceptorFn1
and interceptorFn2
functions with
interceptors.my-interceptor
binding key into a single interceptor.
Build your own interceptor chains
Behind the scenes, interceptors are chained one by one by their orders into an invocation chain. GenericInvocationChain is the base class that can be extended to create your own flavor of interceptors and chains. For example,
import {GenericInvocationChain, GenericInterceptor} from '@loopback/core';
import {RequestContext} from '@loopback/rest';
export interface RequestInterceptor
extends GenericInterceptor<RequestContext> {}
export class RequestInterceptorChain extends GenericInterceptorChain<RequestContext> {}
The interceptor chain can be instantiated in two styles:
- with a list of interceptor functions or binding keys
- with a binding filter function to discover matching interceptors within the context
Once the chain is built, it can be invoked using invokeInterceptors
:
const chain = new RequestInterceptorChain(requestCtx, interceptors);
await chain.invokeInterceptors();
It’s also possible to pass in a final handler:
import {Next} from '@loopback/core';
const finalHandler: Next = async () => {
// return ...;
};
await chain.invokeInterceptors(finalHandler);
The invocation chain itself can be used a single interceptor so that it be registered as one handler to another chain.
const chain = new RequestInterceptorChain(requestCtx, interceptors);
const interceptor = chain.asInterceptor();