Skip to content

Commit 04209f7

Browse files
committed
feat(context): introduce context view to watch bindings by filter
1 parent 23f963c commit 04209f7

File tree

5 files changed

+444
-0
lines changed

5 files changed

+444
-0
lines changed

packages/context/src/context-view.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright IBM Corp. 2018. All Rights Reserved.
2+
// Node module: @loopback/context
3+
// This file is licensed under the MIT License.
4+
// License text available at https://opensource.org/licenses/MIT
5+
6+
import * as debugFactory from 'debug';
7+
import {promisify} from 'util';
8+
import {Binding} from './binding';
9+
import {BindingFilter} from './binding-filter';
10+
import {Context} from './context';
11+
import {
12+
ContextObserver,
13+
ContextEventType,
14+
Subscription,
15+
} from './context-observer';
16+
import {Getter} from './inject';
17+
import {ResolutionSession} from './resolution-session';
18+
import {resolveList, ValueOrPromise} from './value-promise';
19+
const debug = debugFactory('loopback:context:view');
20+
const nextTick = promisify(process.nextTick);
21+
22+
/**
23+
* `ContextView` provides a view for a given context chain to maintain a live
24+
* list of matching bindings and their resolved values within the context
25+
* hierarchy.
26+
*
27+
* This class is the key utility to implement dynamic extensions for extension
28+
* points. For example, the RestServer can react to `controller` bindings even
29+
* they are added/removed/updated after the application starts.
30+
*
31+
*/
32+
export class ContextView<T = unknown> implements ContextObserver {
33+
protected _cachedBindings: Readonly<Binding<T>>[] | undefined;
34+
protected _cachedValues: T[] | undefined;
35+
private _subscription: Subscription | undefined;
36+
37+
constructor(
38+
protected readonly context: Context,
39+
public readonly filter: BindingFilter,
40+
) {}
41+
42+
/**
43+
* Start listening events from the context
44+
*/
45+
open() {
46+
debug('Start listening on changes of context %s', this.context.name);
47+
return (this._subscription = this.context.subscribe(this));
48+
}
49+
50+
/**
51+
* Stop listening events from the context
52+
*/
53+
close() {
54+
debug('Stop listening on changes of context %s', this.context.name);
55+
if (!this._subscription || this._subscription.closed) return;
56+
this._subscription.unsubscribe();
57+
this._subscription = undefined;
58+
}
59+
60+
/**
61+
* Get the list of matched bindings. If they are not cached, it tries to find
62+
* them from the context.
63+
*/
64+
get bindings(): Readonly<Binding<T>>[] {
65+
debug('Reading bindings');
66+
if (this._cachedBindings == null) {
67+
this._cachedBindings = this.findBindings();
68+
}
69+
return this._cachedBindings;
70+
}
71+
72+
/**
73+
* Find matching bindings and refresh the cache
74+
*/
75+
protected findBindings() {
76+
debug('Finding matching bindings');
77+
this._cachedBindings = this.context.find(this.filter);
78+
return this._cachedBindings;
79+
}
80+
81+
/**
82+
* Listen on `bind` or `unbind` and invalidate the cache
83+
*/
84+
observe(event: ContextEventType, binding: Readonly<Binding<unknown>>) {
85+
this.refresh();
86+
}
87+
88+
/**
89+
* Refresh the view by invalidating its cache
90+
*/
91+
refresh() {
92+
debug('Refreshing the view by invalidating cache');
93+
this._cachedBindings = undefined;
94+
this._cachedValues = undefined;
95+
}
96+
97+
/**
98+
* Resolve values for the matching bindings
99+
* @param session Resolution session
100+
*/
101+
resolve(session?: ResolutionSession): ValueOrPromise<T[]> {
102+
debug('Resolving values');
103+
// We don't cache values with this method as it returns `ValueOrPromise`
104+
// for inject `resolve` function to allow `getSync` but `this._cachedValues`
105+
// expects `T[]`
106+
return resolveList(this.bindings, b => {
107+
return b.getValue(this.context, ResolutionSession.fork(session));
108+
});
109+
}
110+
111+
/**
112+
* Get the list of resolved values. If they are not cached, it tries to find
113+
* and resolve them.
114+
*/
115+
async values(): Promise<T[]> {
116+
debug('Reading values');
117+
// Wait for the next tick so that context event notification can be emitted
118+
await nextTick();
119+
if (this._cachedValues == null) {
120+
this._cachedValues = await this.resolve();
121+
}
122+
return this._cachedValues;
123+
}
124+
125+
/**
126+
* As a `Getter` function
127+
*/
128+
asGetter(): Getter<T[]> {
129+
return () => this.values();
130+
}
131+
}

packages/context/src/context.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {ValueOrPromise} from '.';
1010
import {Binding, BindingTag} from './binding';
1111
import {BindingFilter, filterByKey, filterByTag} from './binding-filter';
1212
import {BindingAddress, BindingKey} from './binding-key';
13+
import {ContextView} from './context-view';
1314
import {
1415
ContextEventObserver,
1516
ContextEventType,
@@ -419,6 +420,16 @@ export class Context extends EventEmitter {
419420
return this.observers.has(observer);
420421
}
421422

423+
/**
424+
* Create a view of the context chain with the given binding filter
425+
* @param filter A function to match bindings
426+
*/
427+
createView<T = unknown>(filter: BindingFilter) {
428+
const view = new ContextView<T>(this, filter);
429+
view.open();
430+
return view;
431+
}
432+
422433
/**
423434
* Publish an event to the registered observers. Please note the
424435
* notification is queued and performed asynchronously so that we allow fluent

packages/context/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export * from './binding-key';
1111
export * from './binding-filter';
1212
export * from './context';
1313
export * from './context-observer';
14+
export * from './context-view';
1415
export * from './inject';
1516
export * from './keys';
1617
export * from './provider';
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import {expect} from '@loopback/testlab';
2+
import {
3+
Binding,
4+
filterByTag,
5+
Context,
6+
ContextObserver,
7+
ContextEventType,
8+
ContextView,
9+
Getter,
10+
inject,
11+
} from '../..';
12+
13+
let app: Context;
14+
let server: Context;
15+
16+
describe('ContextView', () => {
17+
let contextView: ContextView;
18+
beforeEach(givenViewForControllers);
19+
20+
it('watches matching bindings', async () => {
21+
// We have server: 1, app: 2
22+
expect(await getControllers()).to.eql(['1', '2']);
23+
server.unbind('controllers.1');
24+
// Now we have app: 2
25+
expect(await getControllers()).to.eql(['2']);
26+
app.unbind('controllers.2');
27+
// All controllers are gone from the context chain
28+
expect(await getControllers()).to.eql([]);
29+
// Add a new controller - server: 3
30+
givenController(server, '3');
31+
expect(await getControllers()).to.eql(['3']);
32+
});
33+
34+
function givenViewForControllers() {
35+
givenServerWithinAnApp();
36+
contextView = server.createView(filterByTag('controller'));
37+
givenController(server, '1');
38+
givenController(app, '2');
39+
}
40+
41+
function givenController(_ctx: Context, _name: string) {
42+
class MyController {
43+
name = _name;
44+
}
45+
_ctx
46+
.bind(`controllers.${_name}`)
47+
.toClass(MyController)
48+
.tag('controller');
49+
}
50+
51+
async function getControllers() {
52+
// tslint:disable-next-line:no-any
53+
return (await contextView.values()).map((v: any) => v.name);
54+
}
55+
});
56+
57+
describe('@inject.* - injects a live collection of matching bindings', async () => {
58+
beforeEach(givenPrimeNumbers);
59+
60+
class MyControllerWithGetter {
61+
@inject.getter(filterByTag('prime'))
62+
getter: Getter<number[]>;
63+
}
64+
65+
class MyControllerWithValues {
66+
constructor(
67+
@inject(filterByTag('prime'))
68+
public values: number[],
69+
) {}
70+
}
71+
72+
class MyControllerWithView {
73+
@inject.view(filterByTag('prime'))
74+
view: ContextView<number[]>;
75+
}
76+
77+
it('injects as getter', async () => {
78+
server.bind('my-controller').toClass(MyControllerWithGetter);
79+
const inst = await server.get<MyControllerWithGetter>('my-controller');
80+
const getter = inst.getter;
81+
expect(await getter()).to.eql([3, 5]);
82+
// Add a new binding that matches the filter
83+
givenPrime(server, 7);
84+
// The getter picks up the new binding
85+
expect(await getter()).to.eql([3, 7, 5]);
86+
});
87+
88+
it('injects as values', async () => {
89+
server.bind('my-controller').toClass(MyControllerWithValues);
90+
const inst = await server.get<MyControllerWithValues>('my-controller');
91+
expect(inst.values).to.eql([3, 5]);
92+
});
93+
94+
it('injects as a view', async () => {
95+
server.bind('my-controller').toClass(MyControllerWithView);
96+
const inst = await server.get<MyControllerWithView>('my-controller');
97+
const view = inst.view;
98+
expect(await view.values()).to.eql([3, 5]);
99+
// Add a new binding that matches the filter
100+
// Add a new binding that matches the filter
101+
givenPrime(server, 7);
102+
// The view picks up the new binding
103+
expect(await view.values()).to.eql([3, 7, 5]);
104+
server.unbind('prime.7');
105+
expect(await view.values()).to.eql([3, 5]);
106+
});
107+
108+
function givenPrimeNumbers() {
109+
givenServerWithinAnApp();
110+
givenPrime(server, 3);
111+
givenPrime(app, 5);
112+
}
113+
114+
function givenPrime(ctx: Context, p: number) {
115+
ctx
116+
.bind(`prime.${p}`)
117+
.to(p)
118+
.tag('prime');
119+
}
120+
});
121+
122+
describe('ContextEventListener', () => {
123+
let contextListener: MyListenerForControllers;
124+
beforeEach(givenControllerListener);
125+
126+
it('receives notifications of matching binding events', async () => {
127+
const controllers = await getControllers();
128+
// We have server: 1, app: 2
129+
// NOTE: The controllers are not guaranteed to be ['1', '2'] as the events
130+
// are emitted by two context objects and they are processed asynchronously
131+
expect(controllers).to.containEql('1');
132+
expect(controllers).to.containEql('2');
133+
server.unbind('controllers.1');
134+
// Now we have app: 2
135+
expect(await getControllers()).to.eql(['2']);
136+
app.unbind('controllers.2');
137+
// All controllers are gone from the context chain
138+
expect(await getControllers()).to.eql([]);
139+
// Add a new controller - server: 3
140+
givenController(server, '3');
141+
expect(await getControllers()).to.eql(['3']);
142+
});
143+
144+
class MyListenerForControllers implements ContextObserver {
145+
controllers: Set<string> = new Set();
146+
filter = filterByTag('controller');
147+
observe(event: ContextEventType, binding: Readonly<Binding<unknown>>) {
148+
if (event === 'bind') {
149+
this.controllers.add(binding.tagMap.name);
150+
} else if (event === 'unbind') {
151+
this.controllers.delete(binding.tagMap.name);
152+
}
153+
}
154+
}
155+
156+
function givenControllerListener() {
157+
givenServerWithinAnApp();
158+
contextListener = new MyListenerForControllers();
159+
server.subscribe(contextListener);
160+
givenController(server, '1');
161+
givenController(app, '2');
162+
}
163+
164+
function givenController(ctx: Context, controllerName: string) {
165+
class MyController {
166+
name = controllerName;
167+
}
168+
ctx
169+
.bind(`controllers.${controllerName}`)
170+
.toClass(MyController)
171+
.tag('controller', {name: controllerName});
172+
}
173+
174+
async function getControllers() {
175+
return new Promise<string[]>(resolve => {
176+
// Wrap it inside `setImmediate` to make the events are triggered
177+
setImmediate(() => resolve(Array.from(contextListener.controllers)));
178+
});
179+
}
180+
});
181+
182+
function givenServerWithinAnApp() {
183+
app = new Context('app');
184+
server = new Context(app, 'server');
185+
}

0 commit comments

Comments
 (0)