From cda66e193cc91f29115a1d1d785aa507731a98f1 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Fri, 25 Jan 2019 07:56:10 -0800 Subject: [PATCH 1/8] feat(context): introduce context listener for bind/unbind events --- packages/context/src/context-listener.ts | 49 ++++++++ packages/context/src/context.ts | 140 ++++++++++++++++++++- packages/context/src/index.ts | 1 + packages/context/test/unit/context.unit.ts | 111 +++++++++++++++- 4 files changed, 295 insertions(+), 6 deletions(-) create mode 100644 packages/context/src/context-listener.ts diff --git a/packages/context/src/context-listener.ts b/packages/context/src/context-listener.ts new file mode 100644 index 000000000000..d26a60b871f9 --- /dev/null +++ b/packages/context/src/context-listener.ts @@ -0,0 +1,49 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/context +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Binding} from './binding'; +import {BindingFilter} from './binding-filter'; +import {ValueOrPromise} from './value-promise'; + +/** + * Context event types. We support `bind` and `unbind` for now but + * keep it open for new types + */ +export type ContextEventType = 'bind' | 'unbind' | string; + +/** + * Listeners of context bind/unbind events + */ +export interface ContextEventListener { + /** + * A filter function to match bindings + */ + filter?: BindingFilter; + + /** + * Listen on `bind`, `unbind`, or other events + * @param eventType Context event type + * @param binding The binding as event source + */ + listen( + eventType: ContextEventType, + binding: Readonly>, + ): ValueOrPromise; +} + +/** + * Subscription of context events. It's modeled after + * https://github.com/tc39/proposal-observable. + */ +export interface Subscription { + /** + * unsubscribe + */ + unsubscribe(): void; + /** + * Is the subscription closed? + */ + closed: boolean; +} diff --git a/packages/context/src/context.ts b/packages/context/src/context.ts index aa862b015886..3e4d0615fa78 100644 --- a/packages/context/src/context.ts +++ b/packages/context/src/context.ts @@ -7,10 +7,15 @@ import * as debugModule from 'debug'; import {v1 as uuidv1} from 'uuid'; import {ValueOrPromise} from '.'; import {Binding, BindingTag} from './binding'; +import {BindingFilter, filterByKey, filterByTag} from './binding-filter'; import {BindingAddress, BindingKey} from './binding-key'; +import { + ContextEventListener, + ContextEventType, + Subscription, +} from './context-listener'; import {ResolutionOptions, ResolutionSession} from './resolution-session'; import {BoundValue, getDeepProperty, isPromiseLike} from './value-promise'; -import {BindingFilter, filterByKey, filterByTag} from './binding-filter'; const debug = debugModule('loopback:context'); @@ -34,8 +39,29 @@ export class Context { protected _parent?: Context; /** - * Create a new context + * A list of registered context listeners + */ + protected readonly listeners: Set = new Set(); + + /** + * Create a new context. For example, + * ```ts + * // Create a new root context, let the framework to create a unique name + * const rootCtx = new Context(); + * + * // Create a new child context inheriting bindings from `rootCtx` + * const childCtx = new Context(rootCtx); + * + * // Create another root context called "application" + * const appCtx = new Context('application'); + * + * // Create a new child context called "request" and inheriting bindings + * // from `appCtx` + * const reqCtx = new Context(appCtx, 'request'); + * ``` * @param _parent The optional parent context + * @param name Name of the context, if not provided, a `uuid` will be + * generated as the name */ constructor(_parent?: Context | string, name?: string) { if (typeof _parent === 'string') { @@ -72,14 +98,21 @@ export class Context { debug('Adding binding: %s', key); } + let existingBinding: Binding | undefined; const keyExists = this.registry.has(key); if (keyExists) { - const existingBinding = this.registry.get(key); + existingBinding = this.registry.get(key); const bindingIsLocked = existingBinding && existingBinding.isLocked; if (bindingIsLocked) throw new Error(`Cannot rebind key "${key}" to a locked binding`); } this.registry.set(key, binding); + if (existingBinding !== binding) { + if (existingBinding != null) { + this.notifyListeners('unbind', existingBinding); + } + this.notifyListeners('bind', binding); + } return this; } @@ -96,10 +129,75 @@ export class Context { unbind(key: BindingAddress): boolean { key = BindingKey.validate(key); const binding = this.registry.get(key); + // If not found, return `false` if (binding == null) return false; if (binding && binding.isLocked) throw new Error(`Cannot unbind key "${key}" of a locked binding`); - return this.registry.delete(key); + this.registry.delete(key); + this.notifyListeners('unbind', binding); + return true; + } + + /** + * Add the context listener as an event listener to the context chain, + * including its ancestors + * @param listener Context listener + */ + subscribe(listener: ContextEventListener): Subscription { + let ctx: Context | undefined = this; + while (ctx != null) { + ctx.listeners.add(listener); + ctx = ctx._parent; + } + return new ContextSubscription(this, listener); + } + + /** + * Remove the context listener from the context chain + * @param listener Context listener + */ + unsubscribe(listener: ContextEventListener) { + let ctx: Context | undefined = this; + while (ctx != null) { + ctx.listeners.delete(listener); + ctx = ctx._parent; + } + } + + /** + * Check if a listener is subscribed to this context + * @param listener Context listener + */ + isSubscribed(listener: ContextEventListener) { + return this.listeners.has(listener); + } + + /** + * Publish an event to the registered listeners. Please note the + * notification happens using `process.nextTick` so that we allow fluent APIs + * such as `ctx.bind('key').to(...).tag(...);` and give listeners the fully + * populated binding + * + * @param event Event names: `bind` or `unbind` + * @param binding Binding bound or unbound + */ + protected notifyListeners( + event: ContextEventType, + binding: Readonly>, + ) { + // Notify listeners in the next tick + process.nextTick(async () => { + for (const listener of this.listeners) { + if (!listener.filter || listener.filter(binding)) { + try { + await listener.listen(event, binding); + } catch (err) { + debug('Error thrown by a listener is ignored', err, event, binding); + // Ignore the error + } + } + } + }); } /** @@ -150,6 +248,19 @@ export class Context { * - return `true` to include the binding in the results * - return `false` to exclude it. */ + find( + pattern?: string | RegExp, + ): Readonly>[]; + + /** + * Find bindings using a filter function + * @param filter A function to test on the binding. It returns `true` to + * include the binding or `false` to exclude the binding. + */ + find( + filter: BindingFilter, + ): Readonly>[]; + find( pattern?: string | RegExp | BindingFilter, ): Readonly>[] { @@ -451,3 +562,24 @@ export class Context { return json; } } + +/** + * An implementation of `Subscription` interface for context events + */ +class ContextSubscription implements Subscription { + constructor( + protected context: Context, + protected listener: ContextEventListener, + ) {} + + private _closed = false; + + unsubscribe() { + this.context.unsubscribe(this.listener); + this._closed = true; + } + + get closed() { + return this._closed; + } +} diff --git a/packages/context/src/index.ts b/packages/context/src/index.ts index 4a83b7c580ef..a8695222ea30 100644 --- a/packages/context/src/index.ts +++ b/packages/context/src/index.ts @@ -10,6 +10,7 @@ export * from './binding-inspector'; export * from './binding-key'; export * from './binding-filter'; export * from './context'; +export * from './context-listener'; export * from './inject'; export * from './keys'; export * from './provider'; diff --git a/packages/context/test/unit/context.unit.ts b/packages/context/test/unit/context.unit.ts index e0896a8c9265..4c8d99c97b7b 100644 --- a/packages/context/test/unit/context.unit.ts +++ b/packages/context/test/unit/context.unit.ts @@ -5,14 +5,18 @@ import {expect} from '@loopback/testlab'; import { - Context, Binding, + BindingKey, BindingScope, BindingType, + Context, + ContextEventListener, isPromiseLike, - BindingKey, } from '../..'; +import {promisify} from 'util'; +const nextTick = promisify(process.nextTick); + /** * Create a subclass of context so that we can access parents and registry * for assertions @@ -681,6 +685,109 @@ describe('Context', () => { }); }); + describe('listener subscription', () => { + let nonMatchingListener: ContextEventListener; + + beforeEach(givenNonMatchingListener); + + it('subscribes listeners', () => { + ctx.subscribe(nonMatchingListener); + expect(ctx.isSubscribed(nonMatchingListener)).to.true(); + }); + + it('unsubscribes listeners', () => { + ctx.subscribe(nonMatchingListener); + expect(ctx.isSubscribed(nonMatchingListener)).to.true(); + ctx.unsubscribe(nonMatchingListener); + expect(ctx.isSubscribed(nonMatchingListener)).to.false(); + }); + + it('allows subscription.unsubscribe()', () => { + const subscription = ctx.subscribe(nonMatchingListener); + expect(ctx.isSubscribed(nonMatchingListener)).to.true(); + subscription.unsubscribe(); + expect(ctx.isSubscribed(nonMatchingListener)).to.false(); + expect(subscription.closed).to.be.true(); + }); + + it('registers listeners on context chain', () => { + const childCtx = new Context(ctx, 'child'); + childCtx.subscribe(nonMatchingListener); + expect(childCtx.isSubscribed(nonMatchingListener)).to.true(); + expect(ctx.isSubscribed(nonMatchingListener)).to.true(); + }); + + it('un-registers listeners on context chain', () => { + const childCtx = new Context(ctx, 'child'); + childCtx.subscribe(nonMatchingListener); + expect(childCtx.isSubscribed(nonMatchingListener)).to.true(); + expect(ctx.isSubscribed(nonMatchingListener)).to.true(); + childCtx.unsubscribe(nonMatchingListener); + expect(childCtx.isSubscribed(nonMatchingListener)).to.false(); + expect(ctx.isSubscribed(nonMatchingListener)).to.false(); + }); + + function givenNonMatchingListener() { + nonMatchingListener = { + filter: binding => false, + listen: (event, binding) => {}, + }; + } + }); + + describe('event notification', () => { + let matchingListener: ContextEventListener; + let nonMatchingListener: ContextEventListener; + const events: string[] = []; + let nonMatchingListenerCalled = false; + + beforeEach(givenListeners); + + it('emits bind event to matching listeners', async () => { + ctx.bind('foo').to('foo'); + await nextTick(); + expect(events).to.eql(['foo:bind']); + expect(nonMatchingListenerCalled).to.be.false(); + }); + + it('emits unbind event to matching listeners', async () => { + ctx.bind('foo').to('foo'); + await nextTick(); + ctx.unbind('foo'); + await nextTick(); + expect(events).to.eql(['foo:bind', 'foo:unbind']); + expect(nonMatchingListenerCalled).to.be.false(); + }); + + it('does not trigger listeners if affected binding is the same', async () => { + const binding = ctx.bind('foo').to('foo'); + await nextTick(); + expect(events).to.eql(['foo:bind']); + ctx.add(binding); + await nextTick(); + expect(events).to.eql(['foo:bind']); + }); + + function givenListeners() { + nonMatchingListenerCalled = false; + events.splice(0, events.length); + nonMatchingListener = { + filter: binding => false, + listen: (event, binding) => { + nonMatchingListenerCalled = true; + }, + }; + matchingListener = { + filter: binding => true, + listen: (event, binding) => { + events.push(`${binding.key}:${event}`); + }, + }; + ctx.subscribe(nonMatchingListener); + ctx.subscribe(matchingListener); + } + }); + function createContext() { ctx = new Context(); } From 9c209fe0112575a525932bb157f9f2ca2378827d Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Mon, 28 Jan 2019 10:43:36 -0800 Subject: [PATCH 2/8] chore(context): use queue to handle event notifications --- packages/context/package.json | 1 + packages/context/src/context.ts | 69 ++++++++-------- packages/context/test/unit/context.unit.ts | 92 +++++++++++++++++----- 3 files changed, 111 insertions(+), 51 deletions(-) diff --git a/packages/context/package.json b/packages/context/package.json index 5df62eefa0fb..4d0db7153473 100644 --- a/packages/context/package.json +++ b/packages/context/package.json @@ -21,6 +21,7 @@ "dependencies": { "@loopback/metadata": "^1.0.5", "debug": "^4.0.1", + "queue": "^5.0.0", "uuid": "^3.2.1" }, "devDependencies": { diff --git a/packages/context/src/context.ts b/packages/context/src/context.ts index 3e4d0615fa78..6cc6ff677852 100644 --- a/packages/context/src/context.ts +++ b/packages/context/src/context.ts @@ -4,6 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import * as debugModule from 'debug'; +import Queue from 'queue'; import {v1 as uuidv1} from 'uuid'; import {ValueOrPromise} from '.'; import {Binding, BindingTag} from './binding'; @@ -43,6 +44,11 @@ export class Context { */ protected readonly listeners: Set = new Set(); + /** + * Queue for context event notifications + */ + protected readonly eventQueue = new Queue({concurrency: 1, autostart: true}); + /** * Create a new context. For example, * ```ts @@ -174,30 +180,44 @@ export class Context { /** * Publish an event to the registered listeners. Please note the - * notification happens using `process.nextTick` so that we allow fluent APIs - * such as `ctx.bind('key').to(...).tag(...);` and give listeners the fully - * populated binding + * notification is queued and performed asynchronously so that we allow fluent + * APIs such as `ctx.bind('key').to(...).tag(...);` and give listeners the + * fully populated binding. * - * @param event Event names: `bind` or `unbind` + * @param eventType Event names: `bind` or `unbind` * @param binding Binding bound or unbound */ protected notifyListeners( - event: ContextEventType, + eventType: ContextEventType, binding: Readonly>, ) { - // Notify listeners in the next tick - process.nextTick(async () => { - for (const listener of this.listeners) { - if (!listener.filter || listener.filter(binding)) { - try { - await listener.listen(event, binding); - } catch (err) { - debug('Error thrown by a listener is ignored', err, event, binding); - // Ignore the error + if (this.listeners.size === 0) return; + // Schedule the notification task into the event queue + const task = () => { + return new Promise((resolve, reject) => { + // Run notifications in nextTick so that the binding is fully populated + process.nextTick(async () => { + for (const listener of this.listeners) { + if (!listener.filter || listener.filter(binding)) { + try { + await listener.listen(eventType, binding); + } catch (err) { + debug( + 'Error thrown by a listener is ignored', + err, + eventType, + binding, + ); + reject(err); + return; + } + } } - } - } - }); + resolve(); + }); + }); + }; + this.eventQueue.push(task); } /** @@ -235,7 +255,7 @@ export class Context { } /** - * Find bindings using the key pattern + * Find bindings using a key pattern or filter function * @param pattern A filter function, a regexp or a wildcard pattern with * optional `*` and `?`. Find returns such bindings where the key matches * the provided pattern. @@ -248,19 +268,6 @@ export class Context { * - return `true` to include the binding in the results * - return `false` to exclude it. */ - find( - pattern?: string | RegExp, - ): Readonly>[]; - - /** - * Find bindings using a filter function - * @param filter A function to test on the binding. It returns `true` to - * include the binding or `false` to exclude the binding. - */ - find( - filter: BindingFilter, - ): Readonly>[]; - find( pattern?: string | RegExp | BindingFilter, ): Readonly>[] { diff --git a/packages/context/test/unit/context.unit.ts b/packages/context/test/unit/context.unit.ts index 4c8d99c97b7b..950381c352f8 100644 --- a/packages/context/test/unit/context.unit.ts +++ b/packages/context/test/unit/context.unit.ts @@ -15,7 +15,7 @@ import { } from '../..'; import {promisify} from 'util'; -const nextTick = promisify(process.nextTick); +const setImmediatePromise = promisify(setImmediate); /** * Create a subclass of context so that we can access parents and registry @@ -29,6 +29,24 @@ class TestContext extends Context { const map = new Map(this.registry); return map; } + /** + * Wait until the context event queue is empty + */ + waitUntilEventsProcessed() { + return new Promise((resolve, reject) => { + if (this.eventQueue.length === 0) { + resolve(); + return; + } + this.eventQueue.on('end', err => { + if (err) reject(err); + else resolve(); + }); + this.eventQueue.on('error', err => { + reject(err); + }); + }); + } } describe('Context constructor', () => { @@ -65,7 +83,7 @@ describe('Context constructor', () => { }); describe('Context', () => { - let ctx: Context; + let ctx: TestContext; beforeEach('given a context', createContext); describe('bind', () => { @@ -736,59 +754,93 @@ describe('Context', () => { }); describe('event notification', () => { - let matchingListener: ContextEventListener; - let nonMatchingListener: ContextEventListener; const events: string[] = []; let nonMatchingListenerCalled = false; beforeEach(givenListeners); it('emits bind event to matching listeners', async () => { - ctx.bind('foo').to('foo'); - await nextTick(); - expect(events).to.eql(['foo:bind']); + ctx.bind('foo').to('foo-value'); + await ctx.waitUntilEventsProcessed(); + expect(events).to.eql(['1:foo:foo-value:bind', '2:foo:foo-value:bind']); expect(nonMatchingListenerCalled).to.be.false(); }); it('emits unbind event to matching listeners', async () => { - ctx.bind('foo').to('foo'); - await nextTick(); + ctx.bind('foo').to('foo-value'); + await ctx.waitUntilEventsProcessed(); ctx.unbind('foo'); - await nextTick(); - expect(events).to.eql(['foo:bind', 'foo:unbind']); + await ctx.waitUntilEventsProcessed(); + expect(events).to.eql([ + '1:foo:foo-value:bind', + '2:foo:foo-value:bind', + '1:foo:foo-value:unbind', + '2:foo:foo-value:unbind', + ]); expect(nonMatchingListenerCalled).to.be.false(); }); it('does not trigger listeners if affected binding is the same', async () => { - const binding = ctx.bind('foo').to('foo'); - await nextTick(); - expect(events).to.eql(['foo:bind']); + const binding = ctx.bind('foo').to('foo-value'); + await ctx.waitUntilEventsProcessed(); + expect(events).to.eql(['1:foo:foo-value:bind', '2:foo:foo-value:bind']); ctx.add(binding); - await nextTick(); - expect(events).to.eql(['foo:bind']); + await ctx.waitUntilEventsProcessed(); + expect(events).to.eql(['1:foo:foo-value:bind', '2:foo:foo-value:bind']); + }); + + it('reports error if a listener fails', () => { + ctx.bind('bar').to('bar-value'); + return expect(ctx.waitUntilEventsProcessed()).to.be.rejectedWith( + 'something wrong', + ); }); function givenListeners() { nonMatchingListenerCalled = false; events.splice(0, events.length); - nonMatchingListener = { + // A listener does not match the criteria + const nonMatchingListener: ContextEventListener = { filter: binding => false, listen: (event, binding) => { nonMatchingListenerCalled = true; }, }; - matchingListener = { + // A sync listener matches the criteria + const matchingListener: ContextEventListener = { filter: binding => true, listen: (event, binding) => { - events.push(`${binding.key}:${event}`); + // Make sure the binding is configured with value + // when the listener is notified + const val = binding.getValue(ctx); + events.push(`1:${binding.key}:${val}:${event}`); + }, + }; + // An async listener matches the criteria + const matchingAsyncListener: ContextEventListener = { + filter: binding => true, + listen: async (event, binding) => { + await setImmediatePromise(); + const val = binding.getValue(ctx); + events.push(`2:${binding.key}:${val}:${event}`); + }, + }; + // An async listener matches the criteria that throws an error + const matchingAsyncListenerWithError: ContextEventListener = { + filter: binding => binding.key === 'bar', + listen: async (event, binding) => { + await setImmediatePromise(); + throw new Error('something wrong'); }, }; ctx.subscribe(nonMatchingListener); ctx.subscribe(matchingListener); + ctx.subscribe(matchingAsyncListener); + ctx.subscribe(matchingAsyncListenerWithError); } }); function createContext() { - ctx = new Context(); + ctx = new TestContext(); } }); From 5ac8b78e7dc8d949434f804820dc740ad48fa05e Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Tue, 29 Jan 2019 08:14:32 -0800 Subject: [PATCH 3/8] chore(context): address review comments --- packages/context/src/context-listener.ts | 5 ++- packages/context/src/context.ts | 18 ++++------- packages/context/test/unit/context.unit.ts | 37 ++++++++++++++-------- 3 files changed, 34 insertions(+), 26 deletions(-) diff --git a/packages/context/src/context-listener.ts b/packages/context/src/context-listener.ts index d26a60b871f9..26949468670f 100644 --- a/packages/context/src/context-listener.ts +++ b/packages/context/src/context-listener.ts @@ -6,6 +6,7 @@ import {Binding} from './binding'; import {BindingFilter} from './binding-filter'; import {ValueOrPromise} from './value-promise'; +import {Context} from './context'; /** * Context event types. We support `bind` and `unbind` for now but @@ -18,7 +19,8 @@ export type ContextEventType = 'bind' | 'unbind' | string; */ export interface ContextEventListener { /** - * A filter function to match bindings + * An optional filter function to match bindings. If not present, the listener + * will be notified of all binding events. */ filter?: BindingFilter; @@ -30,6 +32,7 @@ export interface ContextEventListener { listen( eventType: ContextEventType, binding: Readonly>, + context: Context, ): ValueOrPromise; } diff --git a/packages/context/src/context.ts b/packages/context/src/context.ts index 6cc6ff677852..8e5c9d40898b 100644 --- a/packages/context/src/context.ts +++ b/packages/context/src/context.ts @@ -145,9 +145,8 @@ export class Context { } /** - * Add the context listener as an event listener to the context chain, - * including its ancestors - * @param listener Context listener + * Add a context event listener to the context chain, including its ancestors + * @param listener Context event listener */ subscribe(listener: ContextEventListener): Subscription { let ctx: Context | undefined = this; @@ -159,8 +158,8 @@ export class Context { } /** - * Remove the context listener from the context chain - * @param listener Context listener + * Remove the context event listener from the context chain + * @param listener Context event listener */ unsubscribe(listener: ContextEventListener) { let ctx: Context | undefined = this; @@ -200,14 +199,9 @@ export class Context { for (const listener of this.listeners) { if (!listener.filter || listener.filter(binding)) { try { - await listener.listen(eventType, binding); + await listener.listen(eventType, binding, this); } catch (err) { - debug( - 'Error thrown by a listener is ignored', - err, - eventType, - binding, - ); + debug(err, eventType, binding); reject(err); return; } diff --git a/packages/context/test/unit/context.unit.ts b/packages/context/test/unit/context.unit.ts index 950381c352f8..f80091ee9f8e 100644 --- a/packages/context/test/unit/context.unit.ts +++ b/packages/context/test/unit/context.unit.ts @@ -15,7 +15,7 @@ import { } from '../..'; import {promisify} from 'util'; -const setImmediatePromise = promisify(setImmediate); +const setImmediateAsync = promisify(setImmediate); /** * Create a subclass of context so that we can access parents and registry @@ -30,7 +30,7 @@ class TestContext extends Context { return map; } /** - * Wait until the context event queue is empty + * Wait until the context event queue is empty or an error is thrown */ waitUntilEventsProcessed() { return new Promise((resolve, reject) => { @@ -38,11 +38,11 @@ class TestContext extends Context { resolve(); return; } - this.eventQueue.on('end', err => { + this.eventQueue.once('end', err => { if (err) reject(err); else resolve(); }); - this.eventQueue.on('error', err => { + this.eventQueue.once('error', err => { reject(err); }); }); @@ -759,13 +759,25 @@ describe('Context', () => { beforeEach(givenListeners); - it('emits bind event to matching listeners', async () => { + it('emits one bind event to matching listeners', async () => { ctx.bind('foo').to('foo-value'); await ctx.waitUntilEventsProcessed(); expect(events).to.eql(['1:foo:foo-value:bind', '2:foo:foo-value:bind']); expect(nonMatchingListenerCalled).to.be.false(); }); + it('emits multiple bind events to matching listeners', async () => { + ctx.bind('foo').to('foo-value'); + ctx.bind('xyz').to('xyz-value'); + await ctx.waitUntilEventsProcessed(); + expect(events).to.eql([ + '1:foo:foo-value:bind', + '2:foo:foo-value:bind', + '1:xyz:xyz-value:bind', + '2:xyz:xyz-value:bind', + ]); + }); + it('emits unbind event to matching listeners', async () => { ctx.bind('foo').to('foo-value'); await ctx.waitUntilEventsProcessed(); @@ -808,28 +820,27 @@ describe('Context', () => { }; // A sync listener matches the criteria const matchingListener: ContextEventListener = { - filter: binding => true, - listen: (event, binding) => { + listen: (event, binding, context) => { // Make sure the binding is configured with value // when the listener is notified - const val = binding.getValue(ctx); + const val = binding.getValue(context); events.push(`1:${binding.key}:${val}:${event}`); }, }; // An async listener matches the criteria const matchingAsyncListener: ContextEventListener = { filter: binding => true, - listen: async (event, binding) => { - await setImmediatePromise(); - const val = binding.getValue(ctx); + listen: async (event, binding, context) => { + await setImmediateAsync(); + const val = binding.getValue(context); events.push(`2:${binding.key}:${val}:${event}`); }, }; // An async listener matches the criteria that throws an error const matchingAsyncListenerWithError: ContextEventListener = { filter: binding => binding.key === 'bar', - listen: async (event, binding) => { - await setImmediatePromise(); + listen: async () => { + await setImmediateAsync(); throw new Error('something wrong'); }, }; From fcfbf08cb14cf92a1d5f11a32f9e716276bb553a Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Tue, 29 Jan 2019 15:18:26 -0800 Subject: [PATCH 4/8] chore(context): make Context extend from EventEmitter --- packages/context/src/context.ts | 45 +++++++++++++++++----- packages/context/test/unit/context.unit.ts | 4 +- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/packages/context/src/context.ts b/packages/context/src/context.ts index 8e5c9d40898b..d47c9d38947b 100644 --- a/packages/context/src/context.ts +++ b/packages/context/src/context.ts @@ -4,6 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import * as debugModule from 'debug'; +import {EventEmitter} from 'events'; import Queue from 'queue'; import {v1 as uuidv1} from 'uuid'; import {ValueOrPromise} from '.'; @@ -23,7 +24,7 @@ const debug = debugModule('loopback:context'); /** * Context provides an implementation of Inversion of Control (IoC) container */ -export class Context { +export class Context extends EventEmitter { /** * Name of the context */ @@ -42,7 +43,7 @@ export class Context { /** * A list of registered context listeners */ - protected readonly listeners: Set = new Set(); + protected readonly observers: Set = new Set(); /** * Queue for context event notifications @@ -70,12 +71,36 @@ export class Context { * generated as the name */ constructor(_parent?: Context | string, name?: string) { + super(); if (typeof _parent === 'string') { name = _parent; _parent = undefined; } this._parent = _parent; this.name = name || uuidv1(); + + this.setupEventHandlers(); + } + + /** + * Set up an internal listener to notify registered observers asynchronously + * upon `bind` and `unbind` events + */ + private setupEventHandlers() { + for (const event of ['bind', 'unbind']) { + this.on(event, (binding: Readonly>) => { + this.notifyListeners(event, binding); + }); + } + this.eventQueue.on('error', err => { + this.emit('error', err); + }); + this.eventQueue.on('end', err => { + if (err) this.emit('error', err); + else { + this.emit('observersNotified'); + } + }); } /** @@ -115,9 +140,9 @@ export class Context { this.registry.set(key, binding); if (existingBinding !== binding) { if (existingBinding != null) { - this.notifyListeners('unbind', existingBinding); + this.emit('unbind', existingBinding); } - this.notifyListeners('bind', binding); + this.emit('bind', binding); } return this; } @@ -140,7 +165,7 @@ export class Context { if (binding && binding.isLocked) throw new Error(`Cannot unbind key "${key}" of a locked binding`); this.registry.delete(key); - this.notifyListeners('unbind', binding); + this.emit('unbind', binding); return true; } @@ -151,7 +176,7 @@ export class Context { subscribe(listener: ContextEventListener): Subscription { let ctx: Context | undefined = this; while (ctx != null) { - ctx.listeners.add(listener); + ctx.observers.add(listener); ctx = ctx._parent; } return new ContextSubscription(this, listener); @@ -164,7 +189,7 @@ export class Context { unsubscribe(listener: ContextEventListener) { let ctx: Context | undefined = this; while (ctx != null) { - ctx.listeners.delete(listener); + ctx.observers.delete(listener); ctx = ctx._parent; } } @@ -174,7 +199,7 @@ export class Context { * @param listener Context listener */ isSubscribed(listener: ContextEventListener) { - return this.listeners.has(listener); + return this.observers.has(listener); } /** @@ -190,13 +215,13 @@ export class Context { eventType: ContextEventType, binding: Readonly>, ) { - if (this.listeners.size === 0) return; + if (this.observers.size === 0) return; // Schedule the notification task into the event queue const task = () => { return new Promise((resolve, reject) => { // Run notifications in nextTick so that the binding is fully populated process.nextTick(async () => { - for (const listener of this.listeners) { + for (const listener of this.observers) { if (!listener.filter || listener.filter(binding)) { try { await listener.listen(eventType, binding, this); diff --git a/packages/context/test/unit/context.unit.ts b/packages/context/test/unit/context.unit.ts index f80091ee9f8e..f472ca3816f0 100644 --- a/packages/context/test/unit/context.unit.ts +++ b/packages/context/test/unit/context.unit.ts @@ -38,11 +38,11 @@ class TestContext extends Context { resolve(); return; } - this.eventQueue.once('end', err => { + this.once('observersNotified', err => { if (err) reject(err); else resolve(); }); - this.eventQueue.once('error', err => { + this.once('error', err => { reject(err); }); }); From 585fe5f6729938809dcf326992061d34b4e41e1f Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Wed, 30 Jan 2019 08:22:31 -0800 Subject: [PATCH 5/8] chore(context): rename ContextEventListener to ContextObserver --- ...ontext-listener.ts => context-observer.ts} | 6 +- packages/context/src/context.ts | 54 +++---- packages/context/src/index.ts | 2 +- packages/context/test/unit/context.unit.ts | 132 +++++++++--------- 4 files changed, 98 insertions(+), 96 deletions(-) rename packages/context/src/{context-listener.ts => context-observer.ts} (93%) diff --git a/packages/context/src/context-listener.ts b/packages/context/src/context-observer.ts similarity index 93% rename from packages/context/src/context-listener.ts rename to packages/context/src/context-observer.ts index 26949468670f..c53b39d273d1 100644 --- a/packages/context/src/context-listener.ts +++ b/packages/context/src/context-observer.ts @@ -15,9 +15,9 @@ import {Context} from './context'; export type ContextEventType = 'bind' | 'unbind' | string; /** - * Listeners of context bind/unbind events + * Observers of context bind/unbind events */ -export interface ContextEventListener { +export interface ContextObserver { /** * An optional filter function to match bindings. If not present, the listener * will be notified of all binding events. @@ -29,7 +29,7 @@ export interface ContextEventListener { * @param eventType Context event type * @param binding The binding as event source */ - listen( + observe( eventType: ContextEventType, binding: Readonly>, context: Context, diff --git a/packages/context/src/context.ts b/packages/context/src/context.ts index d47c9d38947b..53d3d411c2e9 100644 --- a/packages/context/src/context.ts +++ b/packages/context/src/context.ts @@ -12,10 +12,10 @@ import {Binding, BindingTag} from './binding'; import {BindingFilter, filterByKey, filterByTag} from './binding-filter'; import {BindingAddress, BindingKey} from './binding-key'; import { - ContextEventListener, + ContextObserver, ContextEventType, Subscription, -} from './context-listener'; +} from './context-observer'; import {ResolutionOptions, ResolutionSession} from './resolution-session'; import {BoundValue, getDeepProperty, isPromiseLike} from './value-promise'; @@ -41,9 +41,9 @@ export class Context extends EventEmitter { protected _parent?: Context; /** - * A list of registered context listeners + * A list of registered context observers */ - protected readonly observers: Set = new Set(); + protected readonly observers: Set = new Set(); /** * Queue for context event notifications @@ -88,10 +88,12 @@ export class Context extends EventEmitter { */ private setupEventHandlers() { for (const event of ['bind', 'unbind']) { + // Listen on events and notify observers this.on(event, (binding: Readonly>) => { - this.notifyListeners(event, binding); + this.notifyObservers(event, binding); }); } + // Relay events from the event queue this.eventQueue.on('error', err => { this.emit('error', err); }); @@ -170,48 +172,48 @@ export class Context extends EventEmitter { } /** - * Add a context event listener to the context chain, including its ancestors - * @param listener Context event listener + * Add a context event observer to the context chain, including its ancestors + * @param observer Context event observer */ - subscribe(listener: ContextEventListener): Subscription { + subscribe(observer: ContextObserver): Subscription { let ctx: Context | undefined = this; while (ctx != null) { - ctx.observers.add(listener); + ctx.observers.add(observer); ctx = ctx._parent; } - return new ContextSubscription(this, listener); + return new ContextSubscription(this, observer); } /** - * Remove the context event listener from the context chain - * @param listener Context event listener + * Remove the context event observer from the context chain + * @param observer Context event observer */ - unsubscribe(listener: ContextEventListener) { + unsubscribe(observer: ContextObserver) { let ctx: Context | undefined = this; while (ctx != null) { - ctx.observers.delete(listener); + ctx.observers.delete(observer); ctx = ctx._parent; } } /** - * Check if a listener is subscribed to this context - * @param listener Context listener + * Check if an observer is subscribed to this context + * @param observer Context observer */ - isSubscribed(listener: ContextEventListener) { - return this.observers.has(listener); + isSubscribed(observer: ContextObserver) { + return this.observers.has(observer); } /** - * Publish an event to the registered listeners. Please note the + * Publish an event to the registered observers. Please note the * notification is queued and performed asynchronously so that we allow fluent - * APIs such as `ctx.bind('key').to(...).tag(...);` and give listeners the + * APIs such as `ctx.bind('key').to(...).tag(...);` and give observers the * fully populated binding. * * @param eventType Event names: `bind` or `unbind` * @param binding Binding bound or unbound */ - protected notifyListeners( + protected notifyObservers( eventType: ContextEventType, binding: Readonly>, ) { @@ -221,10 +223,10 @@ export class Context extends EventEmitter { return new Promise((resolve, reject) => { // Run notifications in nextTick so that the binding is fully populated process.nextTick(async () => { - for (const listener of this.observers) { - if (!listener.filter || listener.filter(binding)) { + for (const observer of this.observers) { + if (!observer.filter || observer.filter(binding)) { try { - await listener.listen(eventType, binding, this); + await observer.observe(eventType, binding, this); } catch (err) { debug(err, eventType, binding); reject(err); @@ -595,13 +597,13 @@ export class Context extends EventEmitter { class ContextSubscription implements Subscription { constructor( protected context: Context, - protected listener: ContextEventListener, + protected observer: ContextObserver, ) {} private _closed = false; unsubscribe() { - this.context.unsubscribe(this.listener); + this.context.unsubscribe(this.observer); this._closed = true; } diff --git a/packages/context/src/index.ts b/packages/context/src/index.ts index a8695222ea30..0b204e662562 100644 --- a/packages/context/src/index.ts +++ b/packages/context/src/index.ts @@ -10,7 +10,7 @@ export * from './binding-inspector'; export * from './binding-key'; export * from './binding-filter'; export * from './context'; -export * from './context-listener'; +export * from './context-observer'; export * from './inject'; export * from './keys'; export * from './provider'; diff --git a/packages/context/test/unit/context.unit.ts b/packages/context/test/unit/context.unit.ts index f472ca3816f0..53838a3225c9 100644 --- a/packages/context/test/unit/context.unit.ts +++ b/packages/context/test/unit/context.unit.ts @@ -10,7 +10,7 @@ import { BindingScope, BindingType, Context, - ContextEventListener, + ContextObserver, isPromiseLike, } from '../..'; @@ -32,7 +32,7 @@ class TestContext extends Context { /** * Wait until the context event queue is empty or an error is thrown */ - waitUntilEventsProcessed() { + waitUntilObserversNotified() { return new Promise((resolve, reject) => { if (this.eventQueue.length === 0) { resolve(); @@ -703,73 +703,73 @@ describe('Context', () => { }); }); - describe('listener subscription', () => { - let nonMatchingListener: ContextEventListener; + describe('observer subscription', () => { + let nonMatchingObserver: ContextObserver; - beforeEach(givenNonMatchingListener); + beforeEach(givenNonMatchingObserver); - it('subscribes listeners', () => { - ctx.subscribe(nonMatchingListener); - expect(ctx.isSubscribed(nonMatchingListener)).to.true(); + it('subscribes observers', () => { + ctx.subscribe(nonMatchingObserver); + expect(ctx.isSubscribed(nonMatchingObserver)).to.true(); }); - it('unsubscribes listeners', () => { - ctx.subscribe(nonMatchingListener); - expect(ctx.isSubscribed(nonMatchingListener)).to.true(); - ctx.unsubscribe(nonMatchingListener); - expect(ctx.isSubscribed(nonMatchingListener)).to.false(); + it('unsubscribes observers', () => { + ctx.subscribe(nonMatchingObserver); + expect(ctx.isSubscribed(nonMatchingObserver)).to.true(); + ctx.unsubscribe(nonMatchingObserver); + expect(ctx.isSubscribed(nonMatchingObserver)).to.false(); }); it('allows subscription.unsubscribe()', () => { - const subscription = ctx.subscribe(nonMatchingListener); - expect(ctx.isSubscribed(nonMatchingListener)).to.true(); + const subscription = ctx.subscribe(nonMatchingObserver); + expect(ctx.isSubscribed(nonMatchingObserver)).to.true(); subscription.unsubscribe(); - expect(ctx.isSubscribed(nonMatchingListener)).to.false(); + expect(ctx.isSubscribed(nonMatchingObserver)).to.false(); expect(subscription.closed).to.be.true(); }); - it('registers listeners on context chain', () => { + it('registers observers on context chain', () => { const childCtx = new Context(ctx, 'child'); - childCtx.subscribe(nonMatchingListener); - expect(childCtx.isSubscribed(nonMatchingListener)).to.true(); - expect(ctx.isSubscribed(nonMatchingListener)).to.true(); + childCtx.subscribe(nonMatchingObserver); + expect(childCtx.isSubscribed(nonMatchingObserver)).to.true(); + expect(ctx.isSubscribed(nonMatchingObserver)).to.true(); }); - it('un-registers listeners on context chain', () => { + it('un-registers observers on context chain', () => { const childCtx = new Context(ctx, 'child'); - childCtx.subscribe(nonMatchingListener); - expect(childCtx.isSubscribed(nonMatchingListener)).to.true(); - expect(ctx.isSubscribed(nonMatchingListener)).to.true(); - childCtx.unsubscribe(nonMatchingListener); - expect(childCtx.isSubscribed(nonMatchingListener)).to.false(); - expect(ctx.isSubscribed(nonMatchingListener)).to.false(); + childCtx.subscribe(nonMatchingObserver); + expect(childCtx.isSubscribed(nonMatchingObserver)).to.true(); + expect(ctx.isSubscribed(nonMatchingObserver)).to.true(); + childCtx.unsubscribe(nonMatchingObserver); + expect(childCtx.isSubscribed(nonMatchingObserver)).to.false(); + expect(ctx.isSubscribed(nonMatchingObserver)).to.false(); }); - function givenNonMatchingListener() { - nonMatchingListener = { + function givenNonMatchingObserver() { + nonMatchingObserver = { filter: binding => false, - listen: (event, binding) => {}, + observe: (event, binding) => {}, }; } }); describe('event notification', () => { const events: string[] = []; - let nonMatchingListenerCalled = false; + let nonMatchingObserverCalled = false; - beforeEach(givenListeners); + beforeEach(givenObservers); - it('emits one bind event to matching listeners', async () => { + it('emits one bind event to matching observers', async () => { ctx.bind('foo').to('foo-value'); - await ctx.waitUntilEventsProcessed(); + await ctx.waitUntilObserversNotified(); expect(events).to.eql(['1:foo:foo-value:bind', '2:foo:foo-value:bind']); - expect(nonMatchingListenerCalled).to.be.false(); + expect(nonMatchingObserverCalled).to.be.false(); }); - it('emits multiple bind events to matching listeners', async () => { + it('emits multiple bind events to matching observers', async () => { ctx.bind('foo').to('foo-value'); ctx.bind('xyz').to('xyz-value'); - await ctx.waitUntilEventsProcessed(); + await ctx.waitUntilObserversNotified(); expect(events).to.eql([ '1:foo:foo-value:bind', '2:foo:foo-value:bind', @@ -778,76 +778,76 @@ describe('Context', () => { ]); }); - it('emits unbind event to matching listeners', async () => { + it('emits unbind event to matching observers', async () => { ctx.bind('foo').to('foo-value'); - await ctx.waitUntilEventsProcessed(); + await ctx.waitUntilObserversNotified(); ctx.unbind('foo'); - await ctx.waitUntilEventsProcessed(); + await ctx.waitUntilObserversNotified(); expect(events).to.eql([ '1:foo:foo-value:bind', '2:foo:foo-value:bind', '1:foo:foo-value:unbind', '2:foo:foo-value:unbind', ]); - expect(nonMatchingListenerCalled).to.be.false(); + expect(nonMatchingObserverCalled).to.be.false(); }); - it('does not trigger listeners if affected binding is the same', async () => { + it('does not trigger observers if affected binding is the same', async () => { const binding = ctx.bind('foo').to('foo-value'); - await ctx.waitUntilEventsProcessed(); + await ctx.waitUntilObserversNotified(); expect(events).to.eql(['1:foo:foo-value:bind', '2:foo:foo-value:bind']); ctx.add(binding); - await ctx.waitUntilEventsProcessed(); + await ctx.waitUntilObserversNotified(); expect(events).to.eql(['1:foo:foo-value:bind', '2:foo:foo-value:bind']); }); - it('reports error if a listener fails', () => { + it('reports error if an observer fails', () => { ctx.bind('bar').to('bar-value'); - return expect(ctx.waitUntilEventsProcessed()).to.be.rejectedWith( + return expect(ctx.waitUntilObserversNotified()).to.be.rejectedWith( 'something wrong', ); }); - function givenListeners() { - nonMatchingListenerCalled = false; + function givenObservers() { + nonMatchingObserverCalled = false; events.splice(0, events.length); - // A listener does not match the criteria - const nonMatchingListener: ContextEventListener = { + // An observer does not match the criteria + const nonMatchingObserver: ContextObserver = { filter: binding => false, - listen: (event, binding) => { - nonMatchingListenerCalled = true; + observe: (event, binding) => { + nonMatchingObserverCalled = true; }, }; - // A sync listener matches the criteria - const matchingListener: ContextEventListener = { - listen: (event, binding, context) => { + // A sync observer matches the criteria + const matchingObserver: ContextObserver = { + observe: (event, binding, context) => { // Make sure the binding is configured with value - // when the listener is notified + // when the observer is notified const val = binding.getValue(context); events.push(`1:${binding.key}:${val}:${event}`); }, }; - // An async listener matches the criteria - const matchingAsyncListener: ContextEventListener = { + // An async observer matches the criteria + const matchingAsyncObserver: ContextObserver = { filter: binding => true, - listen: async (event, binding, context) => { + observe: async (event, binding, context) => { await setImmediateAsync(); const val = binding.getValue(context); events.push(`2:${binding.key}:${val}:${event}`); }, }; - // An async listener matches the criteria that throws an error - const matchingAsyncListenerWithError: ContextEventListener = { + // An async observer matches the criteria that throws an error + const matchingAsyncObserverWithError: ContextObserver = { filter: binding => binding.key === 'bar', - listen: async () => { + observe: async () => { await setImmediateAsync(); throw new Error('something wrong'); }, }; - ctx.subscribe(nonMatchingListener); - ctx.subscribe(matchingListener); - ctx.subscribe(matchingAsyncListener); - ctx.subscribe(matchingAsyncListenerWithError); + ctx.subscribe(nonMatchingObserver); + ctx.subscribe(matchingObserver); + ctx.subscribe(matchingAsyncObserver); + ctx.subscribe(matchingAsyncObserverWithError); } }); From c2668fc85cbfbfb973e70621b74863a86f69b6e0 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Thu, 31 Jan 2019 09:48:09 -0800 Subject: [PATCH 6/8] chore(context): use p-event as the notification queue --- packages/context/package.json | 2 +- packages/context/src/context.ts | 89 ++++++++++++---------- packages/context/test/unit/context.unit.ts | 19 +---- 3 files changed, 54 insertions(+), 56 deletions(-) diff --git a/packages/context/package.json b/packages/context/package.json index 4d0db7153473..accfc379c8d7 100644 --- a/packages/context/package.json +++ b/packages/context/package.json @@ -21,7 +21,7 @@ "dependencies": { "@loopback/metadata": "^1.0.5", "debug": "^4.0.1", - "queue": "^5.0.0", + "p-event": "^2.2.0", "uuid": "^3.2.1" }, "devDependencies": { diff --git a/packages/context/src/context.ts b/packages/context/src/context.ts index 53d3d411c2e9..dc39cdff69e7 100644 --- a/packages/context/src/context.ts +++ b/packages/context/src/context.ts @@ -5,20 +5,22 @@ import * as debugModule from 'debug'; import {EventEmitter} from 'events'; -import Queue from 'queue'; import {v1 as uuidv1} from 'uuid'; import {ValueOrPromise} from '.'; import {Binding, BindingTag} from './binding'; import {BindingFilter, filterByKey, filterByTag} from './binding-filter'; import {BindingAddress, BindingKey} from './binding-key'; import { - ContextObserver, ContextEventType, + ContextObserver, Subscription, } from './context-observer'; import {ResolutionOptions, ResolutionSession} from './resolution-session'; import {BoundValue, getDeepProperty, isPromiseLike} from './value-promise'; +// FIXME: `@types/p-event` is out of date against `p-event@2.2.0` +const pEvent = require('p-event'); + const debug = debugModule('loopback:context'); /** @@ -46,9 +48,9 @@ export class Context extends EventEmitter { protected readonly observers: Set = new Set(); /** - * Queue for context event notifications + * Internal counter for pending events which observers have not processed yet */ - protected readonly eventQueue = new Queue({concurrency: 1, autostart: true}); + private pendingEvents = 0; /** * Create a new context. For example, @@ -87,22 +89,44 @@ export class Context extends EventEmitter { * upon `bind` and `unbind` events */ private setupEventHandlers() { - for (const event of ['bind', 'unbind']) { - // Listen on events and notify observers - this.on(event, (binding: Readonly>) => { - this.notifyObservers(event, binding); - }); - } - // Relay events from the event queue - this.eventQueue.on('error', err => { - this.emit('error', err); + // Ideally p-event should allow multiple event types in an iterator + this.observeEvent('bind'); + this.observeEvent('unbind'); + } + + /** + * Listen on context events and notify observers + * @param eventType Context event type + */ + private async observeEvent(eventType: ContextEventType) { + this.on(eventType, () => { + // Track pending events + this.pendingEvents++; }); - this.eventQueue.on('end', err => { - if (err) this.emit('error', err); - else { - this.emit('observersNotified'); + // Create an async iterator from the given event type + const bindings: AsyncIterable>> = pEvent.iterator( + this, + eventType, + ); + for await (const binding of bindings) { + try { + await this.notifyObservers(eventType, binding); + this.pendingEvents--; + this.emit('idle'); + } catch (err) { + this.pendingEvents--; + this.emit('error', err); } - }); + } + } + + /** + * Wait until event notification is idle + */ + protected async waitForIdle() { + const count = this.pendingEvents; + if (count === 0) return; + await pEvent.multiple(this, 'idle', {count}); } /** @@ -213,32 +237,17 @@ export class Context extends EventEmitter { * @param eventType Event names: `bind` or `unbind` * @param binding Binding bound or unbound */ - protected notifyObservers( + protected async notifyObservers( eventType: ContextEventType, binding: Readonly>, ) { if (this.observers.size === 0) return; - // Schedule the notification task into the event queue - const task = () => { - return new Promise((resolve, reject) => { - // Run notifications in nextTick so that the binding is fully populated - process.nextTick(async () => { - for (const observer of this.observers) { - if (!observer.filter || observer.filter(binding)) { - try { - await observer.observe(eventType, binding, this); - } catch (err) { - debug(err, eventType, binding); - reject(err); - return; - } - } - } - resolve(); - }); - }); - }; - this.eventQueue.push(task); + + for (const observer of this.observers) { + if (!observer.filter || observer.filter(binding)) { + await observer.observe(eventType, binding, this); + } + } } /** diff --git a/packages/context/test/unit/context.unit.ts b/packages/context/test/unit/context.unit.ts index 53838a3225c9..7985c1257e6f 100644 --- a/packages/context/test/unit/context.unit.ts +++ b/packages/context/test/unit/context.unit.ts @@ -4,6 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {expect} from '@loopback/testlab'; +import {promisify} from 'util'; import { Binding, BindingKey, @@ -14,7 +15,6 @@ import { isPromiseLike, } from '../..'; -import {promisify} from 'util'; const setImmediateAsync = promisify(setImmediate); /** @@ -29,23 +29,12 @@ class TestContext extends Context { const map = new Map(this.registry); return map; } + /** * Wait until the context event queue is empty or an error is thrown */ - waitUntilObserversNotified() { - return new Promise((resolve, reject) => { - if (this.eventQueue.length === 0) { - resolve(); - return; - } - this.once('observersNotified', err => { - if (err) reject(err); - else resolve(); - }); - this.once('error', err => { - reject(err); - }); - }); + waitUntilObserversNotified(): Promise { + return this.waitForIdle(); } } From df0723214db853ed32ede5e0a0300fd6a31997e3 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Fri, 25 Jan 2019 07:58:08 -0800 Subject: [PATCH 7/8] feat(context): introduce context view to watch bindings by filter --- packages/context/src/context-view.ts | 131 +++++++++++++ packages/context/src/context.ts | 11 ++ packages/context/src/index.ts | 1 + .../acceptance/context-view.acceptance.ts | 185 ++++++++++++++++++ .../context/test/unit/context-view.unit.ts | 116 +++++++++++ 5 files changed, 444 insertions(+) create mode 100644 packages/context/src/context-view.ts create mode 100644 packages/context/test/acceptance/context-view.acceptance.ts create mode 100644 packages/context/test/unit/context-view.unit.ts diff --git a/packages/context/src/context-view.ts b/packages/context/src/context-view.ts new file mode 100644 index 000000000000..771bfb0e12e5 --- /dev/null +++ b/packages/context/src/context-view.ts @@ -0,0 +1,131 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/context +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import * as debugFactory from 'debug'; +import {promisify} from 'util'; +import {Binding} from './binding'; +import {BindingFilter} from './binding-filter'; +import {Context} from './context'; +import { + ContextObserver, + ContextEventType, + Subscription, +} from './context-observer'; +import {Getter} from './inject'; +import {ResolutionSession} from './resolution-session'; +import {resolveList, ValueOrPromise} from './value-promise'; +const debug = debugFactory('loopback:context:view'); +const nextTick = promisify(process.nextTick); + +/** + * `ContextView` provides a view for a given context chain to maintain a live + * list of matching bindings and their resolved values within the context + * hierarchy. + * + * This class is the key utility to implement dynamic extensions for extension + * points. For example, the RestServer can react to `controller` bindings even + * they are added/removed/updated after the application starts. + * + */ +export class ContextView implements ContextObserver { + protected _cachedBindings: Readonly>[] | undefined; + protected _cachedValues: T[] | undefined; + private _subscription: Subscription | undefined; + + constructor( + protected readonly context: Context, + public readonly filter: BindingFilter, + ) {} + + /** + * Start listening events from the context + */ + open() { + debug('Start listening on changes of context %s', this.context.name); + return (this._subscription = this.context.subscribe(this)); + } + + /** + * Stop listening events from the context + */ + close() { + debug('Stop listening on changes of context %s', this.context.name); + if (!this._subscription || this._subscription.closed) return; + this._subscription.unsubscribe(); + this._subscription = undefined; + } + + /** + * Get the list of matched bindings. If they are not cached, it tries to find + * them from the context. + */ + get bindings(): Readonly>[] { + debug('Reading bindings'); + if (this._cachedBindings == null) { + this._cachedBindings = this.findBindings(); + } + return this._cachedBindings; + } + + /** + * Find matching bindings and refresh the cache + */ + protected findBindings() { + debug('Finding matching bindings'); + this._cachedBindings = this.context.find(this.filter); + return this._cachedBindings; + } + + /** + * Listen on `bind` or `unbind` and invalidate the cache + */ + observe(event: ContextEventType, binding: Readonly>) { + this.reset(); + } + + /** + * Reset the view by invalidating its cache + */ + reset() { + debug('Invalidating cache'); + this._cachedBindings = undefined; + this._cachedValues = undefined; + } + + /** + * Resolve values for the matching bindings + * @param session Resolution session + */ + resolve(session?: ResolutionSession): ValueOrPromise { + debug('Resolving values'); + // We don't cache values with this method as it returns `ValueOrPromise` + // for inject `resolve` function to allow `getSync` but `this._cachedValues` + // expects `T[]` + return resolveList(this.bindings, b => { + return b.getValue(this.context, ResolutionSession.fork(session)); + }); + } + + /** + * Get the list of resolved values. If they are not cached, it tries to find + * and resolve them. + */ + async values(): Promise { + debug('Reading values'); + // Wait for the next tick so that context event notification can be emitted + await nextTick(); + if (this._cachedValues == null) { + this._cachedValues = await this.resolve(); + } + return this._cachedValues; + } + + /** + * As a `Getter` function + */ + asGetter(): Getter { + return () => this.values(); + } +} diff --git a/packages/context/src/context.ts b/packages/context/src/context.ts index dc39cdff69e7..aea240670574 100644 --- a/packages/context/src/context.ts +++ b/packages/context/src/context.ts @@ -10,6 +10,7 @@ import {ValueOrPromise} from '.'; import {Binding, BindingTag} from './binding'; import {BindingFilter, filterByKey, filterByTag} from './binding-filter'; import {BindingAddress, BindingKey} from './binding-key'; +import {ContextView} from './context-view'; import { ContextEventType, ContextObserver, @@ -228,6 +229,16 @@ export class Context extends EventEmitter { return this.observers.has(observer); } + /** + * Create a view of the context chain with the given binding filter + * @param filter A function to match bindings + */ + createView(filter: BindingFilter) { + const view = new ContextView(this, filter); + view.open(); + return view; + } + /** * Publish an event to the registered observers. Please note the * notification is queued and performed asynchronously so that we allow fluent diff --git a/packages/context/src/index.ts b/packages/context/src/index.ts index 0b204e662562..106924e0b991 100644 --- a/packages/context/src/index.ts +++ b/packages/context/src/index.ts @@ -11,6 +11,7 @@ export * from './binding-key'; export * from './binding-filter'; export * from './context'; export * from './context-observer'; +export * from './context-view'; export * from './inject'; export * from './keys'; export * from './provider'; diff --git a/packages/context/test/acceptance/context-view.acceptance.ts b/packages/context/test/acceptance/context-view.acceptance.ts new file mode 100644 index 000000000000..d199d879abb1 --- /dev/null +++ b/packages/context/test/acceptance/context-view.acceptance.ts @@ -0,0 +1,185 @@ +import {expect} from '@loopback/testlab'; +import { + Binding, + filterByTag, + Context, + ContextObserver, + ContextEventType, + ContextView, + Getter, + inject, +} from '../..'; + +let app: Context; +let server: Context; + +describe('ContextView', () => { + let contextView: ContextView; + beforeEach(givenViewForControllers); + + it('watches matching bindings', async () => { + // We have server: 1, app: 2 + expect(await getControllers()).to.eql(['1', '2']); + server.unbind('controllers.1'); + // Now we have app: 2 + expect(await getControllers()).to.eql(['2']); + app.unbind('controllers.2'); + // All controllers are gone from the context chain + expect(await getControllers()).to.eql([]); + // Add a new controller - server: 3 + givenController(server, '3'); + expect(await getControllers()).to.eql(['3']); + }); + + function givenViewForControllers() { + givenServerWithinAnApp(); + contextView = server.createView(filterByTag('controller')); + givenController(server, '1'); + givenController(app, '2'); + } + + function givenController(_ctx: Context, _name: string) { + class MyController { + name = _name; + } + _ctx + .bind(`controllers.${_name}`) + .toClass(MyController) + .tag('controller'); + } + + async function getControllers() { + // tslint:disable-next-line:no-any + return (await contextView.values()).map((v: any) => v.name); + } +}); + +describe('@inject.* - injects a live collection of matching bindings', async () => { + beforeEach(givenPrimeNumbers); + + class MyControllerWithGetter { + @inject.getter(filterByTag('prime')) + getter: Getter; + } + + class MyControllerWithValues { + constructor( + @inject(filterByTag('prime')) + public values: number[], + ) {} + } + + class MyControllerWithView { + @inject.view(filterByTag('prime')) + view: ContextView; + } + + it('injects as getter', async () => { + server.bind('my-controller').toClass(MyControllerWithGetter); + const inst = await server.get('my-controller'); + const getter = inst.getter; + expect(await getter()).to.eql([3, 5]); + // Add a new binding that matches the filter + givenPrime(server, 7); + // The getter picks up the new binding + expect(await getter()).to.eql([3, 7, 5]); + }); + + it('injects as values', async () => { + server.bind('my-controller').toClass(MyControllerWithValues); + const inst = await server.get('my-controller'); + expect(inst.values).to.eql([3, 5]); + }); + + it('injects as a view', async () => { + server.bind('my-controller').toClass(MyControllerWithView); + const inst = await server.get('my-controller'); + const view = inst.view; + expect(await view.values()).to.eql([3, 5]); + // Add a new binding that matches the filter + // Add a new binding that matches the filter + givenPrime(server, 7); + // The view picks up the new binding + expect(await view.values()).to.eql([3, 7, 5]); + server.unbind('prime.7'); + expect(await view.values()).to.eql([3, 5]); + }); + + function givenPrimeNumbers() { + givenServerWithinAnApp(); + givenPrime(server, 3); + givenPrime(app, 5); + } + + function givenPrime(ctx: Context, p: number) { + ctx + .bind(`prime.${p}`) + .to(p) + .tag('prime'); + } +}); + +describe('ContextEventListener', () => { + let contextListener: MyListenerForControllers; + beforeEach(givenControllerListener); + + it('receives notifications of matching binding events', async () => { + const controllers = await getControllers(); + // We have server: 1, app: 2 + // NOTE: The controllers are not guaranteed to be ['1', '2'] as the events + // are emitted by two context objects and they are processed asynchronously + expect(controllers).to.containEql('1'); + expect(controllers).to.containEql('2'); + server.unbind('controllers.1'); + // Now we have app: 2 + expect(await getControllers()).to.eql(['2']); + app.unbind('controllers.2'); + // All controllers are gone from the context chain + expect(await getControllers()).to.eql([]); + // Add a new controller - server: 3 + givenController(server, '3'); + expect(await getControllers()).to.eql(['3']); + }); + + class MyListenerForControllers implements ContextObserver { + controllers: Set = new Set(); + filter = filterByTag('controller'); + observe(event: ContextEventType, binding: Readonly>) { + if (event === 'bind') { + this.controllers.add(binding.tagMap.name); + } else if (event === 'unbind') { + this.controllers.delete(binding.tagMap.name); + } + } + } + + function givenControllerListener() { + givenServerWithinAnApp(); + contextListener = new MyListenerForControllers(); + server.subscribe(contextListener); + givenController(server, '1'); + givenController(app, '2'); + } + + function givenController(ctx: Context, controllerName: string) { + class MyController { + name = controllerName; + } + ctx + .bind(`controllers.${controllerName}`) + .toClass(MyController) + .tag('controller', {name: controllerName}); + } + + async function getControllers() { + return new Promise(resolve => { + // Wrap it inside `setImmediate` to make the events are triggered + setImmediate(() => resolve(Array.from(contextListener.controllers))); + }); + } +}); + +function givenServerWithinAnApp() { + app = new Context('app'); + server = new Context(app, 'server'); +} diff --git a/packages/context/test/unit/context-view.unit.ts b/packages/context/test/unit/context-view.unit.ts new file mode 100644 index 000000000000..801feac0aa87 --- /dev/null +++ b/packages/context/test/unit/context-view.unit.ts @@ -0,0 +1,116 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/context +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {Binding, BindingScope, filterByTag, Context, ContextView} from '../..'; + +describe('ContextView', () => { + let app: Context; + let server: Context; + + let bindings: Binding[]; + let contextView: ContextView; + + beforeEach(givenContextView); + + it('tracks bindings', () => { + expect(contextView.bindings).to.eql(bindings); + }); + + it('resolves bindings', async () => { + expect(await contextView.resolve()).to.eql(['BAR', 'FOO']); + expect(await contextView.values()).to.eql(['BAR', 'FOO']); + }); + + it('resolves bindings as a getter', async () => { + expect(await contextView.asGetter()()).to.eql(['BAR', 'FOO']); + }); + + it('reloads bindings after reset', async () => { + contextView.reset(); + const abcBinding = server + .bind('abc') + .to('ABC') + .tag('abc'); + const xyzBinding = server + .bind('xyz') + .to('XYZ') + .tag('foo'); + expect(contextView.bindings).to.containEql(xyzBinding); + // `abc` does not have the matching tag + expect(contextView.bindings).to.not.containEql(abcBinding); + expect(await contextView.values()).to.eql(['BAR', 'XYZ', 'FOO']); + }); + + it('reloads bindings if context bindings are added', async () => { + const abcBinding = server + .bind('abc') + .to('ABC') + .tag('abc'); + const xyzBinding = server + .bind('xyz') + .to('XYZ') + .tag('foo'); + expect(contextView.bindings).to.containEql(xyzBinding); + // `abc` does not have the matching tag + expect(contextView.bindings).to.not.containEql(abcBinding); + expect(await contextView.values()).to.eql(['BAR', 'XYZ', 'FOO']); + }); + + it('reloads bindings if context bindings are removed', async () => { + server.unbind('bar'); + expect(await contextView.values()).to.eql(['FOO']); + }); + + it('reloads bindings if context bindings are rebound', async () => { + server.bind('bar').to('BAR'); // No more tagged with `foo` + expect(await contextView.values()).to.eql(['FOO']); + }); + + it('reloads bindings if parent context bindings are added', async () => { + const xyzBinding = app + .bind('xyz') + .to('XYZ') + .tag('foo'); + expect(contextView.bindings).to.containEql(xyzBinding); + expect(await contextView.values()).to.eql(['BAR', 'FOO', 'XYZ']); + }); + + it('reloads bindings if parent context bindings are removed', async () => { + app.unbind('foo'); + expect(await contextView.values()).to.eql(['BAR']); + }); + + it('stops watching', async () => { + expect(await contextView.values()).to.eql(['BAR', 'FOO']); + contextView.close(); + app.unbind('foo'); + expect(await contextView.values()).to.eql(['BAR', 'FOO']); + }); + + function givenContextView() { + bindings = []; + givenContext(); + contextView = server.createView(filterByTag('foo')); + } + + function givenContext() { + app = new Context('app'); + server = new Context(app, 'server'); + bindings.push( + server + .bind('bar') + .toDynamicValue(() => Promise.resolve('BAR')) + .tag('foo', 'bar') + .inScope(BindingScope.SINGLETON), + ); + bindings.push( + app + .bind('foo') + .to('FOO') + .tag('foo', 'bar'), + ); + } +}); From d03b09bdbe9b819ff632fe4e34f23beb9e70587a Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Fri, 25 Jan 2019 07:59:31 -0800 Subject: [PATCH 8/8] feat(context): add @inject.view and extend @inject for multiple bindings --- docs/site/Context.md | 71 ++++++ docs/site/Decorators_inject.md | 94 +++++++- docs/site/Dependency-injection.md | 14 +- packages/context/src/inject.ts | 205 +++++++++++++++--- packages/context/src/resolution-session.ts | 2 +- packages/context/src/resolver.ts | 11 +- .../class-level-bindings.acceptance.ts | 26 +++ .../context/test/unit/inject-view.unit.ts | 160 ++++++++++++++ packages/context/test/unit/inject.unit.ts | 24 +- .../test/unit/resolution-session.unit.ts | 2 +- packages/context/test/unit/resolver.unit.ts | 13 +- 11 files changed, 558 insertions(+), 64 deletions(-) create mode 100644 packages/context/test/unit/inject-view.unit.ts diff --git a/docs/site/Context.md b/docs/site/Context.md index b038f747ef41..92d8e8a1131a 100644 --- a/docs/site/Context.md +++ b/docs/site/Context.md @@ -229,3 +229,74 @@ class HelloController { These "sugar" decorators allow you to quickly build up your application without having to code up all the additional logic by simply giving LoopBack hints (in the form of metadata) to your intent. + +## Context view + +Bindings in a context can come and go. It's often desirable for an artifact +(especially an extension point) to keep track of other artifacts (extensions). +For example, the `RestServer` needs to know routes contributed by `controller` +classes or other handlers. Such routes can be added or removed after the +`RestServer` starts. When a controller is added after the application starts, +new routes are bound into the application context. Ideally, the `RestServer` +should be able to pick up these new routes without restarting. + +To support the dynamic tracking of such artifacts registered within a context +chain, we introduce `ContextEventListener` interface and `ContextView` class +that can be used to watch a list of bindings matching certain criteria depicted +by a `BindingFilter` function. + +```ts +import {Context, ContextView} from '@loopback/context'; + +// Set up a context chain +const appCtx = new Context('app'); +const serverCtx = new Context(appCtx, 'server'); // server -> app + +// Define a binding filter to select bindings with tag `controller` +const controllerFilter = binding => binding.tagMap.controller != null; + +// Watch for bindings with tag `controller` +const view = serverCtx.watch(controllerFilter); + +// No controllers yet +await view.values(); // returns [] + +// Bind Controller1 to server context +serverCtx + .bind('controllers.Controller1') + .toClass(Controller1) + .tag('controller'); + +// Resolve to an instance of Controller1 +await view.values(); // returns [an instance of Controller1]; + +// Bind Controller2 to app context +appCtx + .bind('controllers.Controller2') + .toClass(Controller2) + .tag('controller'); + +// Resolve to an instance of Controller1 and an instance of Controller2 +await view.values(); // returns [an instance of Controller1, an instance of Controller2]; + +// Unbind Controller2 +appCtx.unbind('controllers.Controller2'); + +// No more instance of Controller2 +await view.values(); // returns [an instance of Controller1]; +``` + +The key benefit of `ContextView` is that it caches resolved values until context +bindings matching the filter function are added/removed. For most cases, we +don't have to pay the penalty to find/resolve per request. + +To fully leverage the live list of extensions, an extension point such as +`RoutingTable` should either keep a pointer to an instance of `ContextView` +corresponding to all `routes` (extensions) in the context chain and use the +`values()` function to match again the live `routes` per request or implement +itself as a `ContextEventListener` to rebuild the routes upon changes of routes +in the context with `listen()`. + +If your dependency needs to follow the context for values from bindings matching +a filter, use [`@inject.view`](Decorators_inject.md#@inject.view) for dependency +injection. diff --git a/docs/site/Decorators_inject.md b/docs/site/Decorators_inject.md index 928441583fb3..e87131f13306 100644 --- a/docs/site/Decorators_inject.md +++ b/docs/site/Decorators_inject.md @@ -6,7 +6,12 @@ sidebar: lb4_sidebar permalink: /doc/en/lb4/Decorators_inject.html --- -## Dependency Injection Decorator +## Dependency Injection Decorators + +### @inject + +Syntax: +`@inject(bindingSelector: BindingSelector, metadata?: InjectionMetadata)`. `@inject` is a decorator to annotate class properties or constructor arguments for automatic injection by LoopBack's IoC container. @@ -65,13 +70,28 @@ export class WidgetController { } ``` +The `@inject` decorator now also accepts a binding filter function so that an +array of values can be injected. If the target type is not `Array`, an error +will be thrown. + +```ts +class MyControllerWithValues { + constructor( + @inject(binding => binding.tagNames.includes('foo')) + public values: string[], + ) {} +} +``` + A few variants of `@inject` are provided to declare special forms of -dependencies: +dependencies. + +### @inject.getter -- `@inject.getter`: inject a getter function that returns a promise of the bound - value of the key +`@inject.getter` injects a getter function that returns a promise of the bound +value of the key. -Syntax: `@inject.getter(bindingKey: string)`. +Syntax: `@inject.getter(bindingSelector: BindingSelector)`. ```ts import {inject, Getter} from '@loopback/context'; @@ -92,7 +112,19 @@ export class HelloController { } ``` -- `@inject.setter`: inject a setter function to set the bound value of the key +`@inject.getter` also allows the getter function to return an array of values +from bindings that match a filter function. + +```ts +class MyControllerWithGetter { + @inject.getter(filterByTag('prime')) + getter: Getter; +} +``` + +### @inject.setter + +`@inject.setter` injects a setter function to set the bound value of the key. Syntax: `@inject.setter(bindingKey: string)`. @@ -111,10 +143,12 @@ export class HelloController { } ``` -- `@inject.tag`: inject an array of values by a pattern or regexp to match - binding tags +### @inject.tag + +`@inject.tag` injects an array of values by a pattern or regexp to match binding +tags. -Syntax: `@inject.tag(tag: string | RegExp)`. +Syntax: `@inject.tag(tag: BindingTag | RegExp)`. ```ts class Store { @@ -135,7 +169,47 @@ const store = ctx.getSync('store'); console.log(store.locations); // ['San Francisco', 'San Jose'] ``` -- `@inject.context`: inject the current context +### @inject.view + +`@inject.view` injects a `ContextView` to track a list of bound values matching +a filter function. + +```ts +import {inject} from '@loopback/context'; +import {DataSource} from '@loopback/repository'; + +export class DataSourceTracker { + constructor( + @inject.view(filterByTag('datasource')) + private dataSources: ContextView, + ) {} + + async listDataSources(): Promise { + // Use the Getter function to resolve data source instances + return await this.dataSources.values(); + } +} +``` + +In the example above, `filterByTag` is a helper function that creates a filter +function that matches a given tag. You can define your own filter functions, +such as: + +```ts +export class DataSourceTracker { + constructor( + @inject.view(binding => binding.tagNames.includes('datasource')) + private dataSources: ContextView, + ) {} +} +``` + +The `@inject.view` decorator takes a `BindingFilter` function. It can only be +applied to a property or method parameter of `ContextView` type. + +### @inject.context + +`@inject.context` injects the current context. Syntax: `@inject.context()`. diff --git a/docs/site/Dependency-injection.md b/docs/site/Dependency-injection.md index 1bbd339438a1..fa40c738dbc1 100644 --- a/docs/site/Dependency-injection.md +++ b/docs/site/Dependency-injection.md @@ -50,7 +50,7 @@ important when testing code interacting with external services like a database or an OAuth2 provider. Instead of making expensive network requests, the test can provide a lightweight implementation returning pre-defined responses. -## Configuring what to inject +## Configure what to inject Now that we write a class that gets the dependencies injected, you are probably wondering where are these values going to be injected from and how to configure @@ -239,6 +239,18 @@ export class MyController { } ``` +## Additional `inject.*` decorators + +There are a few special decorators from the `inject` namespace. + +- [`@inject.getter`](Decorators_inject.md#@inject.getter) +- [`@inject.setter`](Decorators_inject.md#@inject.setter) +- [`@inject.context`](Decorators_inject.md#@inject.context) +- [`@inject.tag`](Decorators_inject.md#@inject.tag) +- [`@inject.view`](Decorators_inject.md#@inject.view) + +See [Inject decorators](Decorators_inject.md) for more details. + ## Circular dependencies LoopBack can detect circular dependencies and report the path which leads to the diff --git a/packages/context/src/inject.ts b/packages/context/src/inject.ts index b9f2173b137b..6af664ae81ed 100644 --- a/packages/context/src/inject.ts +++ b/packages/context/src/inject.ts @@ -4,18 +4,21 @@ // License text available at https://opensource.org/licenses/MIT import { - MetadataInspector, DecoratorFactory, + InspectionOptions, + MetadataAccessor, + MetadataInspector, + MetadataMap, ParameterDecoratorFactory, PropertyDecoratorFactory, - MetadataMap, - MetadataAccessor, - InspectionOptions, } from '@loopback/metadata'; -import {BoundValue, ValueOrPromise, resolveList} from './value-promise'; +import {BindingTag} from './binding'; +import {BindingFilter, filterByTag} from './binding-filter'; +import {BindingAddress} from './binding-key'; import {Context} from './context'; -import {BindingKey, BindingAddress} from './binding-key'; +import {ContextView} from './context-view'; import {ResolutionSession} from './resolution-session'; +import {BoundValue, ValueOrPromise} from './value-promise'; const PARAMETERS_KEY = MetadataAccessor.create( 'inject:parameters', @@ -54,6 +57,13 @@ export interface InjectionMetadata { [attribute: string]: BoundValue; } +/** + * Select binding(s) by key or a filter function + */ +export type BindingSelector = + | BindingAddress + | BindingFilter; + /** * Descriptor for an injection point */ @@ -64,7 +74,7 @@ export interface Injection { | TypedPropertyDescriptor | number; - bindingKey: BindingAddress; // Binding key + bindingSelector: BindingSelector; // Binding selector metadata?: InjectionMetadata; // Related metadata resolve?: ResolverFunction; // A custom resolve function } @@ -89,17 +99,20 @@ export interface Injection { * * - TODO(bajtos) * - * @param bindingKey What binding to use in order to resolve the value of the + * @param bindingSelector What binding to use in order to resolve the value of the * decorated constructor parameter or property. * @param metadata Optional metadata to help the injection * @param resolve Optional function to resolve the injection * */ export function inject( - bindingKey: string | BindingKey, + bindingSelector: BindingSelector, metadata?: InjectionMetadata, resolve?: ResolverFunction, ) { + if (typeof bindingSelector === 'function' && !resolve) { + resolve = resolveValuesByFilter; + } metadata = Object.assign({decorator: '@inject'}, metadata); return function markParameterOrPropertyAsInjected( target: Object, @@ -119,7 +132,7 @@ export function inject( target, member, methodDescriptorOrParameterIndex, - bindingKey, + bindingSelector, metadata, resolve, }, @@ -155,7 +168,7 @@ export function inject( target, member, methodDescriptorOrParameterIndex, - bindingKey, + bindingSelector, metadata, resolve, }, @@ -175,7 +188,7 @@ export function inject( } /** - * The function injected by `@inject.getter(key)`. + * The function injected by `@inject.getter(bindingSelector)`. */ export type Getter = () => Promise; @@ -205,15 +218,16 @@ export namespace inject { * * See also `Getter`. * - * @param bindingKey The key of the value we want to eventually get. + * @param bindingSelector The binding key or filter we want to eventually get + * value(s) from. * @param metadata Optional metadata to help the injection */ export const getter = function injectGetter( - bindingKey: BindingAddress, + bindingSelector: BindingSelector, metadata?: InjectionMetadata, ) { metadata = Object.assign({decorator: '@inject.getter'}, metadata); - return inject(bindingKey, metadata, resolveAsGetter); + return inject(bindingSelector, metadata, resolveAsGetter); }; /** @@ -248,18 +262,38 @@ export namespace inject { * ) {} * } * ``` - * @param bindingTag Tag name or regex + * @param bindingTag Tag name, regex or object * @param metadata Optional metadata to help the injection */ - export const tag = function injectTag( - bindingTag: string | RegExp, + export const tag = function injectByTag( + bindingTag: BindingTag | RegExp, metadata?: InjectionMetadata, ) { metadata = Object.assign( {decorator: '@inject.tag', tag: bindingTag}, metadata, ); - return inject('', metadata, resolveByTag); + return inject(filterByTag(bindingTag), metadata); + }; + + /** + * Inject matching bound values by the filter function + * + * ```ts + * class MyControllerWithView { + * @inject.view(filterByTag('foo')) + * view: ContextView; + * } + * ``` + * @param bindingFilter A binding filter function + * @param metadata + */ + export const view = function injectByFilter( + bindingFilter: BindingFilter, + metadata?: InjectionMetadata, + ) { + metadata = Object.assign({decorator: '@inject.view'}, metadata); + return inject(bindingFilter, metadata, resolveAsContextView); }; /** @@ -277,15 +311,33 @@ export namespace inject { }; } +function isBindingAddress( + bindingSelector: BindingSelector, +): bindingSelector is BindingAddress { + return typeof bindingSelector !== 'function'; +} + function resolveAsGetter( ctx: Context, injection: Readonly, session?: ResolutionSession, ) { + const targetType = inspectTargetType(injection); + if (targetType && targetType !== Function) { + const targetName = ResolutionSession.describeInjection(injection)! + .targetName; + throw new Error( + `The type of ${targetName} (${targetType.name}) is not a Getter function`, + ); + } + const bindingSelector = injection.bindingSelector; + if (!isBindingAddress(bindingSelector)) { + return resolveAsGetterByFilter(ctx, injection, session); + } // We need to clone the session for the getter as it will be resolved later session = ResolutionSession.fork(session); return function getter() { - return ctx.get(injection.bindingKey, { + return ctx.get(bindingSelector, { session, optional: injection.metadata && injection.metadata.optional, }); @@ -293,9 +345,22 @@ function resolveAsGetter( } function resolveAsSetter(ctx: Context, injection: Injection) { + const targetType = inspectTargetType(injection); + const targetName = ResolutionSession.describeInjection(injection)!.targetName; + if (targetType && targetType !== Function) { + throw new Error( + `The type of ${targetName} (${targetType.name}) is not a Setter function`, + ); + } + const bindingSelector = injection.bindingSelector; + if (!isBindingAddress(bindingSelector)) { + throw new Error( + `@inject.setter for (${targetType.name}) does not allow BindingFilter`, + ); + } // No resolution session should be propagated into the setter - return function setter(value: BoundValue) { - ctx.bind(injection.bindingKey).to(value); + return function setter(value: unknown) { + ctx.bind(bindingSelector).to(value); }; } @@ -331,19 +396,99 @@ export function describeInjectedArguments( return meta || []; } -function resolveByTag( +/** + * Inspect the target type + * @param injection + */ +function inspectTargetType(injection: Readonly) { + let type = MetadataInspector.getDesignTypeForProperty( + injection.target, + injection.member!, + ); + if (type) { + return type; + } + const designType = MetadataInspector.getDesignTypeForMethod( + injection.target, + injection.member!, + ); + type = + designType.parameterTypes[ + injection.methodDescriptorOrParameterIndex as number + ]; + return type; +} + +/** + * Resolve an array of bound values matching the filter function for `@inject`. + * @param ctx Context object + * @param injection Injection information + * @param session Resolution session + */ +function resolveValuesByFilter( + ctx: Context, + injection: Readonly, + session?: ResolutionSession, +) { + const targetType = inspectTargetType(injection); + + if (targetType !== Array) { + const targetName = ResolutionSession.describeInjection(injection)! + .targetName; + throw new Error( + `The type of ${targetName} (${targetType.name}) is not Array`, + ); + } + + const bindingFilter = injection.bindingSelector as BindingFilter; + const view = new ContextView(ctx, bindingFilter); + return view.resolve(session); +} + +/** + * Resolve to a getter function that returns an array of bound values matching + * the filter function for `@inject.getter`. + * + * @param ctx Context object + * @param injection Injection information + * @param session Resolution session + */ +function resolveAsGetterByFilter( + ctx: Context, + injection: Readonly, + session?: ResolutionSession, +) { + const bindingFilter = injection.bindingSelector as BindingFilter; + const view = new ContextView(ctx, bindingFilter); + view.open(); + return view.asGetter(); +} + +/** + * Resolve to an instance of `ContextView` by the binding filter function + * for `@inject.view` + * @param ctx Context object + * @param injection Injection information + * @param session Resolution session + */ +function resolveAsContextView( ctx: Context, injection: Readonly, session?: ResolutionSession, ) { - const tag: string | RegExp = injection.metadata!.tag; - const bindings = ctx.findByTag(tag); + const targetType = inspectTargetType(injection); + if (targetType && targetType !== ContextView) { + const targetName = ResolutionSession.describeInjection(injection)! + .targetName; + throw new Error( + `The type of ${targetName} (${targetType.name}) is not ContextView`, + ); + } - return resolveList(bindings, b => { - // We need to clone the session so that resolution of multiple bindings - // can be tracked in parallel - return b.getValue(ctx, ResolutionSession.fork(session)); - }); + const bindingFilter = injection.bindingSelector as BindingFilter; + const view = new ContextView(ctx, bindingFilter); + view.open(); + return view; } /** diff --git a/packages/context/src/resolution-session.ts b/packages/context/src/resolution-session.ts index 0a682c02f5b8..c3738d3f38c9 100644 --- a/packages/context/src/resolution-session.ts +++ b/packages/context/src/resolution-session.ts @@ -169,7 +169,7 @@ export class ResolutionSession { ); return { targetName: name, - bindingKey: injection.bindingKey, + bindingKey: injection.bindingSelector, // Cast to Object so that we don't have to expose InjectionMetadata metadata: injection.metadata as Object, }; diff --git a/packages/context/src/resolver.ts b/packages/context/src/resolver.ts index e1394ecaf85f..f4a7656d6459 100644 --- a/packages/context/src/resolver.ts +++ b/packages/context/src/resolver.ts @@ -5,6 +5,7 @@ import {DecoratorFactory} from '@loopback/metadata'; import {Context} from './context'; +import {BindingAddress} from './binding-key'; import { BoundValue, Constructor, @@ -100,7 +101,8 @@ function resolve( return injection.resolve(ctx, injection, s); } else { // Default to resolve the value from the context by binding key - return ctx.getValueOrPromise(injection.bindingKey, { + const key = injection.bindingSelector as BindingAddress; + return ctx.getValueOrPromise(key, { session: s, // If the `optional` flag is set for the injection, the resolution // will return `undefined` instead of throwing an error @@ -174,7 +176,10 @@ export function resolveInjectedArguments( // The `val` argument is not used as the resolver only uses `injectedArgs` // and `extraArgs` to return the new value const injection = ix < injectedArgs.length ? injectedArgs[ix] : undefined; - if (injection == null || (!injection.bindingKey && !injection.resolve)) { + if ( + injection == null || + (!injection.bindingSelector && !injection.resolve) + ) { if (nonInjectedIndex < extraArgs.length) { // Set the argument from the non-injected list return extraArgs[nonInjectedIndex++]; @@ -265,7 +270,7 @@ export function resolveInjectedProperties( const injectedProperties = describeInjectedProperties(constructor.prototype); return resolveMap(injectedProperties, (injection, p) => { - if (!injection.bindingKey && !injection.resolve) { + if (!injection.bindingSelector && !injection.resolve) { const name = getTargetName(constructor, p); throw new Error( `Cannot resolve injected property ${name}: ` + diff --git a/packages/context/test/acceptance/class-level-bindings.acceptance.ts b/packages/context/test/acceptance/class-level-bindings.acceptance.ts index 3a195ec3b77c..e5dd06edc372 100644 --- a/packages/context/test/acceptance/class-level-bindings.acceptance.ts +++ b/packages/context/test/acceptance/class-level-bindings.acceptance.ts @@ -181,6 +181,32 @@ describe('Context bindings - Injecting dependencies of classes', () => { return expect(getter()).to.be.fulfilledWith('data'); }); + it('reports an error if @inject.getter has a non-function target', async () => { + ctx.bind('key').to('value'); + + class Store { + constructor(@inject.getter('key') public getter: string) {} + } + + ctx.bind('store').toClass(Store); + expect(() => ctx.getSync('store')).to.throw( + 'The type of Store.constructor[0] (String) is not a Getter function', + ); + }); + + it('reports an error if @inject.setter has a non-function target', async () => { + ctx.bind('key').to('value'); + + class Store { + constructor(@inject.setter('key') public setter: object) {} + } + + ctx.bind('store').toClass(Store); + expect(() => ctx.getSync('store')).to.throw( + 'The type of Store.constructor[0] (Object) is not a Setter function', + ); + }); + it('injects a nested property', async () => { class TestComponent { constructor(@inject('config#test') public config: string) {} diff --git a/packages/context/test/unit/inject-view.unit.ts b/packages/context/test/unit/inject-view.unit.ts new file mode 100644 index 000000000000..eaee9e89400c --- /dev/null +++ b/packages/context/test/unit/inject-view.unit.ts @@ -0,0 +1,160 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/context +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import { + Binding, + BindingScope, + filterByTag, + Context, + ContextView, + Getter, + inject, +} from '../..'; + +describe('@inject.view', async () => { + let ctx: Context; + beforeEach(() => { + ctx = givenContext(); + }); + + class MyControllerWithGetter { + @inject.view(filterByTag('foo')) + getter: Getter; + } + + class MyControllerWithValues { + constructor( + @inject.view(filterByTag('foo')) + public values: string[], + ) {} + } + + class MyControllerWithTracker { + @inject.view(filterByTag('foo')) + view: ContextView; + } + + it('reports error if the target type (Getter) is not ContextView', async () => { + ctx.bind('my-controller').toClass(MyControllerWithGetter); + await expect( + ctx.get('my-controller'), + ).to.be.rejectedWith( + 'The type of MyControllerWithGetter.prototype.getter (Function) is not ContextView', + ); + }); + + it('reports error if the target type (string[]) is not ContextView', async () => { + ctx.bind('my-controller').toClass(MyControllerWithValues); + await expect( + ctx.get('my-controller'), + ).to.be.rejectedWith( + 'The type of MyControllerWithValues.constructor[0] (Array) is not ContextView', + ); + }); + + it('injects as a view', async () => { + ctx.bind('my-controller').toClass(MyControllerWithTracker); + const inst = await ctx.get('my-controller'); + expect(inst.view).to.be.instanceOf(ContextView); + expect(await inst.view.values()).to.eql(['BAR', 'FOO']); + // Add a new binding that matches the filter + ctx + .bind('xyz') + .to('XYZ') + .tag('foo'); + // The view picks up the new binding + expect(await inst.view.values()).to.eql(['BAR', 'XYZ', 'FOO']); + }); +}); + +describe('@inject with filter function', async () => { + let ctx: Context; + beforeEach(() => { + ctx = givenContext(); + }); + + class MyControllerWithGetter { + @inject.getter(filterByTag('foo')) + getter: Getter; + } + + class MyControllerWithValues { + constructor( + @inject(filterByTag('foo')) + public values: string[], + ) {} + } + + class MyControllerWithView { + @inject(filterByTag('foo')) + view: ContextView; + } + + class MyControllerWithGetter2 { + @inject(filterByTag('foo')) + getter: Getter; + } + + it('injects as getter', async () => { + ctx.bind('my-controller').toClass(MyControllerWithGetter); + const inst = await ctx.get('my-controller'); + const getter = inst.getter; + expect(getter).to.be.a.Function(); + expect(await getter()).to.eql(['BAR', 'FOO']); + // Add a new binding that matches the filter + ctx + .bind('xyz') + .to('XYZ') + .tag('foo'); + // The getter picks up the new binding + expect(await getter()).to.eql(['BAR', 'XYZ', 'FOO']); + }); + + it('injects as values', async () => { + ctx.bind('my-controller').toClass(MyControllerWithValues); + const inst = await ctx.get('my-controller'); + expect(inst.values).to.eql(['BAR', 'FOO']); + }); + + it('refuses to inject as a view', async () => { + ctx.bind('my-controller').toClass(MyControllerWithView); + await expect( + ctx.get('my-controller'), + ).to.be.rejectedWith( + 'The type of MyControllerWithView.prototype.view' + + ' (ContextView) is not Array', + ); + }); + + it('refuses to inject as a getter', async () => { + ctx.bind('my-controller').toClass(MyControllerWithGetter2); + await expect( + ctx.get('my-controller'), + ).to.be.rejectedWith( + 'The type of MyControllerWithGetter2.prototype.getter' + + ' (Function) is not Array', + ); + }); +}); + +function givenContext(bindings: Binding[] = []) { + const parent = new Context('app'); + const ctx = new Context(parent, 'server'); + bindings.push( + ctx + .bind('bar') + .toDynamicValue(() => Promise.resolve('BAR')) + .tag('foo', 'bar') + .inScope(BindingScope.SINGLETON), + ); + bindings.push( + parent + .bind('foo') + .to('FOO') + .tag('foo', 'bar'), + ); + return ctx; +} diff --git a/packages/context/test/unit/inject.unit.ts b/packages/context/test/unit/inject.unit.ts index 75c4a3dd74d2..1b1eec921cea 100644 --- a/packages/context/test/unit/inject.unit.ts +++ b/packages/context/test/unit/inject.unit.ts @@ -25,7 +25,7 @@ describe('function argument injection', () => { } const meta = describeInjectedArguments(TestClass); - expect(meta.map(m => m.bindingKey)).to.deepEqual(['foo']); + expect(meta.map(m => m.bindingSelector)).to.deepEqual(['foo']); }); it('can retrieve information about injected method arguments', () => { @@ -35,7 +35,7 @@ describe('function argument injection', () => { } const meta = describeInjectedArguments(TestClass.prototype, 'test'); - expect(meta.map(m => m.bindingKey)).to.deepEqual(['foo']); + expect(meta.map(m => m.bindingSelector)).to.deepEqual(['foo']); }); it('can retrieve information about injected static method arguments', () => { @@ -44,7 +44,7 @@ describe('function argument injection', () => { } const meta = describeInjectedArguments(TestClass, 'test'); - expect(meta.map(m => m.bindingKey)).to.deepEqual(['foo']); + expect(meta.map(m => m.bindingSelector)).to.deepEqual(['foo']); }); it('returns an empty array when no ctor arguments are decorated', () => { @@ -63,7 +63,7 @@ describe('function argument injection', () => { class SubTestClass extends TestClass {} const meta = describeInjectedArguments(SubTestClass); - expect(meta.map(m => m.bindingKey)).to.deepEqual(['foo']); + expect(meta.map(m => m.bindingSelector)).to.deepEqual(['foo']); }); it('supports inheritance with overriding constructor', () => { @@ -77,7 +77,7 @@ describe('function argument injection', () => { } } const meta = describeInjectedArguments(SubTestClass); - expect(meta.map(m => m.bindingKey)).to.deepEqual(['bar']); + expect(meta.map(m => m.bindingSelector)).to.deepEqual(['bar']); }); it('supports inheritance with overriding constructor - no args', () => { @@ -91,7 +91,7 @@ describe('function argument injection', () => { } } const meta = describeInjectedArguments(SubTestClass); - expect(meta.map(m => m.bindingKey)).to.deepEqual([]); + expect(meta.map(m => m.bindingSelector)).to.deepEqual([]); }); }); @@ -112,7 +112,7 @@ describe('property injection', () => { } const meta = describeInjectedProperties(TestClass.prototype); - expect(meta.foo.bindingKey).to.eql('foo'); + expect(meta.foo.bindingSelector).to.eql('foo'); }); it('returns an empty object when no properties are decorated', () => { @@ -152,7 +152,7 @@ describe('property injection', () => { class SubTestClass extends TestClass {} const meta = describeInjectedProperties(SubTestClass.prototype); - expect(meta.foo.bindingKey).to.eql('foo'); + expect(meta.foo.bindingSelector).to.eql('foo'); }); it('supports inheritance with overriding property', () => { @@ -167,10 +167,10 @@ describe('property injection', () => { } const base = describeInjectedProperties(TestClass.prototype); - expect(base.foo.bindingKey).to.eql('foo'); + expect(base.foo.bindingSelector).to.eql('foo'); const sub = describeInjectedProperties(SubTestClass.prototype); - expect(sub.foo.bindingKey).to.eql('bar'); + expect(sub.foo.bindingSelector).to.eql('bar'); }); it('supports inherited and own properties', () => { @@ -184,8 +184,8 @@ describe('property injection', () => { bar: string; } const meta = describeInjectedProperties(SubTestClass.prototype); - expect(meta.foo.bindingKey).to.eql('foo'); - expect(meta.bar.bindingKey).to.eql('bar'); + expect(meta.foo.bindingSelector).to.eql('foo'); + expect(meta.bar.bindingSelector).to.eql('bar'); }); it('does not clone metadata deeply', () => { diff --git a/packages/context/test/unit/resolution-session.unit.ts b/packages/context/test/unit/resolution-session.unit.ts index 73c248804666..2b507e0554c9 100644 --- a/packages/context/test/unit/resolution-session.unit.ts +++ b/packages/context/test/unit/resolution-session.unit.ts @@ -14,7 +14,7 @@ describe('ResolutionSession', () => { function givenInjection(): Injection { return { target: MyController, - bindingKey: 'b', + bindingSelector: 'b', methodDescriptorOrParameterIndex: 0, }; } diff --git a/packages/context/test/unit/resolver.unit.ts b/packages/context/test/unit/resolver.unit.ts index f66522ce7f88..292744630c2b 100644 --- a/packages/context/test/unit/resolver.unit.ts +++ b/packages/context/test/unit/resolver.unit.ts @@ -5,12 +5,13 @@ import {expect} from '@loopback/testlab'; import { + BindingAddress, Context, + Getter, inject, + Injection, instantiateClass, invokeMethod, - Injection, - Getter, ResolutionSession, } from '../..'; @@ -82,7 +83,7 @@ describe('constructor injection', () => { @inject('foo', {x: 'bar'}, (c: Context, injection: Injection) => { const barKey = injection.metadata && injection.metadata.x; const b = c.getSync(barKey); - const f = c.getSync(injection.bindingKey); + const f = c.getSync(injection.bindingSelector as BindingAddress); return f + ':' + b; }) public fooBar: string, @@ -377,7 +378,7 @@ describe('property injection', () => { @inject('foo', {x: 'bar'}, (c: Context, injection: Injection) => { const barKey = injection.metadata && injection.metadata.x; const b = c.getSync(barKey); - const f = c.getSync(injection.bindingKey); + const f = c.getSync(injection.bindingSelector as BindingAddress); return f + ':' + b; }) public fooBar: string; @@ -586,7 +587,7 @@ function customDecorator(def: Object) { return inject('foo', def, (c: Context, injection: Injection) => { const barKey = injection.metadata && injection.metadata.x; const b = c.getSync(barKey); - const f = c.getSync(injection.bindingKey); + const f = c.getSync(injection.bindingSelector as BindingAddress); return f + ':' + b; }); } @@ -595,7 +596,7 @@ function customAsyncDecorator(def: Object) { return inject('foo', def, async (c: Context, injection: Injection) => { const barKey = injection.metadata && injection.metadata.x; const b = await c.get(barKey); - const f = await c.get(injection.bindingKey); + const f = await c.get(injection.bindingSelector as BindingAddress); return f + ':' + b; }); }