Skip to content

Commit

Permalink
feat(core): add more flavors of @extensions decorator
Browse files Browse the repository at this point in the history
This allows a getter, a view, or a list of extensions to be injected.
  • Loading branch information
raymondfeng committed Mar 23, 2020
1 parent ede4bbd commit 192563a
Show file tree
Hide file tree
Showing 3 changed files with 238 additions and 8 deletions.
51 changes: 50 additions & 1 deletion docs/site/Extension-point-and-extensions.md
Expand Up @@ -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
Expand All @@ -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<Greeter[]>;
}
```

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<Greeter>,
) {
// ...
}
}
```

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
Expand Down
Expand Up @@ -5,9 +5,12 @@

import {
bind,
Binding,
BindingScope,
BINDING_METADATA_KEY,
Context,
ContextEvent,
ContextView,
createBindingFromClass,
Getter,
MetadataInspector,
Expand Down Expand Up @@ -70,6 +73,69 @@ describe('extension point', () => {
assertGreeterExtensions(greeters);
});

it('injects a view of extensions', async () => {
@extensionPoint('greeters')
class GreetingService {
readonly bindings: Set<Readonly<Binding<unknown>>>;
constructor(
@extensions.view()
public readonly greetersView: ContextView<Greeter>,
) {
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<GreetingService>('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<GreetingService>('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()
Expand Down Expand Up @@ -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];
}
});

Expand Down
124 changes: 119 additions & 5 deletions packages/core/src/extension-point.ts
Expand Up @@ -4,6 +4,7 @@
// License text available at https://opensource.org/licenses/MIT

import {
assertTargetType,
bind,
Binding,
BindingFilter,
Expand All @@ -13,11 +14,14 @@ import {
Constructor,
Context,
ContextTags,
ContextView,
createBindingFromClass,
createViewGetter,
filterByTag,
includesTagValue,
inject,
Injection,
ResolutionSession,
} from '@loopback/context';
import {CoreTags} from './keys';

Expand Down Expand Up @@ -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,
Expand All @@ -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<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 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<Injection<unknown>>,
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
Expand Down

0 comments on commit 192563a

Please sign in to comment.