diff --git a/packages/context/src/binding-tracker.ts b/packages/context/src/binding-tracker.ts new file mode 100644 index 000000000000..cd5f270ea68e --- /dev/null +++ b/packages/context/src/binding-tracker.ts @@ -0,0 +1,103 @@ +// 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:binding-tracker'); + +/** + * Tracking 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 BindingTracker { + private _cachedBindings: Readonly>[] | undefined; + private _cachedValues: ValueOrPromise | undefined; + + constructor( + protected readonly ctx: Context, + public readonly filter: BindingFilter, + ) {} + + watch() { + debug('Starting to watch context %s', this.ctx.name); + this.ctx.subscribe(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 + */ + findBindings() { + debug('Finding matching bindings'); + this._cachedBindings = this.ctx.find(this.filter); + return this._cachedBindings; + } + + /** + * Invalidate the 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() { + 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..5ba1bedee6e7 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 {BindingTracker} from './binding-tracker'; const debug = debugModule('loopback:context'); +export type BindingFilter = (binding: Readonly>) => boolean; + /** * Context provides an implementation of Inversion of Control (IoC) container */ @@ -38,6 +41,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 +74,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 +108,62 @@ 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; + } + + protected trackers: BindingTracker[] = []; + + /** + * Add the binding tracker as an event listener to the context chain + * @param bindingTracker + */ + subscribe(bindingTracker: BindingTracker) { + let ctx: Context | undefined = this; + while (ctx != null) { + if (!ctx.trackers.includes(bindingTracker)) { + ctx.trackers.push(bindingTracker); + } + ctx = ctx._parent; + } + } + + /** + * Remove the binding tracker from the context chain + * @param bindingTracker + */ + unsubscribe(bindingTracker: BindingTracker) { + let ctx: Context | undefined = this; + while (ctx != null) { + const index = ctx.trackers.indexOf(bindingTracker); + if (index !== -1) { + ctx.trackers.splice(index, 1); + } + ctx = ctx._parent; + } + } + + /** + * Publish an event to the registered binding trackers + * @param event Bind or unbind events + * @param binding Binding + */ + protected publish( + event: 'bind' | 'unbind', + binding: Readonly>, + ) { + // Reset trackers in the next tick so that we allow fluent APIs such as + // ctx.bind('key').to(...).tag(...); + process.nextTick(() => { + for (const tracker of this.trackers) { + if (tracker.filter(binding)) { + // FIXME: [rfeng] We just reset the tracker to invalidate the cache + // for now + tracker.reset(); + } + } + }); } /** @@ -128,23 +200,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 +217,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 +239,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 +271,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 +566,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..54573f00477f 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 {BindingTracker} from './binding-tracker'; + 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..2ae9700f866d 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 {BindingTracker} from './binding-tracker'; 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')) + * tracker: 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 + * @param ctx + * @param injection + * @param 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 tracker = new BindingTracker(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) tracker.watch(); + return tracker.asGetter(); + } else if (targetType === BindingTracker) { + if (watch !== false) tracker.watch(); + return tracker; + } else { + return tracker.resolve(session); + } } /** diff --git a/packages/context/test/unit/binding-tracker.unit.ts b/packages/context/test/unit/binding-tracker.unit.ts new file mode 100644 index 000000000000..e23755b28be0 --- /dev/null +++ b/packages/context/test/unit/binding-tracker.unit.ts @@ -0,0 +1,177 @@ +// 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 { + BindingTracker, + Context, + Binding, + BindingScope, + inject, + Getter, +} from '../..'; +import {expect} from '@loopback/testlab'; + +describe('BindingTracker', () => { + let ctx: Context; + let bindings: Binding[]; + let bindingTracker: BindingTracker; + + beforeEach(givenBindingTracker); + + it('tracks bindings', () => { + expect(bindingTracker.bindings).to.eql(bindings); + }); + + it('resolves bindings', async () => { + expect(await bindingTracker.resolve()).to.eql(['BAR', 'FOO']); + expect(await bindingTracker.values()).to.eql(['BAR', 'FOO']); + }); + + it('resolves bindings as a getter', async () => { + expect(await bindingTracker.asGetter()()).to.eql(['BAR', 'FOO']); + }); + + it('reloads bindings after reset', async () => { + bindingTracker.reset(); + const abcBinding = ctx + .bind('abc') + .to('ABC') + .tag('abc'); + const xyzBinding = ctx + .bind('xyz') + .to('XYZ') + .tag('foo'); + expect(bindingTracker.bindings).to.containEql(xyzBinding); + // `abc` does not have the matching tag + expect(bindingTracker.bindings).to.not.containEql(abcBinding); + expect(await bindingTracker.values()).to.eql(['BAR', 'XYZ', 'FOO']); + }); + + it('reloads bindings if context bindings are added', async () => { + bindingTracker.watch(); + const abcBinding = ctx + .bind('abc') + .to('ABC') + .tag('abc'); + const xyzBinding = ctx + .bind('xyz') + .to('XYZ') + .tag('foo'); + expect(bindingTracker.bindings).to.containEql(xyzBinding); + // `abc` does not have the matching tag + expect(bindingTracker.bindings).to.not.containEql(abcBinding); + expect(await bindingTracker.values()).to.eql(['BAR', 'XYZ', 'FOO']); + }); + + it('reloads bindings if context bindings are removed', async () => { + bindingTracker.watch(); + ctx.unbind('bar'); + expect(await bindingTracker.values()).to.eql(['FOO']); + }); + + it('reloads bindings if context bindings are rebound', async () => { + bindingTracker.watch(); + ctx.bind('bar').to('BAR'); // No more tagged with `foo` + expect(await bindingTracker.values()).to.eql(['FOO']); + }); + + it('reloads bindings if parent context bindings are added', async () => { + bindingTracker.watch(); + const xyzBinding = ctx + .parent!.bind('xyz') + .to('XYZ') + .tag('foo'); + expect(bindingTracker.bindings).to.containEql(xyzBinding); + expect(await bindingTracker.values()).to.eql(['BAR', 'FOO', 'XYZ']); + }); + + it('reloads bindings if parent context bindings are removed', async () => { + bindingTracker.watch(); + ctx.parent!.unbind('foo'); + expect(await bindingTracker.values()).to.eql(['BAR']); + }); + + function givenBindingTracker() { + bindings = []; + ctx = givenContext(bindings); + bindingTracker = new BindingTracker(ctx, 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: BindingTracker; + } + + 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(BindingTracker); + 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; +}