From 335e87ca14773f541e73a117248ff0ddc213f5ff Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Sun, 3 May 2020 00:14:41 -0700 Subject: [PATCH 1/5] feat(context): allow composition of intercetors --- .../__tests__/unit/interceptor-chain.unit.ts | 49 +++++++++++++++++++ packages/context/src/interceptor-chain.ts | 40 +++++++++++++-- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/packages/context/src/__tests__/unit/interceptor-chain.unit.ts b/packages/context/src/__tests__/unit/interceptor-chain.unit.ts index d4301c061b5a..953c0bfc503c 100644 --- a/packages/context/src/__tests__/unit/interceptor-chain.unit.ts +++ b/packages/context/src/__tests__/unit/interceptor-chain.unit.ts @@ -6,6 +6,7 @@ import {expect} from '@loopback/testlab'; import { compareBindingsByTag, + composeInterceptors, Context, filterByTag, GenericInterceptor, @@ -54,6 +55,20 @@ describe('GenericInterceptorChain', () => { expect(result).to.eql('ABC'); }); + it('honors final handler', async () => { + givenInterceptorChain( + givenNamedInterceptor('interceptor1'), + async (context, next) => { + return next(); + }, + ); + const finalHandler = () => { + return 'final'; + }; + const result = await interceptorChain.invokeInterceptors(finalHandler); + expect(result).to.eql('final'); + }); + it('skips downstream interceptors if next is not invoked', async () => { givenInterceptorChain(async (context, next) => { return 'ABC'; @@ -157,6 +172,40 @@ describe('GenericInterceptorChain', () => { ]); }); + it('can be used as an interceptor', async () => { + givenInterceptorChain( + givenNamedInterceptor('interceptor1'), + async (context, next) => { + await next(); + return 'ABC'; + }, + ); + const interceptor = interceptorChain.asInterceptor(); + let invoked = false; + await interceptor(new Context(), () => { + invoked = true; + return invoked; + }); + expect(invoked).to.be.true(); + }); + + it('composes multiple interceptors as a single interceptor', async () => { + const interceptor = composeInterceptors( + givenNamedInterceptor('interceptor1'), + async (context, next) => { + await next(); + return 'ABC'; + }, + ); + let invoked = false; + const result = await interceptor(new Context(), () => { + invoked = true; + return invoked; + }); + expect(invoked).to.be.true(); + expect(result).to.eql('ABC'); + }); + function givenContext() { events = []; ctx = new Context(); diff --git a/packages/context/src/interceptor-chain.ts b/packages/context/src/interceptor-chain.ts index 02e71e934cfc..91ebe6d323f6 100644 --- a/packages/context/src/interceptor-chain.ts +++ b/packages/context/src/interceptor-chain.ts @@ -53,8 +53,12 @@ class InterceptorChainState { /** * Create a state for the interceptor chain * @param interceptors - Interceptor functions or binding keys + * @param finalHandler - An optional final handler */ - constructor(private interceptors: GenericInterceptorOrKey[]) {} + constructor( + public readonly interceptors: GenericInterceptorOrKey[], + public readonly finalHandler: Next = () => undefined, + ) {} /** * Get the index for the current interceptor @@ -138,12 +142,24 @@ export class GenericInterceptorChain { /** * Invoke the interceptor chain */ - invokeInterceptors(): ValueOrPromise { + invokeInterceptors(finalHandler?: Next): ValueOrPromise { // Create a state for each invocation to provide isolation - const state = new InterceptorChainState(this.getInterceptors()); + const state = new InterceptorChainState( + this.getInterceptors(), + finalHandler, + ); return this.next(state); } + /** + * Use the interceptor chain as an interceptor + */ + asInterceptor(): GenericInterceptor { + return (ctx, next) => { + return this.invokeInterceptors(next); + }; + } + /** * Invoke downstream interceptors or the target method */ @@ -152,7 +168,7 @@ export class GenericInterceptorChain { ): ValueOrPromise { if (state.done()) { // No more interceptors - return undefined; + return state.finalHandler(); } // Invoke the next interceptor in the chain return this.invokeNextInterceptor(state); @@ -206,3 +222,19 @@ export function invokeInterceptors< const chain = new GenericInterceptorChain(context, interceptors); return chain.invokeInterceptors(); } + +/** + * Compose a list of interceptors as a single interceptor + * @param interceptors - A list of interceptor functions or binding keys + */ +export function composeInterceptors( + ...interceptors: GenericInterceptor[] +): GenericInterceptor { + return (ctx, next) => { + const interceptor = new GenericInterceptorChain( + ctx, + interceptors, + ).asInterceptor(); + return interceptor(ctx, next); + }; +} From 7f2a186723d5bc1366bd06b40f45dd09a6999af8 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Mon, 4 May 2020 08:13:26 -0700 Subject: [PATCH 2/5] feat(docs): add docs to compose interceptors and extend interceptor chains --- docs/site/Interceptors.md | 69 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/docs/site/Interceptors.md b/docs/site/Interceptors.md index 1ce48b43baad..03ae4dd19c44 100644 --- a/docs/site/Interceptors.md +++ b/docs/site/Interceptors.md @@ -732,3 +732,72 @@ Here are some example interceptor functions: return result; }; ``` + +### Compose multiple interceptors + +Sometimes we want to apply more than one interceptors together as a whole. It +can be done by `composeInterceptors`: + +```ts +import {composeInterceptors} from '@loopback/context'; + +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](https://loopback.io/doc/en/lb4/apidocs.context.genericinterceptorchain.html) +is the base class that can be extended to create your own flavor of interceptors +and chains. For example, + +```ts +import {GenericInvocationChain, GenericInterceptor} from '@loopback/context'; +import {RequestContext} from '@loopback/rest'; + +export interface RequestInterceptor + extends GenericInterceptor {} + +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`: + +```ts +const chain = new RequestInterceptorChain(requestCtx, interceptors); +await chain.invokeInterceptors(); +``` + +It's also possible to pass in a final handler: + +```ts +import {Next} from '@loopback/context'; +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. + +```ts +const chain = new RequestInterceptorChain(requestCtx, interceptors); +const interceptor = chain.asInterceptor(); +``` From 9dcabc51201fc73d3d63289e66d8ed0e52ae74a3 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Sun, 3 May 2020 11:26:22 -0700 Subject: [PATCH 3/5] feat(context): allows default namespace for bindings from classes --- docs/site/Binding.md | 2 ++ .../__tests__/unit/binding-inspector.unit.ts | 28 +++++++++++++++++++ packages/context/src/binding-inspector.ts | 14 ++++++++-- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/docs/site/Binding.md b/docs/site/Binding.md index edfac20a55c7..cc5588ad7144 100644 --- a/docs/site/Binding.md +++ b/docs/site/Binding.md @@ -461,6 +461,8 @@ parameter of `BindingFromClassOptions` type with the following settings: } ``` +- defaultNamespace: Default namespace if namespace or namespace tag does not + exist - defaultScope: Default scope if the binding does not have an explicit scope set. The `scope` from `@bind` of the bound class takes precedence. diff --git a/packages/context/src/__tests__/unit/binding-inspector.unit.ts b/packages/context/src/__tests__/unit/binding-inspector.unit.ts index aac0bbed3bf2..a39d582fc6f1 100644 --- a/packages/context/src/__tests__/unit/binding-inspector.unit.ts +++ b/packages/context/src/__tests__/unit/binding-inspector.unit.ts @@ -14,6 +14,7 @@ import { createBindingFromClass, Provider, } from '../..'; +import {ContextTags} from '../../keys'; describe('createBindingFromClass()', () => { it('inspects classes', () => { @@ -206,6 +207,33 @@ describe('createBindingFromClass()', () => { expect(binding.key).to.eql('services.MyService'); }); + it('honors default namespace with options', () => { + class MyService {} + + @bind({tags: {[ContextTags.NAMESPACE]: 'my-services'}}) + class MyServiceWithNS {} + + const ctx = new Context(); + let binding = givenBindingFromClass(MyService, ctx, { + defaultNamespace: 'services', + }); + + expect(binding.key).to.eql('services.MyService'); + + binding = givenBindingFromClass(MyService, ctx, { + namespace: 'my-services', + defaultNamespace: 'services', + }); + + expect(binding.key).to.eql('my-services.MyService'); + + binding = givenBindingFromClass(MyServiceWithNS, ctx, { + defaultNamespace: 'services', + }); + + expect(binding.key).to.eql('my-services.MyServiceWithNS'); + }); + it('includes class name in error messages', () => { expect(() => { // Reproduce a problem that @bajtos encountered when the project diff --git a/packages/context/src/binding-inspector.ts b/packages/context/src/binding-inspector.ts index 28b81fc5e363..2fd1bd130067 100644 --- a/packages/context/src/binding-inspector.ts +++ b/packages/context/src/binding-inspector.ts @@ -201,17 +201,23 @@ export type BindingFromClassOptions = { */ type?: string; /** - * Artifact name, such as `my-rest-server` and `my-controller` + * Artifact name, such as `my-rest-server` and `my-controller`. It + * overrides the name tag */ name?: string; /** - * Namespace for the binding key, such as `servers` and `controllers` + * Namespace for the binding key, such as `servers` and `controllers`. It + * overrides the default namespace or namespace tag */ namespace?: string; /** * Mapping artifact type to binding key namespaces */ typeNamespaceMapping?: TypeNamespaceMapping; + /** + * Default namespace if the binding does not have an explicit namespace + */ + defaultNamespace?: string; /** * Default scope if the binding does not have an explicit scope */ @@ -313,7 +319,9 @@ function buildBindingKey( if (key) return key; let namespace = - options.namespace ?? bindingTemplate.tagMap[ContextTags.NAMESPACE]; + options.namespace ?? + bindingTemplate.tagMap[ContextTags.NAMESPACE] ?? + options.defaultNamespace; if (!namespace) { const namespaces = Object.assign( {}, From 6c9af4c7ff7250c547439c1942853b6c832a054e Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Sun, 3 May 2020 10:52:47 -0700 Subject: [PATCH 4/5] feat(context): add registerInterceptor helper function and app.interceptor --- docs/site/Interceptors.md | 28 +++-- .../boot/src/booters/interceptor.booter.ts | 7 +- .../src/__tests__/unit/interceptor.unit.ts | 102 +++++++++--------- packages/context/src/interceptor.ts | 90 +++++++++++++++- packages/context/src/keys.ts | 5 + .../src/__tests__/unit/application.unit.ts | 95 ++++++++++++++++ packages/core/src/application.ts | 16 +++ 7 files changed, 277 insertions(+), 66 deletions(-) diff --git a/docs/site/Interceptors.md b/docs/site/Interceptors.md index 03ae4dd19c44..4c8417a68864 100644 --- a/docs/site/Interceptors.md +++ b/docs/site/Interceptors.md @@ -53,10 +53,11 @@ method level interceptors. For example, the following code registers a global `caching-interceptor` for all methods. ```ts -app - .bind('caching-interceptor') - .toProvider(CachingInterceptorProvider) - .apply(asGlobalInterceptor('caching')); +app.interceptor(CachingInterceptorProvider, { + global: true, + group: 'caching', + key: 'caching-interceptor', +}); ``` Global interceptors are also executed for route handler functions without a @@ -332,13 +333,13 @@ class MyControllerWithClassLevelInterceptors { ### Global interceptors Global interceptors are discovered from the `InvocationContext`. They are -registered as bindings with `interceptor` tag. For example, +registered as bindings with `globalInterceptor` tag. For example, ```ts import {asGlobalInterceptor} from '@loopback/context'; app - .bind('interceptors.MetricsInterceptor') + .bind('globalInterceptors.MetricsInterceptor') .toProvider(MetricsInterceptorProvider) .apply(asGlobalInterceptor('metrics')); ``` @@ -352,6 +353,12 @@ templates that mark bindings of global interceptors. It takes an optional - - `asGlobalInterceptor()`: mark a binding as a global interceptor in the default group +The registration can be further simplified as: + +```ts +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 @@ -435,10 +442,11 @@ Global interceptors can be sorted as follows: example: ```ts - app - .bind('globalInterceptors.authInterceptor') - .to(authInterceptor) - .apply(asGlobalInterceptor('auth')); + app.interceptor(authInterceptor, { + name: 'authInterceptor', + global: true, + group: 'auth', + }); ``` If the group tag does not exist, the value is default to `''`. diff --git a/packages/boot/src/booters/interceptor.booter.ts b/packages/boot/src/booters/interceptor.booter.ts index 7de52cc852a5..207def828855 100644 --- a/packages/boot/src/booters/interceptor.booter.ts +++ b/packages/boot/src/booters/interceptor.booter.ts @@ -4,10 +4,8 @@ // License text available at https://opensource.org/licenses/MIT import { - BindingScope, config, Constructor, - createBindingFromClass, inject, Interceptor, Provider, @@ -59,10 +57,7 @@ export class InterceptorProviderBooter extends BaseArtifactBooter { this.interceptors = this.classes as InterceptorProviderClass[]; for (const interceptor of this.interceptors) { debug('Bind interceptor: %s', interceptor.name); - const binding = createBindingFromClass(interceptor, { - defaultScope: BindingScope.TRANSIENT, - }); - this.app.add(binding); + const binding = this.app.interceptor(interceptor); debug('Binding created for interceptor: %j', binding); } } diff --git a/packages/context/src/__tests__/unit/interceptor.unit.ts b/packages/context/src/__tests__/unit/interceptor.unit.ts index a44bf62910ff..a12431d5a4bb 100644 --- a/packages/context/src/__tests__/unit/interceptor.unit.ts +++ b/packages/context/src/__tests__/unit/interceptor.unit.ts @@ -19,6 +19,7 @@ import { mergeInterceptors, Provider, } from '../..'; +import {registerInterceptor} from '../../interceptor'; describe('mergeInterceptors', () => { it('removes duplicate entries from the spec', () => { @@ -107,15 +108,17 @@ describe('globalInterceptors', () => { }); it('sorts by group alphabetically without ordered group', () => { - ctx - .bind('globalInterceptors.authInterceptor') - .to(authInterceptor) - .apply(asGlobalInterceptor('auth')); + registerInterceptor(ctx, authInterceptor, { + global: true, + name: 'authInterceptor', + group: 'auth', + }); - ctx - .bind('globalInterceptors.logInterceptor') - .to(logInterceptor) - .apply(asGlobalInterceptor('log')); + registerInterceptor(ctx, logInterceptor, { + global: true, + group: 'log', + name: 'logInterceptor', + }); const invocationCtx = givenInvocationContext(); @@ -137,15 +140,15 @@ describe('globalInterceptors', () => { it, 'sorts by binding order without group tags', async () => { - ctx - .bind('globalInterceptors.authInterceptor') - .to(authInterceptor) - .apply(asGlobalInterceptor()); + registerInterceptor(ctx, authInterceptor, { + global: true, + name: 'authInterceptor', + }); - ctx - .bind('globalInterceptors.logInterceptor') - .to(logInterceptor) - .apply(asGlobalInterceptor()); + registerInterceptor(ctx, logInterceptor, { + global: true, + name: 'logInterceptor', + }); const invocationCtx = givenInvocationContext(); @@ -192,18 +195,19 @@ describe('globalInterceptors', () => { }); it('includes interceptors that match the source type', () => { - ctx - .bind('globalInterceptors.authInterceptor') - .to(authInterceptor) - .apply(asGlobalInterceptor('auth')) - // Allows `route` source type explicitly - .tag({[ContextTags.GLOBAL_INTERCEPTOR_SOURCE]: 'route'}); + registerInterceptor(ctx, authInterceptor, { + global: true, + group: 'auth', + source: 'route', + name: 'authInterceptor', + }); - ctx - .bind('globalInterceptors.logInterceptor') - .to(logInterceptor) - .apply(asGlobalInterceptor('log')); - // No source type is tagged - always apply + registerInterceptor(ctx, logInterceptor, { + global: true, + group: 'log', + name: 'logInterceptor', + // No source type is tagged - always apply + }); const invocationCtx = givenInvocationContext('route'); @@ -215,17 +219,18 @@ describe('globalInterceptors', () => { }); it('excludes interceptors that do not match the source type', () => { - ctx - .bind('globalInterceptors.authInterceptor') - .to(authInterceptor) - .apply(asGlobalInterceptor('auth')) - // Do not apply for `proxy` source type - .tag({[ContextTags.GLOBAL_INTERCEPTOR_SOURCE]: 'route'}); + registerInterceptor(ctx, authInterceptor, { + global: true, + group: 'auth', + source: 'route', + name: 'authInterceptor', + }); - ctx - .bind('globalInterceptors.logInterceptor') - .to(logInterceptor) - .apply(asGlobalInterceptor('log')); + registerInterceptor(ctx, logInterceptor, { + global: true, + group: 'log', + name: 'logInterceptor', + }); const invocationCtx = givenInvocationContext('proxy'); @@ -234,18 +239,19 @@ describe('globalInterceptors', () => { }); it('excludes interceptors that do not match the source type - with array', () => { - ctx - .bind('globalInterceptors.authInterceptor') - .to(authInterceptor) - .apply(asGlobalInterceptor('auth')) - // Do not apply for `proxy` source type - .tag({[ContextTags.GLOBAL_INTERCEPTOR_SOURCE]: 'route'}); + registerInterceptor(ctx, authInterceptor, { + global: true, + group: 'auth', + source: 'route', + name: 'authInterceptor', + }); - ctx - .bind('globalInterceptors.logInterceptor') - .to(logInterceptor) - .apply(asGlobalInterceptor('log')) - .tag({[ContextTags.GLOBAL_INTERCEPTOR_SOURCE]: ['route', 'proxy']}); + registerInterceptor(ctx, logInterceptor, { + global: true, + group: 'log', + source: ['route', 'proxy'], + name: 'logInterceptor', + }); const invocationCtx = givenInvocationContext('proxy'); diff --git a/packages/context/src/interceptor.ts b/packages/context/src/interceptor.ts index b4ec7f664779..af8390d68d08 100644 --- a/packages/context/src/interceptor.ts +++ b/packages/context/src/interceptor.ts @@ -15,7 +15,13 @@ import assert from 'assert'; import debugFactory from 'debug'; import {Binding, BindingTemplate} from './binding'; import {bind} from './binding-decorator'; -import {BindingSpec} from './binding-inspector'; +import { + BindingFromClassOptions, + BindingSpec, + createBindingFromClass, + isProviderClass, +} from './binding-inspector'; +import {BindingAddress, BindingKey} from './binding-key'; import {sortBindingsByPhase} from './binding-sorter'; import {Context} from './context'; import { @@ -33,8 +39,10 @@ import { ContextBindings, ContextTags, GLOBAL_INTERCEPTOR_NAMESPACE, + LOCAL_INTERCEPTOR_NAMESPACE, } from './keys'; -import {tryWithFinally, ValueOrPromise} from './value-promise'; +import {Provider} from './provider'; +import {Constructor, tryWithFinally, ValueOrPromise} from './value-promise'; const debug = debugFactory('loopback:context:interceptor'); /** @@ -345,3 +353,81 @@ export function invokeMethodWithInterceptors( () => 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>, + 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; + if (isProviderClass(interceptor as Constructor>)) { + binding = createBindingFromClass( + interceptor as Constructor>, + { + 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(namespace).key; + } else { + key = `${namespace}.${name}`; + } + } + binding = ctx + .bind(key as BindingAddress) + .to(interceptor as Interceptor); + } + if (global) { + binding.apply(asGlobalInterceptor(group)); + if (source) { + binding.tag({[ContextTags.GLOBAL_INTERCEPTOR_SOURCE]: source}); + } + } + + return binding; +} diff --git a/packages/context/src/keys.ts b/packages/context/src/keys.ts index 8835c087591a..d6c99aeef5e4 100644 --- a/packages/context/src/keys.ts +++ b/packages/context/src/keys.ts @@ -58,6 +58,11 @@ export namespace ContextTags { */ export const GLOBAL_INTERCEPTOR_NAMESPACE = 'globalInterceptors'; +/** + * Default namespace for local interceptors + */ +export const LOCAL_INTERCEPTOR_NAMESPACE = 'interceptors'; + /** * Namespace for context bindings */ diff --git a/packages/core/src/__tests__/unit/application.unit.ts b/packages/core/src/__tests__/unit/application.unit.ts index b0e7f9991f80..212747171c9b 100644 --- a/packages/core/src/__tests__/unit/application.unit.ts +++ b/packages/core/src/__tests__/unit/application.unit.ts @@ -4,12 +4,17 @@ // License text available at https://opensource.org/licenses/MIT import { + asGlobalInterceptor, bind, Binding, BindingScope, BindingTag, Context, + ContextTags, inject, + Interceptor, + InvocationContext, + Next, Provider, } from '@loopback/context'; import {expect} from '@loopback/testlab'; @@ -363,6 +368,96 @@ describe('Application', () => { } }); + describe('interceptor binding', () => { + beforeEach(givenApp); + + it('registers a function as local interceptor', () => { + const binding = app.interceptor(logInterceptor, { + name: 'logInterceptor', + }); + expect(binding).to.containDeep({ + key: 'interceptors.logInterceptor', + }); + expect(binding.tagMap[ContextTags.GLOBAL_INTERCEPTOR]).to.be.undefined(); + }); + + it('registers a provider class as local interceptor', () => { + const binding = app.interceptor(LogInterceptorProviderWithoutDecoration, { + name: 'logInterceptor', + }); + expect(binding).to.containDeep({ + key: 'interceptors.logInterceptor', + }); + expect(binding.tagMap[ContextTags.GLOBAL_INTERCEPTOR]).to.be.undefined(); + }); + + it('registers a function as global interceptor', () => { + const binding = app.interceptor(logInterceptor, { + global: true, + group: 'log', + source: ['route', 'proxy'], + name: 'logInterceptor', + }); + expect(binding).to.containDeep({ + key: 'globalInterceptors.logInterceptor', + tagMap: { + [ContextTags.GLOBAL_INTERCEPTOR_GROUP]: 'log', + [ContextTags.GLOBAL_INTERCEPTOR_SOURCE]: ['route', 'proxy'], + [ContextTags.GLOBAL_INTERCEPTOR]: ContextTags.GLOBAL_INTERCEPTOR, + }, + }); + }); + + it('registers a provider class as global interceptor', () => { + const binding = app.interceptor(LogInterceptorProvider, { + group: 'log', + source: ['route', 'proxy'], + name: 'logInterceptor', + }); + expect(binding).to.containDeep({ + key: 'globalInterceptors.logInterceptor', + tagMap: { + [ContextTags.GLOBAL_INTERCEPTOR_GROUP]: 'log', + [ContextTags.GLOBAL_INTERCEPTOR_SOURCE]: ['route', 'proxy'], + [ContextTags.GLOBAL_INTERCEPTOR]: ContextTags.GLOBAL_INTERCEPTOR, + }, + }); + }); + + it('registers a provider class without decoration as global interceptor', () => { + const binding = app.interceptor(LogInterceptorProviderWithoutDecoration, { + global: true, + group: 'log', + source: ['route', 'proxy'], + name: 'logInterceptor', + }); + expect(binding).to.containDeep({ + key: 'globalInterceptors.logInterceptor', + tagMap: { + [ContextTags.GLOBAL_INTERCEPTOR_GROUP]: 'log', + [ContextTags.GLOBAL_INTERCEPTOR_SOURCE]: ['route', 'proxy'], + [ContextTags.GLOBAL_INTERCEPTOR]: ContextTags.GLOBAL_INTERCEPTOR, + }, + }); + }); + + function logInterceptor(ctx: InvocationContext, next: Next) {} + + @bind(asGlobalInterceptor()) + class LogInterceptorProvider implements Provider { + value() { + return logInterceptor; + } + } + + class LogInterceptorProviderWithoutDecoration + implements Provider { + value() { + return logInterceptor; + } + } + }); + function findKeysByTag(ctx: Context, tag: BindingTag | RegExp) { return ctx.findByTag(tag).map(binding => binding.key); } diff --git a/packages/core/src/application.ts b/packages/core/src/application.ts index a0a49ef23526..b5364863f363 100644 --- a/packages/core/src/application.ts +++ b/packages/core/src/application.ts @@ -10,8 +10,11 @@ import { Constructor, Context, createBindingFromClass, + Interceptor, + InterceptorBindingOptions, JSONObject, Provider, + registerInterceptor, } from '@loopback/context'; import assert from 'assert'; import debugFactory from 'debug'; @@ -457,6 +460,19 @@ export class Application extends Context implements LifeCycleObserver { return binding; } + /** + * Register an interceptor + * @param interceptor - An interceptor function or provider class + * @param nameOrOptions - Binding name or options + */ + public interceptor( + interceptor: Interceptor | Constructor>, + nameOrOptions?: string | InterceptorBindingOptions, + ) { + const options = toOptions(nameOrOptions); + return registerInterceptor(this, interceptor, options); + } + /** * Set up signals that are captured to shutdown the application */ From 6d96fd498eba93c176b7c86e2971702c14276b98 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Sun, 3 May 2020 13:17:56 -0700 Subject: [PATCH 5/5] feat(context): consolidate uuid generation and testing --- .../context/src/__tests__/unit/binding-key.unit.ts | 7 +++---- packages/context/src/__tests__/unit/context.unit.ts | 7 +++---- .../context/src/__tests__/unit/interceptor.unit.ts | 13 +++++++++++++ packages/context/src/binding-key.ts | 7 ++++--- packages/context/src/context.ts | 4 ++-- packages/context/src/value-promise.ts | 13 +++++++++++++ 6 files changed, 38 insertions(+), 13 deletions(-) diff --git a/packages/context/src/__tests__/unit/binding-key.unit.ts b/packages/context/src/__tests__/unit/binding-key.unit.ts index 9f5cf233de05..271b9d9811c3 100644 --- a/packages/context/src/__tests__/unit/binding-key.unit.ts +++ b/packages/context/src/__tests__/unit/binding-key.unit.ts @@ -5,6 +5,7 @@ import {expect} from '@loopback/testlab'; import {BindingKey} from '../..'; +import {UUID_PATTERN} from '../../value-promise'; describe('BindingKey', () => { describe('create', () => { @@ -62,9 +63,7 @@ describe('BindingKey', () => { describe('generate', () => { it('generates binding key without namespace', () => { const key1 = BindingKey.generate().key; - expect(key1).to.match( - /^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, - ); + expect(key1).to.match(UUID_PATTERN); const key2 = BindingKey.generate().key; expect(key1).to.not.eql(key2); }); @@ -72,7 +71,7 @@ describe('BindingKey', () => { it('generates binding key with namespace', () => { const key1 = BindingKey.generate('services').key; expect(key1).to.match( - /^services\.[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, + new RegExp(`^services\\.${UUID_PATTERN.source}$`, 'i'), ); const key2 = BindingKey.generate('services').key; expect(key1).to.not.eql(key2); diff --git a/packages/context/src/__tests__/unit/context.unit.ts b/packages/context/src/__tests__/unit/context.unit.ts index 09397a81dd33..90f9b3c9d155 100644 --- a/packages/context/src/__tests__/unit/context.unit.ts +++ b/packages/context/src/__tests__/unit/context.unit.ts @@ -17,6 +17,7 @@ import { isPromiseLike, Provider, } from '../..'; +import {UUID_PATTERN} from '../../value-promise'; /** * Create a subclass of context so that we can access parents and registry @@ -45,15 +46,13 @@ class TestContext extends Context { describe('Context constructor', () => { it('generates uuid name if not provided', () => { const ctx = new Context(); - expect(ctx.name).to.match( - /^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, - ); + expect(ctx.name).to.match(UUID_PATTERN); }); it('adds subclass name as the prefix', () => { const ctx = new TestContext(); expect(ctx.name).to.match( - /^TestContext-[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, + new RegExp(`^TestContext-${UUID_PATTERN.source}$`, 'i'), ); }); diff --git a/packages/context/src/__tests__/unit/interceptor.unit.ts b/packages/context/src/__tests__/unit/interceptor.unit.ts index a12431d5a4bb..4206ac799882 100644 --- a/packages/context/src/__tests__/unit/interceptor.unit.ts +++ b/packages/context/src/__tests__/unit/interceptor.unit.ts @@ -20,6 +20,7 @@ import { Provider, } from '../..'; import {registerInterceptor} from '../../interceptor'; +import {UUID_PATTERN} from '../../value-promise'; describe('mergeInterceptors', () => { it('removes duplicate entries from the spec', () => { @@ -259,6 +260,18 @@ describe('globalInterceptors', () => { expect(keys).to.eql(['globalInterceptors.logInterceptor']); }); + it('infers binding key from the interceptor function', () => { + const binding = registerInterceptor(ctx, logInterceptor); + expect(binding.key).to.eql('interceptors.logInterceptor'); + }); + + it('generates binding key for the interceptor function', () => { + const binding = registerInterceptor(ctx, () => {}); + expect(binding.key).to.match( + new RegExp(`interceptors.${UUID_PATTERN.source}`, 'i'), + ); + }); + class MyController { greet(name: string) { return `Hello, ${name}`; diff --git a/packages/context/src/binding-key.ts b/packages/context/src/binding-key.ts index d7f66e2638de..5efc76efb048 100644 --- a/packages/context/src/binding-key.ts +++ b/packages/context/src/binding-key.ts @@ -3,7 +3,8 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {v4} from 'uuid'; +import {uuid} from './value-promise'; + export type BindingAddress = string | BindingKey; export class BindingKey { @@ -127,7 +128,7 @@ export class BindingKey { */ static generate(namespace = ''): BindingKey { const prefix = namespace ? `${namespace}.` : ''; - const uuid = v4(); - return BindingKey.create(`${prefix}${uuid}`); + const name = uuid(); + return BindingKey.create(`${prefix}${name}`); } } diff --git a/packages/context/src/context.ts b/packages/context/src/context.ts index 6aeb7d1034ed..7c5652f29e90 100644 --- a/packages/context/src/context.ts +++ b/packages/context/src/context.ts @@ -5,7 +5,6 @@ import debugFactory, {Debugger} from 'debug'; import {EventEmitter} from 'events'; -import {v4 as uuidv4} from 'uuid'; import {Binding, BindingInspectOptions, BindingTag} from './binding'; import { ConfigurationResolver, @@ -37,6 +36,7 @@ import { Constructor, getDeepProperty, isPromiseLike, + uuid, ValueOrPromise, } from './value-promise'; @@ -151,7 +151,7 @@ export class Context extends EventEmitter { } private generateName() { - const id = uuidv4(); + const id = uuid(); if (this.constructor === Context) return id; return `${this.constructor.name}-${id}`; } diff --git a/packages/context/src/value-promise.ts b/packages/context/src/value-promise.ts index ab021826393b..1f48b1f52485 100644 --- a/packages/context/src/value-promise.ts +++ b/packages/context/src/value-promise.ts @@ -8,6 +8,7 @@ * utility methods to handle values and/or promises. */ +import {v4 as uuidv4} from 'uuid'; /** * A class constructor accepting arbitrary arguments. */ @@ -270,3 +271,15 @@ export function transformValueOrPromise( return transformer(valueOrPromise); } } + +/** + * A utility to generate uuid v4 + */ +export function uuid() { + return uuidv4(); +} + +/** + * A regular expression for testing uuid v4 PATTERN + */ +export const UUID_PATTERN = /[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}/i;