UNPKG

13.2 kBPlain TextView Raw
1// Copyright IBM Corp. and LoopBack contributors 2019,2020. All Rights Reserved.
2// Node module: @loopback/context
3// This file is licensed under the MIT License.
4// License text available at https://opensource.org/licenses/MIT
5
6import {
7 ClassDecoratorFactory,
8 DecoratorFactory,
9 MetadataAccessor,
10 MetadataInspector,
11 MetadataMap,
12 MethodDecoratorFactory,
13} from '@loopback/metadata';
14import assert from 'assert';
15import debugFactory from 'debug';
16import {Binding, BindingTemplate} from './binding';
17import {injectable} from './binding-decorator';
18import {
19 BindingFromClassOptions,
20 BindingSpec,
21 createBindingFromClass,
22 isProviderClass,
23} from './binding-inspector';
24import {BindingAddress, BindingKey} from './binding-key';
25import {sortBindingsByPhase} from './binding-sorter';
26import {Context} from './context';
27import {
28 GenericInterceptor,
29 GenericInterceptorOrKey,
30 invokeInterceptors,
31} from './interceptor-chain';
32import {
33 InvocationArgs,
34 InvocationContext,
35 InvocationOptions,
36 InvocationResult,
37} from './invocation';
38import {
39 ContextBindings,
40 ContextTags,
41 GLOBAL_INTERCEPTOR_NAMESPACE,
42 LOCAL_INTERCEPTOR_NAMESPACE,
43} from './keys';
44import {Provider} from './provider';
45import {Constructor, tryWithFinally, ValueOrPromise} from './value-promise';
46const debug = debugFactory('loopback:context:interceptor');
47
48/**
49 * A specialized InvocationContext for interceptors
50 */
51export class InterceptedInvocationContext extends InvocationContext {
52 /**
53 * Discover all binding keys for global interceptors (tagged by
54 * ContextTags.GLOBAL_INTERCEPTOR)
55 */
56 getGlobalInterceptorBindingKeys(): string[] {
57 let bindings: Readonly<Binding<Interceptor>>[] = this.findByTag(
58 ContextTags.GLOBAL_INTERCEPTOR,
59 );
60 bindings = bindings.filter(binding =>
61 // Only include interceptors that match the source type of the invocation
62 this.applicableTo(binding),
63 );
64
65 this.sortGlobalInterceptorBindings(bindings);
66 const keys = bindings.map(b => b.key);
67 debug('Global interceptor binding keys:', keys);
68 return keys;
69 }
70
71 /**
72 * Check if the binding for a global interceptor matches the source type
73 * of the invocation
74 * @param binding - Binding
75 */
76 private applicableTo(binding: Readonly<Binding<unknown>>) {
77 const sourceType = this.source?.type;
78 // Unknown source type, always apply
79 if (sourceType == null) return true;
80 const allowedSource: string | string[] =
81 binding.tagMap[ContextTags.GLOBAL_INTERCEPTOR_SOURCE];
82 return (
83 // No tag, always apply
84 allowedSource == null ||
85 // source matched
86 allowedSource === sourceType ||
87 // source included in the string[]
88 (Array.isArray(allowedSource) && allowedSource.includes(sourceType))
89 );
90 }
91
92 /**
93 * Sort global interceptor bindings by `globalInterceptorGroup` tags
94 * @param bindings - An array of global interceptor bindings
95 */
96 private sortGlobalInterceptorBindings(
97 bindings: Readonly<Binding<Interceptor>>[],
98 ) {
99 // Get predefined ordered groups for global interceptors
100 const orderedGroups =
101 this.getSync(ContextBindings.GLOBAL_INTERCEPTOR_ORDERED_GROUPS, {
102 optional: true,
103 }) ?? [];
104 return sortBindingsByPhase(
105 bindings,
106 ContextTags.GLOBAL_INTERCEPTOR_GROUP,
107 orderedGroups,
108 );
109 }
110
111 /**
112 * Load all interceptors for the given invocation context. It adds
113 * interceptors from possibly three sources:
114 * 1. method level `@intercept`
115 * 2. class level `@intercept`
116 * 3. global interceptors discovered in the context
117 */
118 loadInterceptors() {
119 let interceptors =
120 MetadataInspector.getMethodMetadata(
121 INTERCEPT_METHOD_KEY,
122 this.target,
123 this.methodName,
124 ) ?? [];
125 const targetClass =
126 typeof this.target === 'function' ? this.target : this.target.constructor;
127 const classInterceptors =
128 MetadataInspector.getClassMetadata(INTERCEPT_CLASS_KEY, targetClass) ??
129 [];
130 // Inserting class level interceptors before method level ones
131 interceptors = mergeInterceptors(classInterceptors, interceptors);
132 const globalInterceptors = this.getGlobalInterceptorBindingKeys();
133 // Inserting global interceptors
134 interceptors = mergeInterceptors(globalInterceptors, interceptors);
135 debug('Interceptors for %s', this.targetName, interceptors);
136 return interceptors;
137 }
138}
139
140/**
141 * The `BindingTemplate` function to configure a binding as a global interceptor
142 * by tagging it with `ContextTags.INTERCEPTOR`
143 * @param group - Group for ordering the interceptor
144 */
145export function asGlobalInterceptor(group?: string): BindingTemplate {
146 return binding => {
147 binding
148 // Tagging with `GLOBAL_INTERCEPTOR` is required.
149 .tag(ContextTags.GLOBAL_INTERCEPTOR)
150 // `GLOBAL_INTERCEPTOR_NAMESPACE` is to make the binding key more readable.
151 .tag({[ContextTags.NAMESPACE]: GLOBAL_INTERCEPTOR_NAMESPACE});
152 if (group) binding.tag({[ContextTags.GLOBAL_INTERCEPTOR_GROUP]: group});
153 };
154}
155
156/**
157 * `@globalInterceptor` decorator to mark the class as a global interceptor
158 * @param group - Group for ordering the interceptor
159 * @param specs - Extra binding specs
160 */
161export function globalInterceptor(group?: string, ...specs: BindingSpec[]) {
162 return injectable(asGlobalInterceptor(group), ...specs);
163}
164
165/**
166 * Interceptor function to intercept method invocations
167 */
168export interface Interceptor extends GenericInterceptor<InvocationContext> {}
169
170/**
171 * Interceptor function or binding key that can be used as parameters for
172 * `@intercept()`
173 */
174export type InterceptorOrKey = GenericInterceptorOrKey<InvocationContext>;
175
176/**
177 * Metadata key for method-level interceptors
178 */
179export const INTERCEPT_METHOD_KEY = MetadataAccessor.create<
180 InterceptorOrKey[],
181 MethodDecorator
182>('intercept:method');
183
184/**
185 * Adding interceptors from the spec to the front of existing ones. Duplicate
186 * entries are eliminated from the spec side.
187 *
188 * For example:
189 *
190 * - [log] + [cache, log] => [cache, log]
191 * - [log] + [log, cache] => [log, cache]
192 * - [] + [cache, log] => [cache, log]
193 * - [cache, log] + [] => [cache, log]
194 * - [log] + [cache] => [log, cache]
195 *
196 * @param interceptorsFromSpec - Interceptors from `@intercept`
197 * @param existingInterceptors - Interceptors already applied for the method
198 */
199export function mergeInterceptors(
200 interceptorsFromSpec: InterceptorOrKey[],
201 existingInterceptors: InterceptorOrKey[],
202) {
203 const interceptorsToApply = new Set(interceptorsFromSpec);
204 const appliedInterceptors = new Set(existingInterceptors);
205 // Remove interceptors that already exist
206 for (const i of interceptorsToApply) {
207 if (appliedInterceptors.has(i)) {
208 interceptorsToApply.delete(i);
209 }
210 }
211 // Add existing interceptors after ones from the spec
212 for (const i of appliedInterceptors) {
213 interceptorsToApply.add(i);
214 }
215 return Array.from(interceptorsToApply);
216}
217
218/**
219 * Metadata key for method-level interceptors
220 */
221export const INTERCEPT_CLASS_KEY = MetadataAccessor.create<
222 InterceptorOrKey[],
223 ClassDecorator
224>('intercept:class');
225
226/**
227 * A factory to define `@intercept` for classes. It allows `@intercept` to be
228 * used multiple times on the same class.
229 */
230class InterceptClassDecoratorFactory extends ClassDecoratorFactory<
231 InterceptorOrKey[]
232> {
233 protected mergeWithOwn(ownMetadata: InterceptorOrKey[], target: Object) {
234 ownMetadata = ownMetadata || [];
235 return mergeInterceptors(this.spec, ownMetadata);
236 }
237}
238
239/**
240 * A factory to define `@intercept` for methods. It allows `@intercept` to be
241 * used multiple times on the same method.
242 */
243class InterceptMethodDecoratorFactory extends MethodDecoratorFactory<
244 InterceptorOrKey[]
245> {
246 protected mergeWithOwn(
247 ownMetadata: MetadataMap<InterceptorOrKey[]>,
248 target: Object,
249 methodName: string,
250 methodDescriptor: TypedPropertyDescriptor<unknown>,
251 ) {
252 ownMetadata = ownMetadata || {};
253 const interceptors = ownMetadata[methodName] || [];
254
255 // Adding interceptors to the list
256 ownMetadata[methodName] = mergeInterceptors(this.spec, interceptors);
257
258 return ownMetadata;
259 }
260}
261
262/**
263 * Decorator function `@intercept` for classes/methods to apply interceptors. It
264 * can be applied on a class and its public methods. Multiple occurrences of
265 * `@intercept` are allowed on the same target class or method. The decorator
266 * takes a list of `interceptor` functions or binding keys.
267 *
268 * @example
269 * ```ts
270 * @intercept(log, metrics)
271 * class MyController {
272 * @intercept('caching-interceptor')
273 * @intercept('name-validation-interceptor')
274 * greet(name: string) {
275 * return `Hello, ${name}`;
276 * }
277 * }
278 * ```
279 *
280 * @param interceptorOrKeys - One or more interceptors or binding keys that are
281 * resolved to be interceptors
282 */
283export function intercept(...interceptorOrKeys: InterceptorOrKey[]) {
284 return function interceptDecoratorForClassOrMethod(
285 // Class or a prototype
286 // eslint-disable-next-line @typescript-eslint/no-explicit-any
287 target: any,
288 method?: string,
289 // Use `any` to for `TypedPropertyDescriptor`
290 // See https://github.com/loopbackio/loopback-next/pull/2704
291 // eslint-disable-next-line @typescript-eslint/no-explicit-any
292 methodDescriptor?: TypedPropertyDescriptor<any>,
293 ) {
294 if (method && methodDescriptor) {
295 // Method
296 return InterceptMethodDecoratorFactory.createDecorator(
297 INTERCEPT_METHOD_KEY,
298 interceptorOrKeys,
299 {decoratorName: '@intercept'},
300 )(target, method, methodDescriptor!);
301 }
302 if (typeof target === 'function' && !method && !methodDescriptor) {
303 // Class
304 return InterceptClassDecoratorFactory.createDecorator(
305 INTERCEPT_CLASS_KEY,
306 interceptorOrKeys,
307 {decoratorName: '@intercept'},
308 )(target);
309 }
310 // Not on a class or method
311 throw new Error(
312 '@intercept cannot be used on a property: ' +
313 DecoratorFactory.getTargetName(target, method, methodDescriptor),
314 );
315 };
316}
317
318/**
319 * Invoke a method with the given context
320 * @param context - Context object
321 * @param target - Target class (for static methods) or object (for instance methods)
322 * @param methodName - Method name
323 * @param args - An array of argument values
324 * @param options - Options for the invocation
325 */
326export function invokeMethodWithInterceptors(
327 context: Context,
328 target: object,
329 methodName: string,
330 args: InvocationArgs,
331 options: InvocationOptions = {},
332): ValueOrPromise<InvocationResult> {
333 // Do not allow `skipInterceptors` as it's against the function name
334 // `invokeMethodWithInterceptors`
335 assert(!options.skipInterceptors, 'skipInterceptors is not allowed');
336 const invocationCtx = new InterceptedInvocationContext(
337 context,
338 target,
339 methodName,
340 args,
341 options.source,
342 );
343
344 invocationCtx.assertMethodExists();
345 return tryWithFinally(
346 () => {
347 const interceptors = invocationCtx.loadInterceptors();
348 const targetMethodInvoker = () =>
349 invocationCtx.invokeTargetMethod(options);
350 interceptors.push(targetMethodInvoker);
351 return invokeInterceptors(invocationCtx, interceptors);
352 },
353 () => invocationCtx.close(),
354 );
355}
356
357/**
358 * Options for an interceptor binding
359 */
360export interface InterceptorBindingOptions extends BindingFromClassOptions {
361 /**
362 * Global or local interceptor
363 */
364 global?: boolean;
365 /**
366 * Group name for a global interceptor
367 */
368 group?: string;
369 /**
370 * Source filter for a global interceptor
371 */
372 source?: string | string[];
373}
374
375/**
376 * Register an interceptor function or provider class to the given context
377 * @param ctx - Context object
378 * @param interceptor - An interceptor function or provider class
379 * @param options - Options for the interceptor binding
380 */
381export function registerInterceptor(
382 ctx: Context,
383 interceptor: Interceptor | Constructor<Provider<Interceptor>>,
384 options: InterceptorBindingOptions = {},
385) {
386 let {global} = options;
387 const {group, source} = options;
388 if (group != null || source != null) {
389 // If group or source is set, assuming global
390 global = global !== false;
391 }
392
393 const namespace =
394 options.namespace ?? options.defaultNamespace ?? global
395 ? GLOBAL_INTERCEPTOR_NAMESPACE
396 : LOCAL_INTERCEPTOR_NAMESPACE;
397
398 let binding: Binding<Interceptor>;
399 if (isProviderClass(interceptor)) {
400 binding = createBindingFromClass(interceptor, {
401 defaultNamespace: namespace,
402 ...options,
403 });
404 if (binding.tagMap[ContextTags.GLOBAL_INTERCEPTOR]) {
405 global = true;
406 }
407 ctx.add(binding);
408 } else {
409 let key = options.key;
410 if (!key) {
411 const name = options.name ?? interceptor.name;
412 if (!name) {
413 key = BindingKey.generate<Interceptor>(namespace).key;
414 } else {
415 key = `${namespace}.${name}`;
416 }
417 }
418 binding = ctx
419 .bind(key as BindingAddress<Interceptor>)
420 .to(interceptor as Interceptor);
421 }
422 if (global) {
423 binding.apply(asGlobalInterceptor(group));
424 if (source) {
425 binding.tag({[ContextTags.GLOBAL_INTERCEPTOR_SOURCE]: source});
426 }
427 }
428
429 return binding;
430}