From eddad5770174342538bfef7a7782bc1c1cb142e5 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Tue, 9 Jul 2019 13:40:34 -0700 Subject: [PATCH] feat(context): allow @config.* to specify the target binding key Sometimes we want to inject configuration from another binding instead of the current one. --- docs/site/Context.md | 25 ++- .../acceptance/binding-config.acceptance.ts | 153 +++++++++++++++++- .../class-level-bindings.acceptance.ts | 8 +- .../src/__tests__/unit/context-config.unit.ts | 6 +- packages/context/src/binding-config.ts | 16 +- packages/context/src/context.ts | 20 +-- packages/context/src/inject-config.ts | 93 ++++++++--- 7 files changed, 266 insertions(+), 55 deletions(-) diff --git a/docs/site/Context.md b/docs/site/Context.md index 4b2e6bc9970a..916b64c1073d 100644 --- a/docs/site/Context.md +++ b/docs/site/Context.md @@ -599,9 +599,9 @@ export class RestServer { } ``` -The `@config.*` decorators can take an optional `configPath` parameter to allow -the configuration value to be a deep property of the bound value. For example, -`@config('port')` injects `RestServerConfig.port` to the target. +The `@config.*` decorators can take an optional `propertyPath` parameter to +allow the configuration value to be a deep property of the bound value. For +example, `@config('port')` injects `RestServerConfig.port` to the target. ```ts export class MyRestServer { @@ -617,6 +617,25 @@ export class MyRestServer { } ``` +We also allow `@config.*` to be resolved from another binding than the current +one: + +```ts +export class MyRestServer { + constructor( + // Inject the `rest.host` from the application config + @config({fromBinding: 'application', propertyPath: 'rest.host'}) + host: string, + // Inject the `rest.port` from the application config + @config({fromBinding: 'application', propertyPath: 'rest.port'}) + port: number, + ) { + // ... + } + // ... +} +``` + Now we can use `context.configure()` to provide configuration for target bindings. diff --git a/packages/context/src/__tests__/acceptance/binding-config.acceptance.ts b/packages/context/src/__tests__/acceptance/binding-config.acceptance.ts index 4960a88291c4..88bbdebfd865 100644 --- a/packages/context/src/__tests__/acceptance/binding-config.acceptance.ts +++ b/packages/context/src/__tests__/acceptance/binding-config.acceptance.ts @@ -59,7 +59,7 @@ describe('Context bindings - injecting configuration for bound artifacts', () => expect(server1.configObj).to.eql({port: 3000}); }); - it('allows configPath for injection', async () => { + it('allows propertyPath for injection', async () => { class RestServerWithPort { constructor(@config('port') public port: number) {} } @@ -78,6 +78,69 @@ describe('Context bindings - injecting configuration for bound artifacts', () => expect(server1.port).to.eql(3000); }); + it('allows propertyPath for injection metadata', async () => { + class RestServerWithPort { + constructor(@config({propertyPath: 'port'}) public port: number) {} + } + + // Bind configuration + ctx + .configure('servers.rest.server1') + .toDynamicValue(() => Promise.resolve({port: 3000})); + + // Bind RestServer + ctx.bind('servers.rest.server1').toClass(RestServerWithPort); + + // Resolve an instance of RestServer + // Expect server1.config to be `{port: 3000} + const server1 = await ctx.get('servers.rest.server1'); + expect(server1.port).to.eql(3000); + }); + + it('allows propertyPath & fromBinding for injection metadata', async () => { + class RestServerWithPort { + constructor( + @config({propertyPath: 'port', fromBinding: 'restServer'}) + public port: number, + ) {} + } + + // Bind configuration + ctx + .configure('restServer') + .toDynamicValue(() => Promise.resolve({port: 3000})); + + // Bind RestServer + ctx.bind('servers.rest.server1').toClass(RestServerWithPort); + + // Resolve an instance of RestServer + // Expect server1.config to be `{port: 3000} + const server1 = await ctx.get('servers.rest.server1'); + expect(server1.port).to.eql(3000); + }); + + it('allows propertyPath parameter & fromBinding for injection metadata', async () => { + class RestServerWithPort { + constructor( + @config('port', {fromBinding: 'restServer'}) + public port: number, + ) {} + } + + // Bind configuration + ctx + .configure('restServer') + .toDynamicValue(() => Promise.resolve({port: 3000})); + + // Bind RestServer + ctx.bind('servers.rest.server1').toClass(RestServerWithPort); + + // Resolve an instance of RestServer + // Expect server1.config to be `{port: 3000} + const server1 = await ctx.get('servers.rest.server1'); + expect(server1.port).to.eql(3000); + }); + const LOGGER_KEY = 'loggers.Logger'; it('injects a getter function to access config', async () => { class Logger { @@ -111,6 +174,44 @@ describe('Context bindings - injecting configuration for bound artifacts', () => expect(configObj).to.be.undefined(); }); + it('injects a getter function with fromBinding to access config', async () => { + class MyService { + constructor( + @config.getter({fromBinding: LOGGER_KEY}) + public configGetter: Getter, + ) {} + } + + // Bind logger configuration + ctx.configure(LOGGER_KEY).to({level: 'INFO'}); + + // Bind MyService + ctx.bind('services.MyService').toClass(MyService); + + const myService = await ctx.get('services.MyService'); + const configObj = await myService.configGetter(); + expect(configObj).to.eql({level: 'INFO'}); + }); + + it('injects a getter function with propertyPath, {fromBinding} to access config', async () => { + class MyService { + constructor( + @config.getter('level', {fromBinding: LOGGER_KEY}) + public levelGetter: Getter, + ) {} + } + + // Bind logger configuration + ctx.configure(LOGGER_KEY).to({level: 'INFO'}); + + // Bind MyService + ctx.bind('services.MyService').toClass(MyService); + + const myService = await ctx.get('services.MyService'); + const configObj = await myService.levelGetter(); + expect(configObj).to.eql('INFO'); + }); + it('injects a view to access config', async () => { class Logger { constructor( @@ -161,6 +262,56 @@ describe('Context bindings - injecting configuration for bound artifacts', () => expect(level).to.eql('DEBUG'); }); + it('injects a view to access config with {fromBinding, propertyPath}', async () => { + class MyService { + constructor( + @config.view({fromBinding: LOGGER_KEY, propertyPath: 'level'}) + public configView: ContextView, + ) {} + } + + // Bind logger configuration + ctx.configure(LOGGER_KEY).to({level: 'INFO'}); + + // Bind MyService + ctx.bind('services.MyService').toClass(MyService); + + const myService = await ctx.get('services.MyService'); + let level = await myService.configView.singleValue(); + expect(level).to.eql('INFO'); + + // Update logger configuration + ctx.configure(LOGGER_KEY).to({level: 'DEBUG'}); + + level = await myService.configView.singleValue(); + expect(level).to.eql('DEBUG'); + }); + + it('injects a view to access config with parameter, {fromBinding}', async () => { + class MyService { + constructor( + @config.view('level', {fromBinding: LOGGER_KEY}) + public configView: ContextView, + ) {} + } + + // Bind logger configuration + ctx.configure(LOGGER_KEY).to({level: 'INFO'}); + + // Bind MyService + ctx.bind('services.MyService').toClass(MyService); + + const myService = await ctx.get('services.MyService'); + let level = await myService.configView.singleValue(); + expect(level).to.eql('INFO'); + + // Update logger configuration + ctx.configure(LOGGER_KEY).to({level: 'DEBUG'}); + + level = await myService.configView.singleValue(); + expect(level).to.eql('DEBUG'); + }); + it('rejects injection of config view if the target type is not ContextView', async () => { class Logger { constructor( diff --git a/packages/context/src/__tests__/acceptance/class-level-bindings.acceptance.ts b/packages/context/src/__tests__/acceptance/class-level-bindings.acceptance.ts index be4f66db7acb..04514bfeb9ae 100644 --- a/packages/context/src/__tests__/acceptance/class-level-bindings.acceptance.ts +++ b/packages/context/src/__tests__/acceptance/class-level-bindings.acceptance.ts @@ -767,7 +767,7 @@ describe('Context bindings - Injecting dependencies of classes', () => { expect(store.optionXY).to.eql('y'); }); - it('injects config if the configPath is not present', () => { + it('injects config if the propertyPath is not present', () => { class Store { constructor(@config() public configObj: object) {} } @@ -778,7 +778,7 @@ describe('Context bindings - Injecting dependencies of classes', () => { expect(store.configObj).to.eql({x: 1, y: 'a'}); }); - it("injects config if the configPath is ''", () => { + it("injects config if the propertyPath is ''", () => { class Store { constructor(@config('') public configObj: object) {} } @@ -789,7 +789,7 @@ describe('Context bindings - Injecting dependencies of classes', () => { expect(store.configObj).to.eql({x: 1, y: 'a'}); }); - it('injects config with configPath', () => { + it('injects config with propertyPath', () => { class Store { constructor(@config('x') public optionX: number) {} } @@ -800,7 +800,7 @@ describe('Context bindings - Injecting dependencies of classes', () => { expect(store.optionX).to.eql(1); }); - it('injects undefined option if configPath not found', () => { + it('injects undefined option if propertyPath not found', () => { class Store { constructor(@config('not-exist') public option: string | undefined) {} } diff --git a/packages/context/src/__tests__/unit/context-config.unit.ts b/packages/context/src/__tests__/unit/context-config.unit.ts index ad3784589a92..87792941f765 100644 --- a/packages/context/src/__tests__/unit/context-config.unit.ts +++ b/packages/context/src/__tests__/unit/context-config.unit.ts @@ -56,7 +56,7 @@ describe('Context binding configuration', () => { expect(await ctx.getConfig('foo')).to.eql({x: 1}); }); - it('gets config for a binding with configPath', async () => { + it('gets config for a binding with propertyPath', async () => { ctx .configure('foo') .toDynamicValue(() => Promise.resolve({a: {x: 0, y: 0}})); @@ -91,7 +91,7 @@ describe('Context binding configuration', () => { expect(ctx.getConfigSync('foo')).to.eql({x: 1}); }); - it('gets config for a binding with configPath', () => { + it('gets config for a binding with propertyPath', () => { ctx.configure('foo').to({x: 1}); expect(ctx.getConfigSync('foo', 'x')).to.eql(1); expect(ctx.getConfigSync('foo', 'y')).to.be.undefined(); @@ -109,7 +109,7 @@ describe('Context binding configuration', () => { class MyConfigResolver implements ConfigurationResolver { getConfigAsValueOrPromise( key: BindingAddress, - configPath?: string, + propertyPath?: string, resolutionOptions?: ResolutionOptions, ): ValueOrPromise { return (`Dummy config for ${key}` as unknown) as ConfigValueType; diff --git a/packages/context/src/binding-config.ts b/packages/context/src/binding-config.ts index 38380e503fdd..c8f6879fd441 100644 --- a/packages/context/src/binding-config.ts +++ b/packages/context/src/binding-config.ts @@ -21,7 +21,7 @@ export interface ConfigurationResolver { * Resolve config for the binding key * * @param key - Binding key - * @param configPath - Property path for the option. For example, `x.y` + * @param propertyPath - Property path for the option. For example, `x.y` * requests for `.x.y`. If not set, the `config` object will be * returned. * @param resolutionOptions - Options for the resolution. @@ -30,7 +30,7 @@ export interface ConfigurationResolver { */ getConfigAsValueOrPromise( key: BindingAddress, - configPath?: string, + propertyPath?: string, resolutionOptions?: ResolutionOptions, ): ValueOrPromise; } @@ -43,11 +43,11 @@ export class DefaultConfigurationResolver implements ConfigurationResolver { getConfigAsValueOrPromise( key: BindingAddress, - configPath?: string, + propertyPath?: string, resolutionOptions?: ResolutionOptions, ): ValueOrPromise { - configPath = configPath || ''; - const configKey = configBindingKeyFor(key, configPath); + propertyPath = propertyPath || ''; + const configKey = configBindingKeyFor(key, propertyPath); const options: ResolutionOptions = Object.assign( {optional: true}, @@ -60,14 +60,14 @@ export class DefaultConfigurationResolver implements ConfigurationResolver { /** * Create binding key for configuration of the binding * @param key - Binding key for the target binding - * @param configPath - Property path for the configuration + * @param propertyPath - Property path for the configuration */ export function configBindingKeyFor( key: BindingAddress, - configPath?: string, + propertyPath?: string, ) { return BindingKey.create( BindingKey.buildKeyForConfig(key).toString(), - configPath, + propertyPath, ); } diff --git a/packages/context/src/context.ts b/packages/context/src/context.ts index 8e5533a404f2..670e3cbc17f2 100644 --- a/packages/context/src/context.ts +++ b/packages/context/src/context.ts @@ -384,7 +384,7 @@ export class Context extends EventEmitter { * Get the value or promise of configuration for a given binding by key * * @param key - Binding key - * @param configPath - Property path for the option. For example, `x.y` + * @param propertyPath - Property path for the option. For example, `x.y` * requests for `.x.y`. If not set, the `` object will be * returned. * @param resolutionOptions - Options for the resolution. @@ -393,13 +393,13 @@ export class Context extends EventEmitter { */ getConfigAsValueOrPromise( key: BindingAddress, - configPath?: string, + propertyPath?: string, resolutionOptions?: ResolutionOptions, ): ValueOrPromise { this.setupConfigurationResolverIfNeeded(); return this.configResolver.getConfigAsValueOrPromise( key, - configPath, + propertyPath, resolutionOptions, ); } @@ -435,19 +435,19 @@ export class Context extends EventEmitter { * Resolve configuration for the binding by key * * @param key - Binding key - * @param configPath - Property path for the option. For example, `x.y` + * @param propertyPath - Property path for the option. For example, `x.y` * requests for `.x.y`. If not set, the `` object will be * returned. * @param resolutionOptions - Options for the resolution. */ async getConfig( key: BindingAddress, - configPath?: string, + propertyPath?: string, resolutionOptions?: ResolutionOptions, ): Promise { return await this.getConfigAsValueOrPromise( key, - configPath, + propertyPath, resolutionOptions, ); } @@ -456,23 +456,23 @@ export class Context extends EventEmitter { * Resolve configuration synchronously for the binding by key * * @param key - Binding key - * @param configPath - Property path for the option. For example, `x.y` + * @param propertyPath - Property path for the option. For example, `x.y` * requests for `config.x.y`. If not set, the `config` object will be * returned. * @param resolutionOptions - Options for the resolution. */ getConfigSync( key: BindingAddress, - configPath?: string, + propertyPath?: string, resolutionOptions?: ResolutionOptions, ): ConfigValueType | undefined { const valueOrPromise = this.getConfigAsValueOrPromise( key, - configPath, + propertyPath, resolutionOptions, ); if (isPromiseLike(valueOrPromise)) { - const prop = configPath ? ` property ${configPath}` : ''; + const prop = propertyPath ? ` property ${propertyPath}` : ''; throw new Error( `Cannot get config${prop} for ${key} synchronously: the value is a promise`, ); diff --git a/packages/context/src/inject-config.ts b/packages/context/src/inject-config.ts index e1f4b5ef39cb..e34a56f6e8f6 100644 --- a/packages/context/src/inject-config.ts +++ b/packages/context/src/inject-config.ts @@ -4,13 +4,30 @@ // License text available at https://opensource.org/licenses/MIT import {BindingFilter} from './binding-filter'; -import {BindingKey} from './binding-key'; +import {BindingAddress, BindingKey} from './binding-key'; import {Context} from './context'; import {ContextView} from './context-view'; import {assertTargetType, inject, Injection, InjectionMetadata} from './inject'; import {ResolutionSession} from './resolution-session'; import {getDeepProperty, ValueOrPromise} from './value-promise'; +/** + * Injection metadata for `@config.*` + */ +export interface ConfigInjectionMetadata extends InjectionMetadata { + /** + * Property path to retrieve the configuration of the target binding, for + * example, `rest.host`. + */ + propertyPath?: string; + /** + * Customize the target binding key from which the configuration is fetched. + * If not specified, the configuration of the current binding that contains + * the injection is used. + */ + fromBinding?: BindingAddress; +} + /** * Inject a property from `config` of the current binding. If no corresponding * config value is present, `undefined` will be injected as the configuration @@ -40,14 +57,21 @@ import {getDeepProperty, ValueOrPromise} from './value-promise'; * expect(store2.optionY).to.eql('b'); * ``` * - * @param configPath - Optional property path of the config. If is `''` or not + * @param propertyPath - Optional property path of the config. If is `''` or not * present, the `config` object will be returned. * @param metadata - Optional metadata to help the injection */ -export function config(configPath?: string, metadata?: InjectionMetadata) { - configPath = configPath || ''; +export function config( + propertyPath?: string | ConfigInjectionMetadata, + metadata?: ConfigInjectionMetadata, +) { + propertyPath = propertyPath || ''; + if (typeof propertyPath === 'object') { + metadata = propertyPath; + propertyPath = ''; + } metadata = Object.assign( - {configPath, decorator: '@config', optional: true}, + {propertyPath, decorator: '@config', optional: true}, metadata, ); return inject('', metadata, resolveFromConfig); @@ -56,16 +80,20 @@ export function config(configPath?: string, metadata?: InjectionMetadata) { export namespace config { /** * `@inject.getter` decorator to inject a config getter function - * @param configPath - Optional property path of the config object + * @param propertyPath - Optional property path of the config object * @param metadata - Injection metadata */ export const getter = function injectConfigGetter( - configPath?: string, - metadata?: InjectionMetadata, + propertyPath?: string | ConfigInjectionMetadata, + metadata?: ConfigInjectionMetadata, ) { - configPath = configPath || ''; + propertyPath = propertyPath || ''; + if (typeof propertyPath === 'object') { + metadata = propertyPath; + propertyPath = ''; + } metadata = Object.assign( - {configPath, decorator: '@config.getter', optional: true}, + {propertyPath, decorator: '@config.getter', optional: true}, metadata, ); return inject('', metadata, resolveAsGetterFromConfig); @@ -74,16 +102,20 @@ export namespace config { /** * `@inject.view` decorator to inject a config context view to allow dynamic * changes in configuration - * @param configPath - Optional property path of the config object + * @param propertyPath - Optional property path of the config object * @param metadata - Injection metadata */ export const view = function injectConfigView( - configPath?: string, - metadata?: InjectionMetadata, + propertyPath?: string | ConfigInjectionMetadata, + metadata?: ConfigInjectionMetadata, ) { - configPath = configPath || ''; + propertyPath = propertyPath || ''; + if (typeof propertyPath === 'object') { + metadata = propertyPath; + propertyPath = ''; + } metadata = Object.assign( - {configPath, decorator: '@config.view', optional: true}, + {propertyPath, decorator: '@config.view', optional: true}, metadata, ); return inject('', metadata, resolveAsViewFromConfig); @@ -100,6 +132,15 @@ function getCurrentBindingKey(session: ResolutionSession) { return session.currentBinding && session.currentBinding.key; } +/** + * Get the target binding key from which the configuration should be resolved + * @param injection - Injection + * @param session - Resolution session + */ +function getTargetBindingKey(injection: Injection, session: ResolutionSession) { + return injection.metadata.fromBinding || getCurrentBindingKey(session); +} + /** * Resolver for `@config` * @param ctx - Context object @@ -111,11 +152,11 @@ function resolveFromConfig( injection: Injection, session: ResolutionSession, ): ValueOrPromise { - const bindingKey = getCurrentBindingKey(session); + const bindingKey = getTargetBindingKey(injection, session); // Return `undefined` if no current binding is present if (!bindingKey) return undefined; const meta = injection.metadata; - return ctx.getConfigAsValueOrPromise(bindingKey, meta.configPath, { + return ctx.getConfigAsValueOrPromise(bindingKey, meta.propertyPath, { session, optional: meta.optional, }); @@ -133,14 +174,14 @@ function resolveAsGetterFromConfig( session: ResolutionSession, ) { assertTargetType(injection, Function, 'Getter function'); - const bindingKey = getCurrentBindingKey(session); + const bindingKey = getTargetBindingKey(injection, session); // We need to clone the session for the getter as it will be resolved later const forkedSession = ResolutionSession.fork(session); const meta = injection.metadata; return async function getter() { // Return `undefined` if no current binding is present if (!bindingKey) return undefined; - return ctx.getConfigAsValueOrPromise(bindingKey, meta.configPath, { + return ctx.getConfigAsValueOrPromise(bindingKey, meta.propertyPath, { session: forkedSession, optional: meta.optional, }); @@ -159,14 +200,14 @@ function resolveAsViewFromConfig( session: ResolutionSession, ) { assertTargetType(injection, ContextView); - const bindingKey = getCurrentBindingKey(session); + const bindingKey = getTargetBindingKey(injection, session); // Return `undefined` if no current binding is present if (!bindingKey) return undefined; const view = new ConfigView( ctx, binding => binding.key === BindingKey.buildKeyForConfig(bindingKey).toString(), - injection.metadata.configPath, + injection.metadata.propertyPath, ); view.open(); return view; @@ -174,13 +215,13 @@ function resolveAsViewFromConfig( /** * A subclass of `ContextView` to handle dynamic configuration as its - * `values()` honors the `configPath`. + * `values()` honors the `propertyPath`. */ class ConfigView extends ContextView { constructor( ctx: Context, filter: BindingFilter, - private configPath?: string, + private propertyPath?: string, ) { super(ctx, filter); } @@ -191,8 +232,8 @@ class ConfigView extends ContextView { */ async values(session?: ResolutionSession) { const configValues = await super.values(session); - const configPath = this.configPath; - if (!configPath) return configValues; - return configValues.map(v => getDeepProperty(v, configPath)); + const propertyPath = this.propertyPath; + if (!propertyPath) return configValues; + return configValues.map(v => getDeepProperty(v, propertyPath)); } }