Skip to content

Commit d53fc57

Browse files
authored
fix(context): inject nested properties (#587)
Fix the implementation of `@inject` resolver to correctly support nested properties. Simplify the implementation of nested properties to avoid edge cases that are difficult to support: - All Context methods creating/returning full Binding instances require a key without property-path suffix. - Only `get` and `getSync`, which are (eventually) returning the bound value only, allow property-path suffix. - A new method `getValueOrPromise` is introduced, this is an internal method shared between `get`, `getSync` and `@inject` (`instantiateClass`). This method supports keys with property-path suffix too.
1 parent d4ee86a commit d53fc57

File tree

5 files changed

+247
-73
lines changed

5 files changed

+247
-73
lines changed

packages/context/src/binding.ts

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -97,31 +97,31 @@ export class Binding {
9797
static validateKey(key: string) {
9898
if (!key) throw new Error('Binding key must be provided.');
9999
if (key.includes(Binding.PROPERTY_SEPARATOR)) {
100-
throw new Error(`Binding key ${key} cannot contain`
101-
+ ` '${Binding.PROPERTY_SEPARATOR}'.`);
100+
throw new Error(
101+
`Binding key ${key} cannot contain` +
102+
` '${Binding.PROPERTY_SEPARATOR}'.`,
103+
);
102104
}
103105
return key;
104106
}
105107

106108
/**
107-
* Remove the segament that denotes a property path
108-
* @param key Binding key, such as `a, a.b, a:b, a/b, a.b#x, a:b#x.y, a/b#x.y`
109+
* Parse a string containing both the binding key and the path to the deeply
110+
* nested property to retrieve.
111+
*
112+
* @param keyWithPath The key with an optional path,
113+
* e.g. "application.instance" or "config#rest.port".
109114
*/
110-
static normalizeKey(key: string) {
111-
const index = key.indexOf(Binding.PROPERTY_SEPARATOR);
112-
if (index !== -1) key = key.substr(0, index);
113-
key = key.trim();
114-
return key;
115-
}
115+
static parseKeyWithPath(keyWithPath: string) {
116+
const index = keyWithPath.indexOf(Binding.PROPERTY_SEPARATOR);
117+
if (index === -1) {
118+
return {key: keyWithPath, path: undefined};
119+
}
116120

117-
/**
118-
* Get the property path separated by `#`
119-
* @param key Binding key
120-
*/
121-
static getKeyPath(key: string) {
122-
const index = key.indexOf(Binding.PROPERTY_SEPARATOR);
123-
if (index !== -1) return key.substr(index + 1);
124-
return undefined;
121+
return {
122+
key: keyWithPath.substr(0, index).trim(),
123+
path: keyWithPath.substr(index+1),
124+
};
125125
}
126126

127127
public readonly key: string;
@@ -145,8 +145,10 @@ export class Binding {
145145
* @param ctx The current context
146146
* @param result The calculated value for the binding
147147
*/
148-
private _cacheValue(ctx: Context, result: BoundValue | Promise<BoundValue>):
149-
BoundValue | Promise<BoundValue> {
148+
private _cacheValue(
149+
ctx: Context,
150+
result: BoundValue | Promise<BoundValue>,
151+
): BoundValue | Promise<BoundValue> {
150152
if (isPromise(result)) {
151153
if (this.scope === BindingScope.SINGLETON) {
152154
// Cache the value

packages/context/src/context.ts

Lines changed: 97 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// This file is licensed under the MIT License.
44
// License text available at https://opensource.org/licenses/MIT
55

6-
import {Binding, BoundValue} from './binding';
6+
import {Binding, BoundValue, ValueOrPromise} from './binding';
77
import {inject} from './inject';
88
import {isPromise} from './is-promise';
99

@@ -16,7 +16,6 @@ export class Context {
1616

1717
bind(key: string): Binding {
1818
Binding.validateKey(key);
19-
key = Binding.normalizeKey(key);
2019
const keyExists = this.registry.has(key);
2120
if (keyExists) {
2221
const existingBinding = this.registry.get(key);
@@ -31,15 +30,15 @@ export class Context {
3130
}
3231

3332
contains(key: string): boolean {
34-
key = Binding.normalizeKey(key);
33+
Binding.validateKey(key);
3534
return this.registry.has(key);
3635
}
3736

3837
find(pattern?: string): Binding[] {
3938
let bindings: Binding[] = [];
4039
if (pattern) {
4140
// TODO(@superkhau): swap with production grade glob to regex lib
42-
pattern = Binding.normalizeKey(pattern);
41+
Binding.validateKey(pattern);
4342
const glob = new RegExp('^' + pattern.split('*').join('.*') + '$');
4443
this.registry.forEach(binding => {
4544
const isMatch = glob.test(binding.key);
@@ -76,32 +75,71 @@ export class Context {
7675
return childList.concat(additions);
7776
}
7877

78+
/**
79+
* Get the value bound to the given key, optionally return a (deep) property
80+
* of the bound value.
81+
*
82+
* @example
83+
*
84+
* ```ts
85+
* // get the value bound to "application.instance"
86+
* const app = await ctx.get('application.instance');
87+
*
88+
* // get "rest" property from the value bound to "config"
89+
* const config = await ctx.getValueOrPromise('config#rest');
90+
*
91+
* // get "a" property of "numbers" property from the value bound to "data"
92+
* ctx.bind('data').to({numbers: {a: 1, b: 2}, port: 3000});
93+
* const a = await ctx.get('data#numbers.a');
94+
* ```
95+
*
96+
* @param keyWithPath The binding key, optionally suffixed with a path to the
97+
* (deeply) nested property to retrieve.
98+
* @returns A promise of the bound value.
99+
*/
79100
get(key: string): Promise<BoundValue> {
80101
try {
81-
const path = Binding.getKeyPath(key);
82-
const binding = this.getBinding(key);
83-
return Promise.resolve(binding.getValue(this)).then(
84-
val => getValue(val, path));
102+
return Promise.resolve(this.getValueOrPromise(key));
85103
} catch (err) {
86104
return Promise.reject(err);
87105
}
88106
}
89107

108+
/**
109+
* Get the synchronous value bound to the given key, optionally
110+
* return a (deep) property of the bound value.
111+
*
112+
* This method throws an error if the bound value requires async computation
113+
* (returns a promise). You should never rely on sync bindings in production
114+
* code.
115+
*
116+
* @example
117+
*
118+
* ```ts
119+
* // get the value bound to "application.instance"
120+
* const app = ctx.get('application.instance');
121+
*
122+
* // get "rest" property from the value bound to "config"
123+
* const config = ctx.getValueOrPromise('config#rest');
124+
* ```
125+
*
126+
* @param keyWithPath The binding key, optionally suffixed with a path to the
127+
* (deeply) nested property to retrieve.
128+
* @returns A promise of the bound value.
129+
*/
90130
getSync(key: string): BoundValue {
91-
const path = Binding.getKeyPath(key);
92-
const binding = this.getBinding(key);
93-
const valueOrPromise = binding.getValue(this);
131+
const valueOrPromise = this.getValueOrPromise(key);
94132

95133
if (isPromise(valueOrPromise)) {
96134
throw new Error(
97135
`Cannot get ${key} synchronously: the value is a promise`);
98136
}
99137

100-
return getValue(valueOrPromise, path);
138+
return valueOrPromise;
101139
}
102140

103141
getBinding(key: string): Binding {
104-
key = Binding.normalizeKey(key);
142+
Binding.validateKey(key);
105143
const binding = this.registry.get(key);
106144
if (binding) {
107145
return binding;
@@ -113,22 +151,55 @@ export class Context {
113151

114152
throw new Error(`The key ${key} was not bound to any value.`);
115153
}
154+
155+
/**
156+
* Get the value bound to the given key.
157+
*
158+
* This is an internal version that preserves the dual sync/async result
159+
* of `Binding#getValue()`. Users should use `get()` or `getSync()` instead.
160+
*
161+
* @example
162+
*
163+
* ```ts
164+
* // get the value bound to "application.instance"
165+
* ctx.getValueOrPromise('application.instance');
166+
*
167+
* // get "rest" property from the value bound to "config"
168+
* ctx.getValueOrPromise('config#rest');
169+
*
170+
* // get "a" property of "numbers" property from the value bound to "data"
171+
* ctx.bind('data').to({numbers: {a: 1, b: 2}, port: 3000});
172+
* ctx.getValueOrPromise('data#numbers.a');
173+
* ```
174+
*
175+
* @param keyWithPath The binding key, optionally suffixed with a path to the
176+
* (deeply) nested property to retrieve.
177+
* @returns The bound value or a promise of the bound value, depending
178+
* on how the binding was configured.
179+
* @internal
180+
*/
181+
getValueOrPromise(keyWithPath: string): ValueOrPromise<BoundValue> {
182+
const {key, path} = Binding.parseKeyWithPath(keyWithPath);
183+
const boundValue = this.getBinding(key).getValue(this);
184+
if (path === undefined || path === '') {
185+
return boundValue;
186+
}
187+
188+
if (isPromise(boundValue)) {
189+
return boundValue.then(v => getDeepProperty(v, path));
190+
}
191+
192+
return getDeepProperty(boundValue, path);
193+
}
116194
}
117195

118-
/**
119-
* Get the value by `.` notation
120-
* @param obj The source value
121-
* @param path A path to the nested property, such as `x`, `x.y`, `x.length`,
122-
* or `x.0`
123-
*/
124-
function getValue(obj: BoundValue, path?: string): BoundValue {
125-
if (!path) return obj;
196+
function getDeepProperty(value: BoundValue, path: string) {
126197
const props = path.split('.');
127-
let val = undefined;
128198
for (const p of props) {
129-
val = obj[p];
130-
if (val == null) return val;
131-
obj = val;
199+
value = value[p];
200+
if (value === undefined || value === null) {
201+
return value;
202+
}
132203
}
133-
return val;
204+
return value;
134205
}

packages/context/src/resolver.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,7 @@ function resolve<T>(ctx: Context, injection: Injection): ValueOrPromise<T> {
7575
return injection.resolve(ctx, injection);
7676
}
7777
// Default to resolve the value from the context by binding key
78-
const binding = ctx.getBinding(injection.bindingKey);
79-
return binding.getValue(ctx);
78+
return ctx.getValueOrPromise(injection.bindingKey);
8079
}
8180

8281
/**

packages/context/test/acceptance/class-level-bindings.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,21 @@ describe('Context bindings - Injecting dependencies of classes', () => {
171171
expect(ctx.getSync('key')).to.equal('a-value');
172172
});
173173

174+
it('injects a nested property', async () => {
175+
class TestComponent {
176+
constructor(
177+
@inject('config#test')
178+
public config: string,
179+
) {}
180+
}
181+
182+
ctx.bind('config').to({test: 'test-config'});
183+
ctx.bind('component').toClass(TestComponent);
184+
185+
const resolved = await ctx.get('component');
186+
expect(resolved.config).to.equal('test-config');
187+
});
188+
174189
function createContext() {
175190
ctx = new Context();
176191
}

0 commit comments

Comments
 (0)