From 192563a3f4cdb136b86d898760a33051436a56de Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Wed, 18 Mar 2020 12:02:14 -0700 Subject: [PATCH] feat(core): add more flavors of @extensions decorator This allows a getter, a view, or a list of extensions to be injected. --- docs/site/Extension-point-and-extensions.md | 51 ++++++- .../acceptance/extension-point.acceptance.ts | 71 +++++++++- packages/core/src/extension-point.ts | 124 +++++++++++++++++- 3 files changed, 238 insertions(+), 8 deletions(-) diff --git a/docs/site/Extension-point-and-extensions.md b/docs/site/Extension-point-and-extensions.md index 1c19609275fd..ba2e4ec6bfdf 100644 --- a/docs/site/Extension-point-and-extensions.md +++ b/docs/site/Extension-point-and-extensions.md @@ -50,6 +50,11 @@ decorators and functions are provided to ensure consistency and convention. custom name - `@extensions`: injects a getter function to access extensions to the target extension point +- `@extensions.view`: injects a context view to access extensions to the target + extension point. The view can be listened for context events. +- `@extensions.list`: injects an array of extensions to the target extension + point. The list is fixed when the injection is done and it does not add or + remove extensions afterward. - `extensionFilter`: creates a binding filter function to find extensions for the named extension point - `extensionFor`: creates a binding template function to set the binding to be @@ -58,7 +63,51 @@ decorators and functions are provided to ensure consistency and convention. - `addExtension`: registers an extension class to the context for the named extension point -The usage of these helper decorators and functions are illustrated in the +## Examples + +1. Inject a getter function for extensions + + ```ts + import {Getter} from '@loopback/context'; + import {extensionPoint, extensions} from '@loopback/core'; + + @extensionPoint('greeters') + class GreetingService { + @extensions() + public getGreeters: Getter; + } + ``` + +2. Inject a context view for extensions + + ```ts + import {ContextView} from '@loopback/context'; + import {extensionPoint, extensions} from '@loopback/core'; + + @extensionPoint('greeters') + class GreetingService { + constructor( + @extensions.view() + public readonly greetersView: ContextView, + ) { + // ... + } + } + ``` + +3. Inject an array of resolved extensions + + ```ts + import {extensionPoint, extensions} from '@loopback/core'; + + @extensionPoint('greeters') + class GreetingService { + @extensions.list() + public greeters: Greeter[]; + } + ``` + +More usage of these helper decorators and functions are illustrated in the `greeter-extension` tutorial. ## Tutorial diff --git a/packages/core/src/__tests__/acceptance/extension-point.acceptance.ts b/packages/core/src/__tests__/acceptance/extension-point.acceptance.ts index 343f0a7b7d13..eb318a749846 100644 --- a/packages/core/src/__tests__/acceptance/extension-point.acceptance.ts +++ b/packages/core/src/__tests__/acceptance/extension-point.acceptance.ts @@ -5,9 +5,12 @@ import { bind, + Binding, BindingScope, BINDING_METADATA_KEY, Context, + ContextEvent, + ContextView, createBindingFromClass, Getter, MetadataInspector, @@ -70,6 +73,69 @@ describe('extension point', () => { assertGreeterExtensions(greeters); }); + it('injects a view of extensions', async () => { + @extensionPoint('greeters') + class GreetingService { + readonly bindings: Set>>; + constructor( + @extensions.view() + public readonly greetersView: ContextView, + ) { + this.bindings = new Set(this.greetersView.bindings); + // Track bind events + this.greetersView.on('bind', (event: ContextEvent) => { + this.bindings.add(event.binding); + }); + // Track unbind events + this.greetersView.on('unbind', (event: ContextEvent) => { + this.bindings.delete(event.binding); + }); + } + } + + // `@extensionPoint` is a sugar decorator for `@bind` + const binding = createBindingFromClass(GreetingService, { + key: 'greeter-service', + }); + ctx.add(binding); + const registeredBindings = registerGreeters('greeters'); + const greeterService = await ctx.get('greeter-service'); + expect(Array.from(greeterService.bindings)).to.eql(registeredBindings); + let greeters = await greeterService.greetersView.values(); + assertGreeterExtensions(greeters); + expect(greeters.length).to.equal(2); + ctx.unbind(registeredBindings[0].key); + greeters = await greeterService.greetersView.values(); + expect(greeters.length).to.equal(1); + expect(Array.from(greeterService.bindings)).to.eql([ + registeredBindings[1], + ]); + }); + + it('injects a list of extensions', async () => { + @extensionPoint('greeters') + class GreetingService { + @extensions.list() + public greeters: Greeter[]; + } + + // `@extensionPoint` is a sugar decorator for `@bind` + const binding = createBindingFromClass(GreetingService, { + key: 'greeter-service', + }); + ctx.add(binding); + const registeredBindings = registerGreeters('greeters'); + const greeterService = await ctx.get('greeter-service'); + expect(greeterService.greeters.length).to.eql(registeredBindings.length); + assertGreeterExtensions(greeterService.greeters); + + const copy = Array.from(greeterService.greeters); + // Now unbind the 1st greeter + ctx.unbind(registeredBindings[0].key); + // The injected greeters are not impacted + expect(greeterService.greeters).to.eql(copy); + }); + it('injects extensions based on `name` tag of the extension point binding', async () => { class GreetingService { @extensions() @@ -230,12 +296,13 @@ describe('extension point', () => { } function registerGreeters(extensionPointName: string) { - addExtension(ctx, extensionPointName, EnglishGreeter, { + const g1 = addExtension(ctx, extensionPointName, EnglishGreeter, { namespace: 'greeters', }); - addExtension(ctx, extensionPointName, ChineseGreeter, { + const g2 = addExtension(ctx, extensionPointName, ChineseGreeter, { namespace: 'greeters', }); + return [g1, g2]; } }); diff --git a/packages/core/src/extension-point.ts b/packages/core/src/extension-point.ts index 38117f45f04a..186c5c310b45 100644 --- a/packages/core/src/extension-point.ts +++ b/packages/core/src/extension-point.ts @@ -4,6 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import { + assertTargetType, bind, Binding, BindingFilter, @@ -13,11 +14,14 @@ import { Constructor, Context, ContextTags, + ContextView, createBindingFromClass, createViewGetter, filterByTag, includesTagValue, inject, + Injection, + ResolutionSession, } from '@loopback/context'; import {CoreTags} from './keys'; @@ -68,11 +72,12 @@ export function extensionPoint(name: string, ...specs: BindingSpec[]) { */ export function extensions(extensionPointName?: string) { return inject('', {decorator: '@extensions'}, (ctx, injection, session) => { - extensionPointName = - extensionPointName ?? - inferExtensionPointName(injection.target, session.currentBinding); - - const bindingFilter = extensionFilter(extensionPointName); + assertTargetType(injection, Function, 'Getter function'); + const bindingFilter = filterByExtensionPoint( + injection, + session, + extensionPointName, + ); return createViewGetter( ctx, bindingFilter, @@ -82,6 +87,115 @@ export function extensions(extensionPointName?: string) { }); } +export namespace extensions { + /** + * Inject a `ContextView` for extensions of the extension point. The view can + * then be listened on events such as `bind`, `unbind`, or `refresh` to react + * on changes of extensions. + * + * @example + * ```ts + * import {extensionPoint, extensions} from '@loopback/core'; + * + * @extensionPoint(GREETER_EXTENSION_POINT_NAME) + * export class GreetingService { + * constructor( + * @extensions.view() // Inject a context view for extensions of the extension point + * private greetersView: ContextView, + * // ... + * ) { + * // ... + * } + * ``` + * @param extensionPointName - Name of the extension point. If not supplied, we + * use the `name` tag from the extension point binding or the class name of the + * extension point class. If a class needs to inject extensions from multiple + * extension points, use different `extensionPointName` for different types of + * extensions. + */ + export function view(extensionPointName?: string) { + return inject( + '', + {decorator: '@extensions.view'}, + (ctx, injection, session) => { + assertTargetType(injection, ContextView); + const bindingFilter = filterByExtensionPoint( + injection, + session, + extensionPointName, + ); + return ctx.createView( + bindingFilter, + injection.metadata.bindingComparator, + ); + }, + ); + } + + /** + * Inject an array of resolved extension instances for the extension point. + * The list is a snapshot of registered extensions when the injection is + * fulfilled. Extensions added or removed afterward won't impact the list. + * + * @example + * ```ts + * import {extensionPoint, extensions} from '@loopback/core'; + * + * @extensionPoint(GREETER_EXTENSION_POINT_NAME) + * export class GreetingService { + * constructor( + * @extensions.list() // Inject an array of extensions for the extension point + * private greeters: Greeter[], + * // ... + * ) { + * // ... + * } + * ``` + * @param extensionPointName - Name of the extension point. If not supplied, we + * use the `name` tag from the extension point binding or the class name of the + * extension point class. If a class needs to inject extensions from multiple + * extension points, use different `extensionPointName` for different types of + * extensions. + */ + export function list(extensionPointName?: string) { + return inject( + '', + {decorator: '@extensions.instances'}, + (ctx, injection, session) => { + assertTargetType(injection, Array); + const bindingFilter = filterByExtensionPoint( + injection, + session, + extensionPointName, + ); + const viewForExtensions = new ContextView( + ctx, + bindingFilter, + injection.metadata.bindingComparator, + ); + return viewForExtensions.resolve(session); + }, + ); + } +} + +/** + * Create a binding filter for `@extensions.*` + * @param injection - Injection object + * @param session - Resolution session + * @param extensionPointName - Extension point name + */ +function filterByExtensionPoint( + injection: Readonly>, + session: ResolutionSession, + extensionPointName?: string, +) { + extensionPointName = + extensionPointName ?? + inferExtensionPointName(injection.target, session.currentBinding); + return extensionFilter(extensionPointName); +} + /** * Infer the extension point name from binding tags/class name * @param injectionTarget - Target class or prototype