From 3be32a34a0109e4f4f2eb0fcfa60171bd66743a6 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Mon, 3 Feb 2020 15:05:06 -0800 Subject: [PATCH] feat(context): allow more options to inspect context/binding objects At binding level, there is one flag: - includeInjections: control if injections should be inspected At context level, there are two flags: - includeInjections: control if binding injections should be inspected - includeParent: control if parent context should be inspected --- .../src/__tests__/unit/binding.unit.ts | 151 ++++++++++++++++++ .../src/__tests__/unit/context.unit.ts | 94 ++++++++++- packages/context/src/binding.ts | 33 +++- packages/context/src/context.ts | 36 ++++- packages/context/src/inject.ts | 65 ++++++++ 5 files changed, 368 insertions(+), 11 deletions(-) diff --git a/packages/context/src/__tests__/unit/binding.unit.ts b/packages/context/src/__tests__/unit/binding.unit.ts index ce6ae02e4a85..e4f0f953036b 100644 --- a/packages/context/src/__tests__/unit/binding.unit.ts +++ b/packages/context/src/__tests__/unit/binding.unit.ts @@ -7,9 +7,11 @@ import {expect, sinon, SinonSpy} from '@loopback/testlab'; import { Binding, BindingEvent, + BindingKey, BindingScope, BindingType, Context, + filterByTag, inject, Provider, } from '../..'; @@ -428,6 +430,155 @@ describe('Binding', () => { type: BindingType.CONSTANT, }); }); + + it('converts a keyed binding with alias to plain JSON object', () => { + const myBinding = new Binding(key) + .inScope(BindingScope.TRANSIENT) + .toAlias(BindingKey.create('b', 'x')); + const json = myBinding.toJSON(); + expect(json).to.eql({ + key: key, + scope: BindingScope.TRANSIENT, + tags: {}, + isLocked: false, + type: BindingType.ALIAS, + alias: 'b#x', + }); + }); + }); + + describe('inspect()', () => { + it('converts a keyed binding to plain JSON object', () => { + const json = binding.inspect(); + expect(json).to.eql({ + key: key, + scope: BindingScope.TRANSIENT, + tags: {}, + isLocked: false, + }); + }); + + it('converts a binding with more attributes to plain JSON object', () => { + const myBinding = new Binding(key, true) + .inScope(BindingScope.CONTEXT) + .tag('model', {name: 'my-model'}) + .to('a'); + const json = myBinding.inspect(); + expect(json).to.eql({ + key: key, + scope: BindingScope.CONTEXT, + tags: {model: 'model', name: 'my-model'}, + isLocked: true, + type: BindingType.CONSTANT, + }); + }); + + it('converts a binding with toDynamicValue to plain JSON object', () => { + const myBinding = new Binding(key) + .inScope(BindingScope.SINGLETON) + .tag('model', {name: 'my-model'}) + .toDynamicValue(() => 'a'); + const json = myBinding.inspect({includeInjections: true}); + expect(json).to.eql({ + key: key, + scope: BindingScope.SINGLETON, + tags: {model: 'model', name: 'my-model'}, + isLocked: false, + type: BindingType.DYNAMIC_VALUE, + }); + }); + + it('converts a binding with valueConstructor to plain JSON object', () => { + function myFilter(b: Readonly>) { + return b.key.startsWith('timers.'); + } + + class MyController { + @inject('y', {optional: true}) + private y: number | undefined; + + @inject(filterByTag('task')) + private tasks: unknown[]; + + @inject(myFilter) + private timers: unknown[]; + + constructor(@inject('x') private x: string) {} + } + const myBinding = new Binding(key, true) + .tag('model', {name: 'my-model'}) + .toClass(MyController); + const json = myBinding.inspect({includeInjections: true}); + expect(json).to.eql({ + key: key, + scope: BindingScope.TRANSIENT, + tags: {model: 'model', name: 'my-model'}, + isLocked: true, + type: BindingType.CLASS, + valueConstructor: 'MyController', + injections: { + constructorArguments: [ + {targetName: 'MyController.constructor[0]', bindingKey: 'x'}, + ], + properties: { + y: { + targetName: 'MyController.prototype.y', + bindingKey: 'y', + optional: true, + }, + tasks: { + targetName: 'MyController.prototype.tasks', + bindingTagPattern: 'task', + }, + timers: { + targetName: 'MyController.prototype.timers', + bindingFilter: 'myFilter', + }, + }, + }, + }); + }); + + it('converts a binding with providerConstructor to plain JSON object', () => { + class MyProvider implements Provider { + @inject('y') + private y: number; + + @inject(filterByTag('task')) + private tasks: unknown[]; + + constructor(@inject('x') private x: string) {} + + value() { + return `${this.x}: ${this.y}`; + } + } + const myBinding = new Binding(key, true) + .inScope(BindingScope.CONTEXT) + .tag('model', {name: 'my-model'}) + .toProvider(MyProvider); + const json = myBinding.inspect({includeInjections: true}); + expect(json).to.eql({ + key: key, + scope: BindingScope.CONTEXT, + tags: {model: 'model', name: 'my-model'}, + isLocked: true, + type: BindingType.PROVIDER, + providerConstructor: 'MyProvider', + injections: { + constructorArguments: [ + {targetName: 'MyProvider.constructor[0]', bindingKey: 'x'}, + ], + properties: { + y: {targetName: 'MyProvider.prototype.y', bindingKey: 'y'}, + tasks: { + targetName: 'MyProvider.prototype.tasks', + bindingTagPattern: 'task', + }, + }, + }, + }); + }); }); function givenBinding() { diff --git a/packages/context/src/__tests__/unit/context.unit.ts b/packages/context/src/__tests__/unit/context.unit.ts index 5b47e7eadf39..a6edeaa37cb8 100644 --- a/packages/context/src/__tests__/unit/context.unit.ts +++ b/packages/context/src/__tests__/unit/context.unit.ts @@ -11,6 +11,7 @@ import { BindingScope, BindingType, Context, + inject, isPromiseLike, Provider, } from '../..'; @@ -898,10 +899,99 @@ describe('Context', () => { }); }); - class MyService {} + it('inspects as plain JSON object to not include parent', () => { + const childCtx = new TestContext(ctx, 'server'); + childCtx.bind('foo').to('foo-value'); + + expect(childCtx.inspect({includeParent: false})).to.eql({ + name: 'server', + bindings: childCtx.toJSON(), + }); + }); + + it('inspects as plain JSON object to include injections', () => { + const childCtx = new TestContext(ctx, 'server'); + childCtx.bind('foo').to('foo-value'); + + const expectedJSON = { + name: 'server', + bindings: { + foo: { + key: 'foo', + scope: 'Transient', + tags: {}, + isLocked: false, + type: 'Constant', + }, + }, + parent: { + name: 'app', + bindings: { + a: { + key: 'a', + scope: 'Transient', + tags: {}, + isLocked: true, + type: 'Constant', + }, + b: { + key: 'b', + scope: 'Singleton', + tags: {X: 'X', Y: 'Y'}, + isLocked: false, + type: 'DynamicValue', + }, + c: { + key: 'c', + scope: 'Transient', + tags: {Z: 'Z', a: 1}, + isLocked: false, + type: 'Constant', + }, + d: { + key: 'd', + scope: 'Transient', + tags: {}, + isLocked: false, + type: 'Class', + valueConstructor: 'MyService', + injections: { + constructorArguments: [ + {targetName: 'MyService.constructor[0]', bindingKey: 'x'}, + ], + }, + }, + e: { + key: 'e', + scope: 'Transient', + tags: {}, + isLocked: false, + type: 'Provider', + providerConstructor: 'MyServiceProvider', + injections: { + properties: { + x: { + targetName: 'MyServiceProvider.prototype.x', + bindingKey: 'x', + }, + }, + }, + }, + }, + }, + }; + const json = childCtx.inspect({includeInjections: true}); + expect(json).to.eql(expectedJSON); + }); + + class MyService { + constructor(@inject('x') private x: string) {} + } class MyServiceProvider implements Provider { + @inject('x') + private x: string; value() { - return new MyService(); + return new MyService(this.x); } } diff --git a/packages/context/src/binding.ts b/packages/context/src/binding.ts index ff2931789f0c..b30c09de3db8 100644 --- a/packages/context/src/binding.ts +++ b/packages/context/src/binding.ts @@ -7,7 +7,9 @@ import debugFactory from 'debug'; import {EventEmitter} from 'events'; import {BindingAddress, BindingKey} from './binding-key'; import {Context} from './context'; +import {inspectInjections} from './inject'; import {createProxyWithInterceptors} from './interception-proxy'; +import {JSONObject} from './json-types'; import {ContextTags} from './keys'; import {Provider} from './provider'; import { @@ -635,8 +637,8 @@ export class Binding extends EventEmitter { /** * Convert to a plain JSON object */ - toJSON(): object { - const json: Record = { + toJSON(): JSONObject { + const json: JSONObject = { key: this.key, scope: this.scope, tags: this.tagMap, @@ -657,6 +659,23 @@ export class Binding extends EventEmitter { return json; } + /** + * Inspect the binding to return a json representation of the binding information + * @param options - Options to control what information should be included + */ + inspect(options: BindingInspectOptions = {}): JSONObject { + options = { + includeInjections: false, + ...options, + }; + const json = this.toJSON(); + if (options.includeInjections) { + const injections = inspectInjections(this); + if (Object.keys(injections).length) json.injections = injections; + } + return json; + } + /** * A static method to create a binding so that we can do * `Binding.bind('foo').to('bar');` as `new Binding('foo').to('bar')` is not @@ -688,6 +707,16 @@ export class Binding extends EventEmitter { } } +/** + * Options for binding.inspect() + */ +export interface BindingInspectOptions { + /** + * The flag to control if injections should be inspected + */ + includeInjections?: boolean; +} + function createInterceptionProxyFromInstance( instOrPromise: ValueOrPromise, context: Context, diff --git a/packages/context/src/context.ts b/packages/context/src/context.ts index 8a4b9a98dee0..4f68a0797a4a 100644 --- a/packages/context/src/context.ts +++ b/packages/context/src/context.ts @@ -6,7 +6,7 @@ import debugFactory from 'debug'; import {EventEmitter} from 'events'; import {v1 as uuidv1} from 'uuid'; -import {Binding, BindingTag} from './binding'; +import {Binding, BindingInspectOptions, BindingTag} from './binding'; import { ConfigurationResolver, DefaultConfigurationResolver, @@ -24,6 +24,7 @@ import {ContextEventObserver, ContextObserver} from './context-observer'; import {ContextSubscriptionManager, Subscription} from './context-subscription'; import {ContextTagIndexer} from './context-tag-indexer'; import {ContextView} from './context-view'; +import {JSONObject} from './json-types'; import {ContextBindings} from './keys'; import { asResolutionOptions, @@ -781,8 +782,8 @@ export class Context extends EventEmitter { /** * Create a plain JSON object for the context */ - toJSON(): object { - const bindings: Record = {}; + toJSON(): JSONObject { + const bindings: JSONObject = {}; for (const [k, v] of this.registry) { bindings[k] = v.toJSON(); } @@ -792,20 +793,41 @@ export class Context extends EventEmitter { /** * Inspect the context and dump out a JSON object representing the context * hierarchy + * @param options - Options for inspect */ // TODO(rfeng): Evaluate https://nodejs.org/api/util.html#util_custom_inspection_functions_on_objects - inspect(): object { - const json: Record = { + inspect(options: ContextInspectOptions = {}): JSONObject { + options = { + includeParent: true, + includeInjections: false, + ...options, + }; + const bindings: JSONObject = {}; + for (const [k, v] of this.registry) { + bindings[k] = v.inspect(options); + } + const json: JSONObject = { name: this.name, - bindings: this.toJSON(), + bindings, }; + if (!options.includeParent) return json; if (this._parent) { - json.parent = this._parent.inspect(); + json.parent = this._parent.inspect(options); } return json; } } +/** + * Options for context.inspect() + */ +export interface ContextInspectOptions extends BindingInspectOptions { + /** + * The flag to control if parent context should be inspected + */ + includeParent?: boolean; +} + /** * Policy to control if a binding should be created for the context */ diff --git a/packages/context/src/inject.ts b/packages/context/src/inject.ts index 2d82665aa8be..99a17172abc2 100644 --- a/packages/context/src/inject.ts +++ b/packages/context/src/inject.ts @@ -18,11 +18,13 @@ import { BindingSelector, filterByTag, isBindingAddress, + isBindingTagFilter, } from './binding-filter'; import {BindingAddress} from './binding-key'; import {BindingComparator} from './binding-sorter'; import {BindingCreationPolicy, Context} from './context'; import {ContextView, createViewGetter} from './context-view'; +import {JSONObject} from './json-types'; import {ResolutionOptions, ResolutionSession} from './resolution-session'; import {BoundValue, ValueOrPromise} from './value-promise'; @@ -698,3 +700,66 @@ export function describeInjectedProperties( ) ?? {}; return metadata; } + +/** + * Inspect injections for a binding created with `toClass` or `toProvider` + * @param binding - Binding object + */ +export function inspectInjections(binding: Readonly>) { + const json: JSONObject = {}; + const ctor = binding.valueConstructor ?? binding.providerConstructor; + if (ctor == null) return json; + const constructorInjections = describeInjectedArguments(ctor, '').map( + inspectInjection, + ); + if (constructorInjections.length) { + json.constructorArguments = constructorInjections; + } + const propertyInjections = describeInjectedProperties(ctor.prototype); + const properties: JSONObject = {}; + for (const p in propertyInjections) { + properties[p] = inspectInjection(propertyInjections[p]); + } + if (Object.keys(properties).length) { + json.properties = properties; + } + return json; +} + +/** + * Inspect an injection + * @param injection - Injection information + */ +function inspectInjection(injection: Readonly>) { + const injectionInfo = ResolutionSession.describeInjection(injection); + const descriptor: JSONObject = {}; + if (injectionInfo.targetName) { + descriptor.targetName = injectionInfo.targetName; + } + if (isBindingAddress(injectionInfo.bindingSelector)) { + // Binding key + descriptor.bindingKey = injectionInfo.bindingSelector.toString(); + } else if (isBindingTagFilter(injectionInfo.bindingSelector)) { + // Binding tag filter + descriptor.bindingTagPattern = JSON.parse( + JSON.stringify(injectionInfo.bindingSelector.bindingTagPattern), + ); + } else { + // Binding filter function + descriptor.bindingFilter = + injectionInfo.bindingSelector?.name ?? ''; + } + // Inspect metadata + if (injectionInfo.metadata) { + if ( + injectionInfo.metadata.decorator && + injectionInfo.metadata.decorator !== '@inject' + ) { + descriptor.decorator = injectionInfo.metadata.decorator; + } + if (injectionInfo.metadata.optional) { + descriptor.optional = injectionInfo.metadata.optional; + } + } + return descriptor; +}