Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/site/Binding.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion docs/site/Extension-point-and-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 58 additions & 0 deletions packages/context/src/__tests__/unit/binding-filter.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
BindingKey,
filterByKey,
filterByTag,
includesTagValue,
isBindingAddress,
isBindingTagFilter,
} from '../..';
Expand Down Expand Up @@ -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,
Expand All @@ -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'});
Expand Down
52 changes: 47 additions & 5 deletions packages/context/src/binding-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>): boolean;
}

/**
* A symbol that can be used to match binding tags by name regardless of the
* value.
Expand All @@ -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
Expand Down Expand Up @@ -156,10 +186,8 @@ export function filterByTag(tagPattern: BindingTag | RegExp): BindingTagFilter {
// Match tag name/value pairs
const tagMap = tagPattern as MapObject<unknown>;
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;
Expand All @@ -171,6 +199,20 @@ export function filterByTag(tagPattern: BindingTag | RegExp): BindingTagFilter {
return tagFilter;
}

function matchTagValue(
tagValueOrMatcher: unknown,
tagName: string,
tagMap: MapObject<unknown>,
) {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// License text available at https://opensource.org/licenses/MIT

import {
bind,
BindingScope,
BINDING_METADATA_KEY,
Context,
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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<MyExtension[]>;
}

@extensionPoint('extensionPoint-2')
class MyExtensionPoint2 {
@extensions() getMyExtensions: Getter<MyExtension[]>;
}

@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();
}
Expand Down
35 changes: 30 additions & 5 deletions packages/core/src/extension-point.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import {
bind,
Binding,
BindingFilter,
BindingFromClassOptions,
BindingSpec,
BindingTemplate,
Expand All @@ -15,6 +16,7 @@ import {
createBindingFromClass,
createViewGetter,
filterByTag,
includesTagValue,
inject,
} from '@loopback/context';
import {CoreTags} from './keys';
Expand Down Expand Up @@ -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});
};
}

/**
Expand Down