diff --git a/docs/site/Binding.md b/docs/site/Binding.md index 62e31cd471bb..252b3587fea3 100644 --- a/docs/site/Binding.md +++ b/docs/site/Binding.md @@ -337,6 +337,38 @@ binding.tag('controller', {name: 'MyController'}); The binding tags can be accessed via `binding.tagMap` or `binding.tagNames`. +Binding tags play an import role in discovering artifacts with matching tags. +The `filterByTag` helper function and `context.findByTag` method can be used to +match/find bindings by tag. The search criteria can be one of the followings: + +1. A tag name, such as `controller` +2. A tag name wildcard or regular expression, such as `controller.*` or + `/controller/` +3. An object contains tag name/value pairs, such as + `{name: 'my-controller', type: 'controller'}`. In addition to exact match, + the value for a tag name can be a function that determines if a given tag + value matches. For example, + + ```ts + import { + ANY_TAG_VALUE, // Match any value if it exists + filterByTag, + includesTagValue, // Match tag value as an array that includes the item + TagValueMatcher, + } from '@loopback/context'; + // Match a binding with a named service + ctx.find(filterByTag({name: ANY_TAG_VALUE, service: 'service'})); + + // Match a binding as an extension for `my-extension-point` + ctx.find( + filterByTag({extensionFor: includesTagValue('my-extension-point')}), + ); + + // Match a binding with weight > 100 + const weightMatcher: TagValueMatcher = tagValue => tagValue > 100; + ctx.find(filterByTag({weight: weightMatcher})); + ``` + ### Chain multiple steps The `Binding` fluent APIs allow us to chain multiple steps as follows: diff --git a/docs/site/Extension-point-and-extensions.md b/docs/site/Extension-point-and-extensions.md index e079be85f275..1c19609275fd 100644 --- a/docs/site/Extension-point-and-extensions.md +++ b/docs/site/Extension-point-and-extensions.md @@ -53,7 +53,8 @@ decorators and functions are provided to ensure consistency and convention. - `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 - an extension for the named extension point + an extension for the named extension point(s). It can accept one or more + extension point names to contribute to given extension points - `addExtension`: registers an extension class to the context for the named extension point diff --git a/packages/context/src/__tests__/unit/binding-filter.unit.ts b/packages/context/src/__tests__/unit/binding-filter.unit.ts index 49a926379700..d250f61bc068 100644 --- a/packages/context/src/__tests__/unit/binding-filter.unit.ts +++ b/packages/context/src/__tests__/unit/binding-filter.unit.ts @@ -11,6 +11,7 @@ import { BindingKey, filterByKey, filterByTag, + includesTagValue, isBindingAddress, isBindingTagFilter, } from '../..'; @@ -63,6 +64,30 @@ describe('BindingFilter', () => { expect(filter(binding)).to.be.true(); }); + it('accepts bindings MATCHING the provided tag map with array values', () => { + const filter = filterByTag({ + extensionFor: includesTagValue('greeting-service'), + name: 'my-name', + }); + binding.tag({ + extensionFor: ['greeting-service', 'anther-extension-point'], + }); + binding.tag({name: 'my-name'}); + expect(filter(binding)).to.be.true(); + }); + + it('rejects bindings NOT MATCHING the provided tag map with array values', () => { + const filter = filterByTag({ + extensionFor: includesTagValue('extension-point-3'), + name: 'my-name', + }); + binding.tag({ + extensionFor: ['extension-point-1', 'extension-point-2'], + }); + binding.tag({name: 'my-name'}); + expect(filter(binding)).to.be.false(); + }); + it('matches ANY_TAG_VALUE if the tag name exists', () => { const filter = filterByTag({ controller: ANY_TAG_VALUE, @@ -81,6 +106,39 @@ describe('BindingFilter', () => { expect(filter(binding)).to.be.false(); }); + it('allows include tag value matcher - true for exact match', () => { + const filter = filterByTag({ + controller: includesTagValue('MyController'), + name: 'my-name', + }); + binding.tag({name: 'my-name', controller: 'MyController'}); + expect(filter(binding)).to.be.true(); + }); + + it('allows include tag value matcher - true for included match', () => { + const filter = filterByTag({ + controller: includesTagValue('MyController'), + name: 'my-name', + }); + binding.tag({ + name: 'my-name', + controller: ['MyController', 'YourController'], + }); + expect(filter(binding)).to.be.true(); + }); + + it('allows include tag value matcher - false for no match', () => { + const filter = filterByTag({ + controller: includesTagValue('XYZController'), + name: 'my-name', + }); + binding.tag({ + name: 'my-name', + controller: ['MyController', 'YourController'], + }); + expect(filter(binding)).to.be.false(); + }); + it('rejects bindings NOT MATCHING the provided tag map', () => { const filter = filterByTag({controller: 'your-controller'}); binding.tag({controller: 'my-controller'}); diff --git a/packages/context/src/binding-filter.ts b/packages/context/src/binding-filter.ts index 8c8cdad07bf6..290d219fe6c6 100644 --- a/packages/context/src/binding-filter.ts +++ b/packages/context/src/binding-filter.ts @@ -111,6 +111,19 @@ export function isBindingTagFilter( ); } +/** + * A function to check if a given tag value is matched for `filterByTag` + */ +export interface TagValueMatcher { + /** + * Check if the given tag value matches the search criteria + * @param tagValue - Tag value from the binding + * @param tagName - Tag name + * @param tagMap - Tag map from the binding + */ + (tagValue: unknown, tagName: string, tagMap: MapObject): boolean; +} + /** * A symbol that can be used to match binding tags by name regardless of the * value. @@ -125,7 +138,24 @@ export function isBindingTagFilter( * ctx.findByTag({controller: ANY_TAG_VALUE}) * ``` */ -export const ANY_TAG_VALUE = Symbol.for('loopback.AnyTagValue'); +export const ANY_TAG_VALUE: TagValueMatcher = (tagValue, tagName, tagMap) => + tagName in tagMap; + +/** + * Create a tag value matcher function that returns `true` if the target tag + * value equals to the item value or is an array that includes the item value. + * @param itemValue - Tag item value + */ +export function includesTagValue(itemValue: unknown): TagValueMatcher { + return tagValue => { + return ( + // The tag value equals the item value + tagValue === itemValue || + // The tag value contains the item value + (Array.isArray(tagValue) && tagValue.includes(itemValue)) + ); + }; +} /** * Create a binding filter for the tag pattern @@ -156,10 +186,8 @@ export function filterByTag(tagPattern: BindingTag | RegExp): BindingTagFilter { // Match tag name/value pairs const tagMap = tagPattern as MapObject; filter = b => { - for (const t in tagPattern) { - if (tagMap[t] === ANY_TAG_VALUE) return t in b.tagMap; - // One tag name/value does not match - if (b.tagMap[t] !== tagMap[t]) return false; + for (const t in tagMap) { + if (!matchTagValue(tagMap[t], t, b.tagMap)) return false; } // All tag name/value pairs match return true; @@ -171,6 +199,20 @@ export function filterByTag(tagPattern: BindingTag | RegExp): BindingTagFilter { return tagFilter; } +function matchTagValue( + tagValueOrMatcher: unknown, + tagName: string, + tagMap: MapObject, +) { + const tagValue = tagMap[tagName]; + if (tagValue === tagValueOrMatcher) return true; + + if (typeof tagValueOrMatcher === 'function') { + return (tagValueOrMatcher as TagValueMatcher)(tagValue, tagName, tagMap); + } + return false; +} + /** * Create a binding filter from key pattern * @param keyPattern - Binding key/wildcard, regexp, or a filter function diff --git a/packages/core/src/__tests__/acceptance/extension-point.acceptance.ts b/packages/core/src/__tests__/acceptance/extension-point.acceptance.ts index fde73baab839..343f0a7b7d13 100644 --- a/packages/core/src/__tests__/acceptance/extension-point.acceptance.ts +++ b/packages/core/src/__tests__/acceptance/extension-point.acceptance.ts @@ -4,6 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import { + bind, BindingScope, BINDING_METADATA_KEY, Context, @@ -12,8 +13,14 @@ import { MetadataInspector, } from '@loopback/context'; import {expect} from '@loopback/testlab'; -import {addExtension, extensionPoint, extensions} from '../..'; -import {CoreTags} from '../../keys'; +import { + addExtension, + CoreTags, + extensionFilter, + extensionFor, + extensionPoint, + extensions, +} from '../..'; describe('extension point', () => { describe('@extensionPoint', () => { @@ -147,6 +154,77 @@ describe('extension point', () => { expect(loadedLoggers[0]).to.be.instanceOf(ConsoleLogger); }); + it('allows an extension to contribute to multiple extension points', () => { + @bind(extensionFor('extensionPoint-1'), extensionFor('extensionPoint-2')) + class MyExtension {} + const binding = createBindingFromClass(MyExtension); + expect(binding.tagMap[CoreTags.EXTENSION_FOR]).to.eql([ + 'extensionPoint-1', + 'extensionPoint-2', + ]); + }); + + it('allows an extension of multiple extension points with extensionFor', () => { + class MyExtension {} + const binding = ctx.bind('my-extension').toClass(MyExtension); + extensionFor('extensionPoint-1')(binding); + expect(binding.tagMap[CoreTags.EXTENSION_FOR]).to.eql('extensionPoint-1'); + + // Now we have two extension points + extensionFor('extensionPoint-2')(binding); + expect(binding.tagMap[CoreTags.EXTENSION_FOR]).to.eql([ + 'extensionPoint-1', + 'extensionPoint-2', + ]); + + // No duplication + extensionFor('extensionPoint-1')(binding); + expect(binding.tagMap[CoreTags.EXTENSION_FOR]).to.eql([ + 'extensionPoint-1', + 'extensionPoint-2', + ]); + }); + + it('allows multiple extension points for extensionFilter', () => { + class MyExtension {} + const binding = ctx.bind('my-extension').toClass(MyExtension); + extensionFor('extensionPoint-1', 'extensionPoint-2')(binding); + expect(extensionFilter('extensionPoint-1')(binding)).to.be.true(); + expect(extensionFilter('extensionPoint-2')(binding)).to.be.true(); + expect(extensionFilter('extensionPoint-3')(binding)).to.be.false(); + }); + + it('allows multiple extension points for @extensions', async () => { + @extensionPoint('extensionPoint-1') + class MyExtensionPoint1 { + @extensions() getMyExtensions: Getter; + } + + @extensionPoint('extensionPoint-2') + class MyExtensionPoint2 { + @extensions() getMyExtensions: Getter; + } + + @bind(extensionFor('extensionPoint-1', 'extensionPoint-2')) + class MyExtension {} + + ctx.add( + createBindingFromClass(MyExtensionPoint1, {key: 'extensionPoint1'}), + ); + ctx.add( + createBindingFromClass(MyExtensionPoint2, {key: 'extensionPoint2'}), + ); + ctx.add(createBindingFromClass(MyExtension)); + const ep1: MyExtensionPoint1 = await ctx.get('extensionPoint1'); + const ep2: MyExtensionPoint2 = await ctx.get('extensionPoint2'); + const extensionsFor1 = await ep1.getMyExtensions(); + const extensionsFor2 = await ep2.getMyExtensions(); + expect(extensionsFor1.length).to.eql(1); + expect(extensionsFor1[0]).to.be.instanceOf(MyExtension); + expect(extensionsFor2.length).to.eql(1); + expect(extensionsFor2[0]).to.be.instanceOf(MyExtension); + }); + function givenContext() { ctx = new Context(); } diff --git a/packages/core/src/extension-point.ts b/packages/core/src/extension-point.ts index 413196c5e29d..38117f45f04a 100644 --- a/packages/core/src/extension-point.ts +++ b/packages/core/src/extension-point.ts @@ -6,6 +6,7 @@ import { bind, Binding, + BindingFilter, BindingFromClassOptions, BindingSpec, BindingTemplate, @@ -15,6 +16,7 @@ import { createBindingFromClass, createViewGetter, filterByTag, + includesTagValue, inject, } from '@loopback/context'; import {CoreTags} from './keys'; @@ -113,19 +115,42 @@ function inferExtensionPointName( * extension point * @param extensionPointName - Name of the extension point */ -export function extensionFilter(extensionPointName: string) { +export function extensionFilter(extensionPointName: string): BindingFilter { return filterByTag({ - [CoreTags.EXTENSION_FOR]: extensionPointName, + [CoreTags.EXTENSION_FOR]: includesTagValue(extensionPointName), }); } /** * A factory function to create binding template for extensions of the given * extension point - * @param extensionPointName - Name of the extension point + * @param extensionPointNames - Names of the extension point */ -export function extensionFor(extensionPointName: string): BindingTemplate { - return binding => binding.tag({[CoreTags.EXTENSION_FOR]: extensionPointName}); +export function extensionFor( + ...extensionPointNames: string[] +): BindingTemplate { + return binding => { + if (extensionPointNames.length === 0) return; + let extensionPoints = binding.tagMap[CoreTags.EXTENSION_FOR]; + // Normalize extensionPoints to string[] + if (extensionPoints == null) { + extensionPoints = []; + } else if (typeof extensionPoints === 'string') { + extensionPoints = [extensionPoints]; + } + + // Add extension points + for (const extensionPointName of extensionPointNames) { + if (!extensionPoints.includes(extensionPointName)) { + extensionPoints.push(extensionPointName); + } + } + if (extensionPoints.length === 1) { + // Keep the value as string for backward compatibility + extensionPoints = extensionPoints[0]; + } + binding.tag({[CoreTags.EXTENSION_FOR]: extensionPoints}); + }; } /**