@loopback/express
Version:
Integrate with Express and expose middleware infrastructure for sequence and interceptors
424 lines (403 loc) • 12.5 kB
text/typescript
// Copyright IBM Corp. and LoopBack contributors 2020. All Rights Reserved.
// Node module: @loopback/express
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT
import {
asGlobalInterceptor,
Binding,
BindingKey,
BindingScope,
config,
Constructor,
Context,
ContextTags,
ContextView,
createBindingFromClass,
GenericInterceptor,
GenericInterceptorChain,
inject,
Interceptor,
InvocationContext,
NamespacedReflect,
Provider,
} from '@loopback/core';
import assert from 'assert';
import debugFactory from 'debug';
import onFinished from 'on-finished';
import {promisify} from 'util';
import {
DEFAULT_MIDDLEWARE_GROUP,
GLOBAL_MIDDLEWARE_INTERCEPTOR_NAMESPACE,
MiddlewareBindings,
MIDDLEWARE_INTERCEPTOR_NAMESPACE,
} from './keys';
import {
ExpressMiddlewareFactory,
ExpressRequestHandler,
MiddlewareContext,
MiddlewareCreationOptions,
MiddlewareInterceptorBindingOptions,
Request,
Response,
} from './types';
const debug = debugFactory('loopback:middleware');
const onFinishedAsync = promisify(onFinished);
/**
* Execute an Express-style callback-based request handler.
*
* @param handler - Express middleware handler function
* @param request
* @param response
* @returns A promise resolved to:
* - `true` when the request was handled
* - `false` when the handler called `next()` to proceed to the next
* handler (middleware) in the chain.
*/
export function executeExpressRequestHandler(
handler: ExpressRequestHandler,
request: Request,
response: Response,
): Promise<boolean> {
const responseWritten = onFinishedAsync(response).then(() => true);
const handlerFinished = new Promise<boolean>((resolve, reject) => {
handler(request, response, (err: unknown) => {
if (err) {
reject(err);
} else {
// Express router called next, which means no route was matched
debug('[%s] Handler calling next()', handler.name, err);
resolve(false);
}
});
});
/**
* Express middleware may handle the response by itself and not call
* `next`. We use `Promise.race()` to determine if we need to proceed
* with next interceptor in the chain or just return.
*/
return Promise.race([handlerFinished, responseWritten]);
}
/**
* Wrap an express middleware handler function as an interceptor
*
* @example
* ```ts
* toInterceptor(fn);
* toInterceptor(fn1, fn2, fn3);
* ```
* @param firstHandler - An Express middleware handler
* @param additionalHandlers - A list of Express middleware handler function
*
* @typeParam CTX - Context type
*/
export function toInterceptor<CTX extends Context = InvocationContext>(
firstHandler: ExpressRequestHandler,
...additionalHandlers: ExpressRequestHandler[]
): GenericInterceptor<CTX> {
if (additionalHandlers.length === 0) {
const handlerFn = firstHandler;
return toInterceptorFromExpressMiddleware<CTX>(handlerFn);
}
const handlers = [firstHandler, ...additionalHandlers];
const interceptorList = handlers.map(handler => toInterceptor<CTX>(handler));
return async (invocationCtx, next) => {
const middlewareChain = new GenericInterceptorChain(
invocationCtx,
interceptorList,
);
return middlewareChain.invokeInterceptors(next);
};
}
function toInterceptorFromExpressMiddleware<
CTX extends Context = InvocationContext,
>(handlerFn: ExpressRequestHandler): GenericInterceptor<CTX> {
return async (context, next) => {
const middlewareCtx = await context.get<MiddlewareContext>(
MiddlewareBindings.CONTEXT,
);
const finished = await executeExpressRequestHandler(
handlerFn,
middlewareCtx.request,
middlewareCtx.response,
);
if (!finished) {
debug('[%s] Proceed with downstream interceptors', handlerFn.name);
const val = await next();
debug(
'[%s] Result received from downstream interceptors',
handlerFn.name,
);
return val;
}
// Return response to indicate the response has been produced
return middlewareCtx.response;
};
}
/**
* Create an interceptor function from express middleware.
* @param middlewareFactory - Express middleware factory function. A wrapper
* can be created if the Express middleware module does not conform to the
* factory pattern and signature.
* @param middlewareConfig - Configuration for the Express middleware
*
* @typeParam CFG - Configuration type
* @typeParam CTX - Context type
*/
export function createInterceptor<CFG, CTX extends Context = InvocationContext>(
middlewareFactory: ExpressMiddlewareFactory<CFG>,
middlewareConfig?: CFG,
): GenericInterceptor<CTX> {
const handlerFn = middlewareFactory(middlewareConfig);
return toInterceptor(handlerFn);
}
/**
* Base class for MiddlewareInterceptor provider classes
*
* @example
*
* To inject the configuration without automatic reloading:
*
* ```ts
* class SpyInterceptorProvider extends ExpressMiddlewareInterceptorProvider<
* SpyConfig
* > {
* constructor(@config() spyConfig?: SpyConfig) {
* super(spy, spyConfig);
* }
* }
* ```
*
* To inject the configuration without automatic reloading:
* ```ts
* class SpyInterceptorProvider extends ExpressMiddlewareInterceptorProvider<
* SpyConfig
* > {
* constructor(@config.view() configView?: ContextView<SpyConfig>) {
* super(spy, configView);
* }
* }
* ```
*
* @typeParam CFG - Configuration type
*/
export abstract class ExpressMiddlewareInterceptorProvider<
CFG,
CTX extends Context = InvocationContext,
> implements Provider<GenericInterceptor<CTX>>
{
protected middlewareConfigView?: ContextView<CFG>;
protected middlewareConfig?: CFG;
constructor(
protected middlewareFactory: ExpressMiddlewareFactory<CFG>,
middlewareConfig?: CFG | ContextView<CFG>,
) {
if (middlewareConfig != null && middlewareConfig instanceof ContextView) {
this.middlewareConfigView = middlewareConfig;
} else {
this.middlewareConfig = middlewareConfig;
}
this.setupConfigView();
}
// Inject current binding for debugging
.binding()
private binding?: Binding<GenericInterceptor<CTX>>;
/**
* Cached interceptor instance. It has three states:
*
* - undefined: Not initialized
* - null: To be recreated as the configuration is changed
* - function: The interceptor function created from the latest configuration
*/
private interceptor?: GenericInterceptor<CTX> | null;
private setupConfigView() {
if (this.middlewareConfigView) {
// Set up a listener to reset the cached interceptor function for the
// first time
this.middlewareConfigView.on('refresh', () => {
if (this.binding != null) {
debug(
'Configuration change is detected for binding %s.' +
' The Express middleware handler function will be recreated.',
this.binding.key,
);
}
this.interceptor = null;
});
}
}
value(): GenericInterceptor<CTX> {
return async (ctx, next) => {
// Get the latest configuration
if (this.middlewareConfigView != null) {
this.middlewareConfig =
(await this.middlewareConfigView.singleValue()) ??
this.middlewareConfig;
}
if (this.interceptor == null) {
// Create a new interceptor for the first time or recreate it if it
// was reset to `null` when its configuration changed
debug(
'Creating interceptor for %s with config',
this.middlewareFactory.name,
this.middlewareConfig,
);
this.interceptor = createInterceptor(
this.middlewareFactory,
this.middlewareConfig,
);
}
return this.interceptor(ctx, next);
};
}
}
/**
* Define a provider class that wraps the middleware as an interceptor
* @param middlewareFactory - Express middleware factory function
* @param defaultMiddlewareConfig - Default middleware config
* @param className - Class name for the generated provider class
*
* @typeParam CFG - Configuration type
* @typeParam CTX - Context type
*/
export function defineInterceptorProvider<
CFG,
CTX extends Context = InvocationContext,
>(
middlewareFactory: ExpressMiddlewareFactory<CFG>,
defaultMiddlewareConfig?: CFG,
options?: MiddlewareCreationOptions,
): Constructor<Provider<GenericInterceptor<CTX>>> {
let className = options?.providerClassName;
className = buildName(middlewareFactory, className);
assert(className, 'className is missing and it cannot be inferred.');
const defineNamedClass = new Function(
'middlewareFactory',
'defaultMiddlewareConfig',
'MiddlewareInterceptorProvider',
'createInterceptor',
`return class ${className} extends MiddlewareInterceptorProvider {
constructor(middlewareConfig) {
super(
middlewareFactory,
middlewareConfig,
);
if (this.middlewareConfig == null) {
this.middlewareConfig = defaultMiddlewareConfig;
}
}
};`,
);
const cls = defineNamedClass(
middlewareFactory,
defaultMiddlewareConfig,
ExpressMiddlewareInterceptorProvider,
createInterceptor,
);
if (options?.injectConfiguration === 'watch') {
// Inject the config view
config.view()(cls, '', 0);
new NamespacedReflect().metadata('design:paramtypes', [ContextView])(cls);
} else {
// Inject the config
config()(cls, '', 0);
}
return cls;
}
/**
* Build a name for the middleware
* @param middlewareFactory - Express middleware factory function
* @param providedName - Provided name
* @param suffix - Suffix
*/
export function buildName<CFG>(
middlewareFactory: ExpressMiddlewareFactory<CFG>,
providedName?: string,
suffix?: string,
) {
if (!providedName) {
let name = middlewareFactory.name;
name = name.replace(/[^\w]/g, '_');
if (name) {
providedName = `${name}${suffix ?? ''}`;
}
}
return providedName;
}
/**
* Bind a middleware interceptor to the given context
*
* @param ctx - Context object
* @param middlewareFactory - Express middleware factory function
* @param middlewareConfig - Express middleware config
* @param options - Options for registration
*
* @typeParam CFG - Configuration type
*/
export function registerExpressMiddlewareInterceptor<CFG>(
ctx: Context,
middlewareFactory: ExpressMiddlewareFactory<CFG>,
middlewareConfig?: CFG,
options: MiddlewareInterceptorBindingOptions = {},
) {
options = {
injectConfiguration: true,
global: true,
group: DEFAULT_MIDDLEWARE_GROUP,
...options,
};
if (!options.injectConfiguration) {
let key = options.key;
if (!key) {
const name = buildName(middlewareFactory);
const namespace = options.global
? GLOBAL_MIDDLEWARE_INTERCEPTOR_NAMESPACE
: MIDDLEWARE_INTERCEPTOR_NAMESPACE;
key = name ? `${namespace}.${name}` : BindingKey.generate(namespace);
}
const binding = ctx
.bind(key!)
.to(createInterceptor(middlewareFactory, middlewareConfig));
if (options.global) {
binding.tag({[ContextTags.GLOBAL_INTERCEPTOR_SOURCE]: 'route'});
binding.apply(asGlobalInterceptor(options.group));
}
return binding;
}
const providerClass = defineInterceptorProvider(
middlewareFactory,
middlewareConfig,
options,
);
const binding = createMiddlewareInterceptorBinding(providerClass, options);
ctx.add(binding);
return binding;
}
/**
* Create a binding for the middleware based interceptor
*
* @param middlewareProviderClass - Middleware provider class
* @param options - Options to create middlewareFactory interceptor binding
*
*/
export function createMiddlewareInterceptorBinding(
middlewareProviderClass: Constructor<Provider<Interceptor>>,
options: MiddlewareInterceptorBindingOptions = {},
) {
options = {
global: true,
group: DEFAULT_MIDDLEWARE_GROUP,
...options,
};
const namespace = options.global
? GLOBAL_MIDDLEWARE_INTERCEPTOR_NAMESPACE
: MIDDLEWARE_INTERCEPTOR_NAMESPACE;
const binding = createBindingFromClass(middlewareProviderClass, {
defaultScope: BindingScope.SINGLETON,
namespace,
});
if (options.global) {
binding.tag({[ContextTags.GLOBAL_INTERCEPTOR_SOURCE]: 'route'});
binding.apply(asGlobalInterceptor(options.group));
}
return binding;
}