Page Contents

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

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 applied
  • Promise<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 the metrics 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'});

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

    @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

    @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

    @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

    @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:

    app.interceptor(authInterceptor, {
      name: 'authInterceptor',
      global: true,
      group: 'auth',
    });
    

    If the group tag does not exist, the value is default to ''.

  2. 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 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.
/**
 * 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 name
  • args (InvocationArgs, i.e., any[]): An array of arguments
  • source: 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:

  1. A route to a controller method

    • The source describes the REST Route
  2. A controller to a repository/service with injected proxy

    • The source describes a ResolutionSession that tracks a stack of bindings and injections
  3. A controller/repository/service method invoked explicitly with invokeMethodWithInterceptors() or invokeMethod

    • The source can be set by the caller of invokeMethodWithInterceptors() or invokeMethod

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:

  1. 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;
    };
    
  2. 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;
      }
    };
    
  3. 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;
    };
    
  4. 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();
      }
    }
    
  5. 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();