From d09a47d5efa91f141db615bcfd65d452ff57b918 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Mon, 3 Dec 2018 11:36:04 -0800 Subject: [PATCH] feat(context): add support to watch context bindings - ContextWatcher - watches matching bindings of a context and maintain a live collection of values - @inject.filter - injects an array of values resolved from bindings that match the filter function --- packages/context/src/context-watcher.ts | 115 ++++++++++ packages/context/src/context.ts | 203 +++++++++++++----- packages/context/src/index.ts | 4 +- packages/context/src/inject.ts | 95 ++++++-- .../acceptance/context-watcher.acceptance.ts | 116 ++++++++++ .../context/test/unit/context-watcher.unit.ts | 179 +++++++++++++++ 6 files changed, 649 insertions(+), 63 deletions(-) create mode 100644 packages/context/src/context-watcher.ts create mode 100644 packages/context/test/acceptance/context-watcher.acceptance.ts create mode 100644 packages/context/test/unit/context-watcher.unit.ts diff --git a/packages/context/src/context-watcher.ts b/packages/context/src/context-watcher.ts new file mode 100644 index 000000000000..88b0a5c315c2 --- /dev/null +++ b/packages/context/src/context-watcher.ts @@ -0,0 +1,115 @@ +// 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 {Context, BindingFilter} from './context'; +import {Binding} from './binding'; +import {ResolutionSession} from './resolution-session'; +import {resolveList, ValueOrPromise} from './value-promise'; +import {Getter} from './inject'; +import * as debugFactory from 'debug'; +const debug = debugFactory('loopback:context:watcher'); + +/** + * Watching 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 ContextWatcher { + private _cachedBindings: Readonly>[] | undefined; + private _cachedValues: ValueOrPromise | undefined; + + constructor( + protected readonly ctx: Context, + public readonly filter: BindingFilter, + ) {} + + watch() { + debug('Start watching context %s', this.ctx.name); + this.ctx.subscribe(this); + } + + unwatch() { + debug('Stop watching context %s', this.ctx.name); + this.ctx.unsubscribe(this); + } + + /** + * 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.ctx.find(this.filter); + return this._cachedBindings; + } + + /** + * Listen on `bind` or `unbind` and invalidate the cache + */ + listen(event: 'bind' | 'unbind', binding: Readonly>) { + this.reset(); + } + + /** + * Reset the watcher by invalidating its cache + */ + reset() { + debug('Invalidating cache'); + this._cachedBindings = undefined; + this._cachedValues = undefined; + } + + /** + * Resolve values for the matching bindings + * @param session + */ + resolve(session?: ResolutionSession) { + debug('Resolving values'); + this._cachedValues = resolveList(this.bindings, b => { + return b.getValue(this.ctx, ResolutionSession.fork(session)); + }); + return this._cachedValues; + } + + /** + * Get the list of resolved values. If they are not cached, it tries tp find + * and resolve them. + */ + values(): Promise { + debug('Reading values'); + // [REVIEW] We need to get values in the next tick so that it can pick up + // binding changes as `Context` publishes such events in `process.nextTick` + return new Promise(resolve => { + process.nextTick(async () => { + if (this._cachedValues == null) { + this._cachedValues = this.resolve(); + } + resolve(await 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 b512d58630ac..b70d5fa46254 100644 --- a/packages/context/src/context.ts +++ b/packages/context/src/context.ts @@ -12,8 +12,11 @@ import {v1 as uuidv1} from 'uuid'; import * as debugModule from 'debug'; import {ValueOrPromise} from '.'; +import {ContextWatcher} from './context-watcher'; const debug = debugModule('loopback:context'); +export type BindingFilter = (binding: Readonly>) => boolean; + /** * Context provides an implementation of Inversion of Control (IoC) container */ @@ -24,6 +27,10 @@ export class Context { readonly name: string; protected readonly registry: Map = new Map(); protected _parent?: Context; + /** + * A list of registered context watchers + */ + protected watchers: ContextWatcher[] = []; /** * Create a new context @@ -38,6 +45,13 @@ export class Context { this.name = name || uuidv1(); } + /** + * Get the parent context + */ + get parent() { + return this._parent; + } + /** * Create a binding with the given key in the context. If a locked binding * already exists with the same key, an error will be thrown. @@ -64,14 +78,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.publish('unbind', existingBinding); + } + this.publish('bind', binding); + } return this; } @@ -91,7 +112,72 @@ export class Context { 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); + const found = this.registry.delete(key); + this.publish('unbind', binding); + return found; + } + + /** + * Add the context watcher as an event listener to the context chain, + * including its ancestors + * @param watcher Context watcher + */ + subscribe(watcher: ContextWatcher) { + let ctx: Context | undefined = this; + while (ctx != null) { + if (!ctx.watchers.includes(watcher)) { + ctx.watchers.push(watcher); + } + ctx = ctx._parent; + } + } + + /** + * Remove the context watcher from the context chain + * @param watcher Context watcher + */ + unsubscribe(watcher: ContextWatcher) { + let ctx: Context | undefined = this; + while (ctx != null) { + const index = ctx.watchers.indexOf(watcher); + if (index !== -1) { + ctx.watchers.splice(index, 1); + } + ctx = ctx._parent; + } + } + + /** + * Watch the context chain with the given binding filter + * @param filter A function to match bindings + */ + watch(filter: BindingFilter) { + const watcher = new ContextWatcher(this, filter); + this.subscribe(watcher); + return watcher; + } + + /** + * Publish an event to the registered watchers. Please note the + * notification happens using `process.nextTick` so that we allow fluent APIs + * such as `ctx.bind('key').to(...).tag(...);` and give watchers the fully + * populated binding + * + * @param event Event names: `bind` or `unbind` + * @param binding Binding bound or unbound + */ + protected publish( + event: 'bind' | 'unbind', + binding: Readonly>, + ) { + // Notify watchers in the next tick + process.nextTick(() => { + for (const watcher of this.watchers) { + if (watcher.filter(binding)) { + watcher.listen(event, binding); + } + } + }); } /** @@ -128,23 +214,6 @@ export class Context { return undefined; } - /** - * Convert a wildcard pattern to RegExp - * @param pattern A wildcard string with `*` and `?` as special characters. - * - `*` matches zero or more characters except `.` and `:` - * - `?` matches exactly one character except `.` and `:` - */ - private wildcardToRegExp(pattern: string): RegExp { - // Escape reserved chars for RegExp: - // `- \ ^ $ + . ( ) | { } [ ] :` - let regexp = pattern.replace(/[\-\[\]\/\{\}\(\)\+\.\\\^\$\|\:]/g, '\\$&'); - // Replace wildcard chars `*` and `?` - // `*` matches zero or more characters except `.` and `:` - // `?` matches one character except `.` and `:` - regexp = regexp.replace(/\*/g, '[^.:]*').replace(/\?/g, '[^.:]'); - return new RegExp(`^${regexp}$`); - } - /** * Find bindings using the key pattern * @param pattern A regexp or wildcard pattern with optional `*` and `?`. If @@ -162,27 +231,19 @@ export class Context { * include the binding or `false` to exclude the binding. */ find( - filter: (binding: Readonly>) => boolean, + filter: BindingFilter, ): Readonly>[]; find( - pattern?: - | string - | RegExp - | ((binding: Readonly>) => boolean), + pattern?: string | RegExp | BindingFilter, ): Readonly>[] { - let bindings: Readonly[] = []; - let filter: (binding: Readonly) => boolean; - if (!pattern) { - filter = binding => true; - } else if (typeof pattern === 'string') { - const regex = this.wildcardToRegExp(pattern); - filter = binding => regex.test(binding.key); - } else if (pattern instanceof RegExp) { - filter = binding => pattern.test(binding.key); - } else { - filter = pattern; - } + const bindings: Readonly[] = []; + const filter: BindingFilter = + pattern == null || + typeof pattern === 'string' || + pattern instanceof RegExp + ? Context.bindingKeyFilter(pattern) + : pattern; for (const b of this.registry.values()) { if (filter(b)) bindings.push(b); @@ -192,6 +253,21 @@ export class Context { return this._mergeWithParent(bindings, parentBindings); } + /** + * Create a binding filter from key pattern + * @param keyPattern Binding key, wildcard, or regexp + */ + static bindingKeyFilter(keyPattern?: string | RegExp) { + let filter: BindingFilter = binding => true; + if (typeof keyPattern === 'string') { + const regex = wildcardToRegExp(keyPattern); + filter = binding => regex.test(binding.key); + } else if (keyPattern instanceof RegExp) { + filter = binding => keyPattern.test(binding.key); + } + return filter; + } + /** * Find bindings using the tag filter. If the filter matches one of the * binding tags, the binding is included. @@ -209,22 +285,32 @@ export class Context { findByTag( tagFilter: string | RegExp | TagMap, ): Readonly>[] { - if (typeof tagFilter === 'string' || tagFilter instanceof RegExp) { + return this.find(Context.bindingTagFilter(tagFilter)); + } + + /** + * Create a binding filter for the tag pattern + * @param tagPattern + */ + static bindingTagFilter(tagPattern: string | RegExp | TagMap) { + let bindingFilter: BindingFilter; + if (typeof tagPattern === 'string' || tagPattern instanceof RegExp) { const regexp = - typeof tagFilter === 'string' - ? this.wildcardToRegExp(tagFilter) - : tagFilter; - return this.find(b => Array.from(b.tagNames).some(t => regexp!.test(t))); + typeof tagPattern === 'string' + ? wildcardToRegExp(tagPattern) + : tagPattern; + bindingFilter = b => Array.from(b.tagNames).some(t => regexp!.test(t)); + } else { + bindingFilter = b => { + for (const t in tagPattern) { + // One tag name/value does not match + if (b.tagMap[t] !== tagPattern[t]) return false; + } + // All tag name/value pairs match + return true; + }; } - - return this.find(b => { - for (const t in tagFilter) { - // One tag name/value does not match - if (b.tagMap[t] !== tagFilter[t]) return false; - } - // All tag name/value pairs match - return true; - }); + return bindingFilter; } protected _mergeWithParent( @@ -494,3 +580,20 @@ export class Context { return json; } } + +/** + * Convert a wildcard pattern to RegExp + * @param pattern A wildcard string with `*` and `?` as special characters. + * - `*` matches zero or more characters except `.` and `:` + * - `?` matches exactly one character except `.` and `:` + */ +function wildcardToRegExp(pattern: string): RegExp { + // Escape reserved chars for RegExp: + // `- \ ^ $ + . ( ) | { } [ ] :` + let regexp = pattern.replace(/[\-\[\]\/\{\}\(\)\+\.\\\^\$\|\:]/g, '\\$&'); + // Replace wildcard chars `*` and `?` + // `*` matches zero or more characters except `.` and `:` + // `?` matches one character except `.` and `:` + regexp = regexp.replace(/\*/g, '[^.:]*').replace(/\?/g, '[^.:]'); + return new RegExp(`^${regexp}$`); +} diff --git a/packages/context/src/index.ts b/packages/context/src/index.ts index c4f9e3bb7fae..84772d7c2694 100644 --- a/packages/context/src/index.ts +++ b/packages/context/src/index.ts @@ -21,12 +21,14 @@ export { export {Binding, BindingScope, BindingType, TagMap} from './binding'; -export {Context} from './context'; +export {Context, BindingFilter} from './context'; export {BindingKey, BindingAddress} from './binding-key'; export {ResolutionSession} from './resolution-session'; export {inject, Setter, Getter, Injection, InjectionMetadata} from './inject'; export {Provider} from './provider'; +export {ContextWatcher} from './context-watcher'; + export {instantiateClass, invokeMethod} from './resolver'; // internals for testing export {describeInjectedArguments, describeInjectedProperties} from './inject'; diff --git a/packages/context/src/inject.ts b/packages/context/src/inject.ts index b9f2173b137b..112f82cb9afe 100644 --- a/packages/context/src/inject.ts +++ b/packages/context/src/inject.ts @@ -12,10 +12,11 @@ import { MetadataAccessor, InspectionOptions, } from '@loopback/metadata'; -import {BoundValue, ValueOrPromise, resolveList} from './value-promise'; -import {Context} from './context'; +import {BoundValue, ValueOrPromise} from './value-promise'; +import {Context, BindingFilter} from './context'; import {BindingKey, BindingAddress} from './binding-key'; import {ResolutionSession} from './resolution-session'; +import {ContextWatcher} from './context-watcher'; const PARAMETERS_KEY = MetadataAccessor.create( 'inject:parameters', @@ -251,7 +252,7 @@ export namespace inject { * @param bindingTag Tag name or regex * @param metadata Optional metadata to help the injection */ - export const tag = function injectTag( + export const tag = function injectByTag( bindingTag: string | RegExp, metadata?: InjectionMetadata, ) { @@ -259,7 +260,42 @@ export namespace inject { {decorator: '@inject.tag', tag: bindingTag}, metadata, ); - return inject('', metadata, resolveByTag); + return filter(Context.bindingTagFilter(bindingTag), metadata); + }; + + /** + * Inject matching bound values by the filter function + * + * ```ts + * class MyControllerWithGetter { + * @inject.filter(Context.bindingTagFilter('foo')) + * getter: Getter; + * } + * + * class MyControllerWithValues { + * constructor( + * @inject.filter(Context.bindingTagFilter('foo')) + * public values: string[], + * ) {} + * } + * + * class MyControllerWithTracker { + * @inject.filter(Context.bindingTagFilter('foo')) + * watcher: BindingTracker; + * } + * ``` + * @param bindingFilter A binding filter function + * @param metadata + */ + export const filter = function injectByFilter( + bindingFilter: BindingFilter, + metadata?: InjectionMetadata, + ) { + metadata = Object.assign( + {decorator: '@inject.filter', bindingFilter}, + metadata, + ); + return inject('', metadata, resolveByFilter); }; /** @@ -331,19 +367,54 @@ 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 values by a binding filter function + * @param ctx Context object + * @param injection Injection information + * @param session Resolution session + */ +function resolveByFilter( ctx: Context, injection: Readonly, session?: ResolutionSession, ) { - const tag: string | RegExp = injection.metadata!.tag; - const bindings = ctx.findByTag(tag); + const bindingFilter = injection.metadata!.bindingFilter; + const watcher = new ContextWatcher(ctx, bindingFilter); + const watch = injection.metadata!.watch; - 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 targetType = inspectTargetType(injection); + if (targetType === Function) { + if (watch !== false) watcher.watch(); + return watcher.asGetter(); + } else if (targetType === ContextWatcher) { + if (watch !== false) watcher.watch(); + return watcher; + } else { + return watcher.resolve(session); + } } /** diff --git a/packages/context/test/acceptance/context-watcher.acceptance.ts b/packages/context/test/acceptance/context-watcher.acceptance.ts new file mode 100644 index 000000000000..963d1dcc20cf --- /dev/null +++ b/packages/context/test/acceptance/context-watcher.acceptance.ts @@ -0,0 +1,116 @@ +import {expect} from '@loopback/testlab'; +import {Context, ContextWatcher, Getter, inject} from '../..'; + +describe('ContextWatcher - watches matching bindings', () => { + let ctx: Context; + let contextWatcher: ContextWatcher; + beforeEach(givenContextWatcher); + + it('watches matching bindings', async () => { + // We have ctx: 1, parent: 2 + expect(await getControllers()).to.eql(['1', '2']); + ctx.unbind('controllers.1'); + // Now we have parent: 2 + expect(await getControllers()).to.eql(['2']); + ctx.parent!.unbind('controllers.2'); + // All controllers are gone from the context chain + expect(await getControllers()).to.eql([]); + // Add a new controller - ctx: 3 + givenController(ctx, '3'); + expect(await getControllers()).to.eql(['3']); + }); + + function givenContextWatcher() { + ctx = givenContext(); + contextWatcher = ctx.watch(Context.bindingTagFilter('controller')); + givenController(ctx, '1'); + givenController(ctx.parent!, '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 contextWatcher.values()).map((v: any) => v.name); + } +}); + +describe('@inject.filter - injects a live collection of matching bindings', async () => { + let ctx: Context; + beforeEach(givenPrimeNumbers); + + class MyControllerWithGetter { + @inject.filter(Context.bindingTagFilter('prime'), {watch: true}) + getter: Getter; + } + + class MyControllerWithValues { + constructor( + @inject.filter(Context.bindingTagFilter('prime')) + public values: string[], + ) {} + } + + class MyControllerWithTracker { + @inject.filter(Context.bindingTagFilter('prime')) + watcher: ContextWatcher; + } + + it('injects as getter', async () => { + ctx.bind('my-controller').toClass(MyControllerWithGetter); + const inst = await ctx.get('my-controller'); + const getter = inst.getter; + expect(await getter()).to.eql([3, 5]); + // Add a new binding that matches the filter + givenPrime(ctx, 7); + // The getter picks up the new binding + expect(await getter()).to.eql([3, 7, 5]); + }); + + it('injects as values', async () => { + ctx.bind('my-controller').toClass(MyControllerWithValues); + const inst = await ctx.get('my-controller'); + expect(inst.values).to.eql([3, 5]); + }); + + it('injects as a watcher', async () => { + ctx.bind('my-controller').toClass(MyControllerWithTracker); + const inst = await ctx.get('my-controller'); + const watcher = inst.watcher; + expect(await watcher.values()).to.eql([3, 5]); + // Add a new binding that matches the filter + // Add a new binding that matches the filter + givenPrime(ctx, 7); + // The watcher picks up the new binding + expect(await watcher.values()).to.eql([3, 7, 5]); + ctx.unbind('prime.7'); + expect(await watcher.values()).to.eql([3, 5]); + }); + + function givenPrimeNumbers() { + ctx = givenContext(); + givenPrime(ctx, 3); + givenPrime(ctx.parent!, 5); + } + + function givenPrime(_ctx: Context, p: number) { + _ctx + .bind(`prime.${p}`) + .to(p) + .tag('prime'); + } +}); + +function givenContext() { + const parent = new Context('app'); + const ctx = new Context(parent, 'server'); + return ctx; +} diff --git a/packages/context/test/unit/context-watcher.unit.ts b/packages/context/test/unit/context-watcher.unit.ts new file mode 100644 index 000000000000..9905fd0038d6 --- /dev/null +++ b/packages/context/test/unit/context-watcher.unit.ts @@ -0,0 +1,179 @@ +// 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, + Context, + ContextWatcher, + Getter, + inject, +} from '../..'; + +describe('ContextWatcher', () => { + let ctx: Context; + let bindings: Binding[]; + let contextWatcher: ContextWatcher; + + beforeEach(givenContextWatcher); + + it('tracks bindings', () => { + expect(contextWatcher.bindings).to.eql(bindings); + }); + + it('resolves bindings', async () => { + expect(await contextWatcher.resolve()).to.eql(['BAR', 'FOO']); + expect(await contextWatcher.values()).to.eql(['BAR', 'FOO']); + }); + + it('resolves bindings as a getter', async () => { + expect(await contextWatcher.asGetter()()).to.eql(['BAR', 'FOO']); + }); + + it('reloads bindings after reset', async () => { + contextWatcher.reset(); + const abcBinding = ctx + .bind('abc') + .to('ABC') + .tag('abc'); + const xyzBinding = ctx + .bind('xyz') + .to('XYZ') + .tag('foo'); + expect(contextWatcher.bindings).to.containEql(xyzBinding); + // `abc` does not have the matching tag + expect(contextWatcher.bindings).to.not.containEql(abcBinding); + expect(await contextWatcher.values()).to.eql(['BAR', 'XYZ', 'FOO']); + }); + + it('reloads bindings if context bindings are added', async () => { + const abcBinding = ctx + .bind('abc') + .to('ABC') + .tag('abc'); + const xyzBinding = ctx + .bind('xyz') + .to('XYZ') + .tag('foo'); + expect(contextWatcher.bindings).to.containEql(xyzBinding); + // `abc` does not have the matching tag + expect(contextWatcher.bindings).to.not.containEql(abcBinding); + expect(await contextWatcher.values()).to.eql(['BAR', 'XYZ', 'FOO']); + }); + + it('reloads bindings if context bindings are removed', async () => { + ctx.unbind('bar'); + expect(await contextWatcher.values()).to.eql(['FOO']); + }); + + it('reloads bindings if context bindings are rebound', async () => { + ctx.bind('bar').to('BAR'); // No more tagged with `foo` + expect(await contextWatcher.values()).to.eql(['FOO']); + }); + + it('reloads bindings if parent context bindings are added', async () => { + const xyzBinding = ctx + .parent!.bind('xyz') + .to('XYZ') + .tag('foo'); + expect(contextWatcher.bindings).to.containEql(xyzBinding); + expect(await contextWatcher.values()).to.eql(['BAR', 'FOO', 'XYZ']); + }); + + it('reloads bindings if parent context bindings are removed', async () => { + ctx.parent!.unbind('foo'); + expect(await contextWatcher.values()).to.eql(['BAR']); + }); + + it('stops watching', async () => { + expect(await contextWatcher.values()).to.eql(['BAR', 'FOO']); + contextWatcher.unwatch(); + ctx.parent!.unbind('foo'); + expect(await contextWatcher.values()).to.eql(['BAR', 'FOO']); + }); + + function givenContextWatcher() { + bindings = []; + ctx = givenContext(bindings); + contextWatcher = ctx.watch(Context.bindingTagFilter('foo')); + } +}); + +describe('@inject.filter', async () => { + let ctx: Context; + beforeEach(() => (ctx = givenContext())); + + class MyControllerWithGetter { + @inject.filter(Context.bindingTagFilter('foo'), {watch: true}) + getter: Getter; + } + + class MyControllerWithValues { + constructor( + @inject.filter(Context.bindingTagFilter('foo')) + public values: string[], + ) {} + } + + class MyControllerWithTracker { + @inject.filter(Context.bindingTagFilter('foo')) + tracker: ContextWatcher; + } + + 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('injects as a tracker', async () => { + ctx.bind('my-controller').toClass(MyControllerWithTracker); + const inst = await ctx.get('my-controller'); + expect(inst.tracker).to.be.instanceOf(ContextWatcher); + expect(await inst.tracker.values()).to.eql(['BAR', 'FOO']); + // Add a new binding that matches the filter + ctx + .bind('xyz') + .to('XYZ') + .tag('foo'); + // The tracker picks up the new binding + expect(await inst.tracker.values()).to.eql(['BAR', 'XYZ', 'FOO']); + }); +}); + +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; +}