UNPKG

contexify

Version:

A TypeScript library providing a powerful dependency injection container with context-based IoC capabilities, inspired by LoopBack's Context system.

438 lines (410 loc) 13.3 kB
import assert from 'assert'; import { ClassDecoratorFactory, DecoratorFactory, MetadataAccessor, MetadataInspector, type MetadataMap, MethodDecoratorFactory, } from 'metarize'; import type { Binding, BindingTemplate } from '../binding/binding.js'; import { injectable } from '../binding/binding-decorator.js'; import { type BindingFromClassOptions, type BindingSpec, createBindingFromClass, isProviderClass, } from '../binding/binding-inspector.js'; import { type BindingAddress, BindingKey } from '../binding/binding-key.js'; import { sortBindingsByPhase } from '../binding/binding-sorter.js'; import type { Context } from '../context/context.js'; import type { Provider } from '../provider/provider.js'; import createDebugger from '../utils/debug.js'; import { ContextBindings, ContextTags, GLOBAL_INTERCEPTOR_NAMESPACE, LOCAL_INTERCEPTOR_NAMESPACE, } from '../utils/keys.js'; import { type Constructor, tryWithFinally, type ValueOrPromise, } from '../utils/value-promise.js'; import { type GenericInterceptor, type GenericInterceptorOrKey, invokeInterceptors, } from './interceptor-chain.js'; import { type InvocationArgs, InvocationContext, type InvocationOptions, type InvocationResult, } from './invocation.js'; const debug = createDebugger('contexify:interceptor'); /** * A specialized InvocationContext for interceptors */ export class InterceptedInvocationContext extends InvocationContext { /** * Discover all binding keys for global interceptors (tagged by * ContextTags.GLOBAL_INTERCEPTOR) */ getGlobalInterceptorBindingKeys(): string[] { let bindings: Readonly<Binding<Interceptor>>[] = this.findByTag( ContextTags.GLOBAL_INTERCEPTOR ); bindings = bindings.filter((binding) => // Only include interceptors that match the source type of the invocation this.applicableTo(binding) ); this.sortGlobalInterceptorBindings(bindings); const keys = bindings.map((b) => b.key); if (debug.enabled) { debug('Global interceptor binding keys:', keys); } return keys; } /** * Check if the binding for a global interceptor matches the source type * of the invocation * @param binding - Binding */ private applicableTo(binding: Readonly<Binding<unknown>>) { const sourceType = this.source?.type; // Unknown source type, always apply if (sourceType == null) return true; const allowedSource: string | string[] = binding.tagMap[ContextTags.GLOBAL_INTERCEPTOR_SOURCE]; return ( // No tag, always apply allowedSource == null || // source matched allowedSource === sourceType || // source included in the string[] (Array.isArray(allowedSource) && allowedSource.includes(sourceType)) ); } /** * Sort global interceptor bindings by `globalInterceptorGroup` tags * @param bindings - An array of global interceptor bindings */ private sortGlobalInterceptorBindings( bindings: Readonly<Binding<Interceptor>>[] ) { // Get predefined ordered groups for global interceptors const orderedGroups = this.getSync(ContextBindings.GLOBAL_INTERCEPTOR_ORDERED_GROUPS, { optional: true, }) ?? []; return sortBindingsByPhase( bindings, ContextTags.GLOBAL_INTERCEPTOR_GROUP, orderedGroups ); } /** * 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); if (debug.enabled) { debug('Interceptors for %s', this.targetName, interceptors); } return interceptors; } } /** * The `BindingTemplate` function to configure a binding as a global interceptor * by tagging it with `ContextTags.INTERCEPTOR` * @param group - Group for ordering the interceptor */ export function asGlobalInterceptor(group?: string): BindingTemplate { return (binding) => { binding // Tagging with `GLOBAL_INTERCEPTOR` is required. .tag(ContextTags.GLOBAL_INTERCEPTOR) // `GLOBAL_INTERCEPTOR_NAMESPACE` is to make the binding key more readable. .tag({ [ContextTags.NAMESPACE]: GLOBAL_INTERCEPTOR_NAMESPACE }); if (group) binding.tag({ [ContextTags.GLOBAL_INTERCEPTOR_GROUP]: group }); }; } /** * `@globalInterceptor` decorator to mark the class as a global interceptor * @param group - Group for ordering the interceptor * @param specs - Extra binding specs */ export function globalInterceptor(group?: string, ...specs: BindingSpec[]) { return injectable(asGlobalInterceptor(group), ...specs); } /** * Interceptor function to intercept method invocations */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface Interceptor extends GenericInterceptor<InvocationContext> {} /** * Interceptor function or binding key that can be used as parameters for * `@intercept()` */ export type InterceptorOrKey = GenericInterceptorOrKey<InvocationContext>; /** * 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<InterceptorOrKey[]>, _target: object, methodName: string, _methodDescriptor: TypedPropertyDescriptor<unknown> ) { 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. * * @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( // Class or a prototype // eslint-disable-next-line @typescript-eslint/no-explicit-any target: any, method?: string, // Use `any` to for `TypedPropertyDescriptor` // Using any for TypedPropertyDescriptor // eslint-disable-next-line @typescript-eslint/no-explicit-any methodDescriptor?: TypedPropertyDescriptor<any> ) { if (method && methodDescriptor) { // Method return InterceptMethodDecoratorFactory.createDecorator( INTERCEPT_METHOD_KEY, interceptorOrKeys, { decoratorName: '@intercept' } )(target, method, methodDescriptor!); } if (typeof target === 'function' && !method && !methodDescriptor) { // Class return InterceptClassDecoratorFactory.createDecorator( INTERCEPT_CLASS_KEY, interceptorOrKeys, { decoratorName: '@intercept' } )(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 * @param options - Options for the invocation */ export function invokeMethodWithInterceptors( context: Context, target: object, methodName: string, args: InvocationArgs, options: InvocationOptions = {} ): ValueOrPromise<InvocationResult> { // Do not allow `skipInterceptors` as it's against the function name // `invokeMethodWithInterceptors` assert(!options.skipInterceptors, 'skipInterceptors is not allowed'); const invocationCtx = new InterceptedInvocationContext( context, target, methodName, args, options.source ); invocationCtx.assertMethodExists(); return tryWithFinally( () => { const interceptors = invocationCtx.loadInterceptors(); const targetMethodInvoker = () => invocationCtx.invokeTargetMethod(options); interceptors.push(targetMethodInvoker); return invokeInterceptors(invocationCtx, interceptors); }, () => invocationCtx.close() ); } /** * Options for an interceptor binding */ export interface InterceptorBindingOptions extends BindingFromClassOptions { /** * Global or local interceptor */ global?: boolean; /** * Group name for a global interceptor */ group?: string; /** * Source filter for a global interceptor */ source?: string | string[]; } /** * Register an interceptor function or provider class to the given context * @param ctx - Context object * @param interceptor - An interceptor function or provider class * @param options - Options for the interceptor binding */ export function registerInterceptor( ctx: Context, interceptor: Interceptor | Constructor<Provider<Interceptor>>, options: InterceptorBindingOptions = {} ) { let { global } = options; const { group, source } = options; if (group != null || source != null) { // If group or source is set, assuming global global = global !== false; } const namespace = (options.namespace ?? options.defaultNamespace ?? global) ? GLOBAL_INTERCEPTOR_NAMESPACE : LOCAL_INTERCEPTOR_NAMESPACE; let binding: Binding<Interceptor>; if (isProviderClass(interceptor)) { binding = createBindingFromClass(interceptor, { defaultNamespace: namespace, ...options, }); if (binding.tagMap[ContextTags.GLOBAL_INTERCEPTOR]) { global = true; } ctx.add(binding); } else { let key = options.key; if (!key) { const name = options.name ?? interceptor.name; if (!name) { key = BindingKey.generate<Interceptor>(namespace).key; } else { key = `${namespace}.${name}`; } } binding = ctx .bind(key as BindingAddress<Interceptor>) .to(interceptor as Interceptor); } if (global) { binding.apply(asGlobalInterceptor(group)); if (source) { binding.tag({ [ContextTags.GLOBAL_INTERCEPTOR_SOURCE]: source }); } } return binding; }