Skip to content

Commit 50cf9df

Browse files
committed
config: treat null as explicitly undefined
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
1 parent db8358d commit 50cf9df

File tree

5 files changed

+120
-29
lines changed

5 files changed

+120
-29
lines changed

.changeset/clever-donkeys-fry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@backstage/config': minor
3+
---
4+
5+
The `ConfigReader` now treats `null` values as present but explicitly undefined, meaning it will not fall back to the next level of configuration.

packages/config/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,7 @@
3737
},
3838
"dependencies": {
3939
"@backstage/errors": "workspace:^",
40-
"@backstage/types": "workspace:^",
41-
"lodash": "^4.17.21"
40+
"@backstage/types": "workspace:^"
4241
},
4342
"devDependencies": {
4443
"@backstage/cli": "workspace:^",

packages/config/src/reader.test.ts

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,15 @@ const DATA = {
4848
};
4949

5050
function expectValidValues(config: ConfigReader) {
51-
expect(config.keys()).toEqual(Object.keys(DATA));
51+
expect(config.keys()).toEqual(Object.keys(DATA).filter(k => k !== 'null'));
5252
expect(config.get('zero')).toBe(0);
5353
expect(config.has('zero')).toBe(true);
5454
expect(config.has('false')).toBe(true);
55-
expect(config.has('null')).toBe(true);
55+
expect(config.has('null')).toBe(false);
5656
expect(config.has('missing')).toBe(false);
5757
expect(config.has('nested.one')).toBe(true);
5858
expect(config.has('nested.missing')).toBe(false);
59-
expect(config.has('nested.null')).toBe(true);
59+
expect(config.has('nested.null')).toBe(false);
6060
expect(config.getNumber('zero')).toBe(0);
6161
expect(config.getNumber('one')).toBe(1);
6262
expect(config.getNumber('zeroString')).toBe(0);
@@ -81,7 +81,7 @@ function expectValidValues(config: ConfigReader) {
8181
expect(config.getConfig('nested').getNumber('one')).toBe(1);
8282
expect(config.get('nested')).toEqual({
8383
one: 1,
84-
null: null,
84+
null: undefined,
8585
string: 'string',
8686
strings: ['string1', 'string2'],
8787
});
@@ -90,6 +90,8 @@ function expectValidValues(config: ConfigReader) {
9090
['string1', 'string2'],
9191
);
9292
expect(config.getOptional('missing')).toBe(undefined);
93+
expect(config.getOptionalConfig('null')).toBe(undefined);
94+
expect(config.getOptionalConfig('null.nested')).toBe(undefined);
9395
expect(config.getOptionalConfig('missing')).toBe(undefined);
9496
expect(config.getOptionalConfigArray('missing')).toBe(undefined);
9597
expect(config.getNumber('zero')).toBe(0);
@@ -120,7 +122,7 @@ function expectInvalidValues(config: ConfigReader) {
120122
"Invalid type in config for key 'true' in 'ctx', got boolean, wanted number",
121123
);
122124
expect(() => config.getStringArray('null')).toThrow(
123-
"Invalid type in config for key 'null' in 'ctx', got null, wanted string-array",
125+
"Missing required config value at 'null' in 'ctx'",
124126
);
125127
expect(() => config.getString('emptyString')).toThrow(
126128
"Invalid type in config for key 'emptyString' in 'ctx', got empty-string, wanted string",
@@ -137,6 +139,9 @@ function expectInvalidValues(config: ConfigReader) {
137139
expect(() => config.getConfig('one')).toThrow(
138140
"Invalid type in config for key 'one' in 'ctx', got number, wanted object",
139141
);
142+
expect(() => config.getConfig('null')).toThrow(
143+
"Missing required config value at 'null'",
144+
);
140145
expect(() => config.getConfigArray('one')).toThrow(
141146
"Invalid type in config for key 'one' in 'ctx', got number, wanted object-array",
142147
);
@@ -742,4 +747,42 @@ describe('ConfigReader.get()', () => {
742747
expect(data1.foo.baz).toEqual({});
743748
expect(data2.x.y.z).toEqual({});
744749
});
750+
751+
it('should treat null as explicitly undefined', () => {
752+
const reader = ConfigReader.fromConfigs([
753+
{
754+
data: { obj: { a: 2, b: 2, c: 2 }, objB: { a: 2, b: null } },
755+
context: 'fallback',
756+
},
757+
{
758+
data: { obj: { a: 1, b: null }, objA: { a: 1, b: null } },
759+
context: 'primary',
760+
},
761+
]);
762+
763+
expect(reader.getOptionalNumber('obj.a')).toBe(1);
764+
expect(reader.getOptionalNumber('obj.b')).toBe(undefined);
765+
expect(reader.getOptionalNumber('obj.c')).toBe(2);
766+
767+
expect(reader.getConfig('obj').getOptionalNumber('a')).toBe(1);
768+
expect(reader.getConfig('obj').getOptionalNumber('b')).toBe(undefined);
769+
expect(reader.getConfig('obj').getOptionalNumber('c')).toBe(2);
770+
771+
expect(reader.getConfig('obj').get('a')).toBe(1);
772+
expect(() => reader.getConfig('obj').get('b')).toThrow(
773+
"Missing required config value at 'obj.b' in 'primary'",
774+
);
775+
expect(reader.getConfig('obj').get('c')).toBe(2);
776+
777+
expect(reader.getConfig('obj').getOptional('a')).toBe(1);
778+
expect(reader.getConfig('obj').getOptional('b')).toBe(undefined);
779+
expect(reader.getConfig('obj').getOptional('c')).toBe(2);
780+
781+
expect(reader.get('obj')).toEqual({ a: 1, c: 2 });
782+
expect(reader.getConfig('obj').get()).toEqual({ a: 1, c: 2 });
783+
expect(reader.getConfig('obj').keys()).toEqual(['a', 'c']);
784+
785+
expect(reader.get('objA')).toEqual({ a: 1 });
786+
expect(reader.get('objB')).toEqual({ a: 2 });
787+
});
745788
});

packages/config/src/reader.ts

Lines changed: 66 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@
1616

1717
import { JsonValue, JsonObject } from '@backstage/types';
1818
import { AppConfig, Config } from './types';
19-
import cloneDeep from 'lodash/cloneDeep';
20-
import mergeWith from 'lodash/mergeWith';
2119

2220
// Update the same pattern in config-loader package if this is changed
2321
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
@@ -26,6 +24,43 @@ function isObject(value: JsonValue | undefined): value is JsonObject {
2624
return typeof value === 'object' && value !== null && !Array.isArray(value);
2725
}
2826

27+
function cloneDeep(value: JsonValue | null | undefined): JsonValue | undefined {
28+
if (typeof value !== 'object' || value === null) {
29+
return value;
30+
}
31+
if (Array.isArray(value)) {
32+
return value.map(cloneDeep) as JsonValue;
33+
}
34+
return Object.fromEntries(
35+
Object.entries(value).map(([k, v]) => [k, cloneDeep(v)]),
36+
);
37+
}
38+
39+
function merge(
40+
into: JsonValue | undefined,
41+
from?: JsonValue | undefined,
42+
): JsonValue | undefined {
43+
if (into === null) {
44+
return undefined;
45+
}
46+
if (into === undefined) {
47+
return from === undefined ? undefined : merge(from);
48+
}
49+
if (typeof into !== 'object' || Array.isArray(into)) {
50+
return into;
51+
}
52+
const fromObj = isObject(from) ? from : {};
53+
54+
const out: JsonObject = {};
55+
for (const key of new Set([...Object.keys(into), ...Object.keys(fromObj)])) {
56+
const val = merge(into[key], fromObj[key]);
57+
if (val !== undefined) {
58+
out[key] = val;
59+
}
60+
}
61+
return out;
62+
}
63+
2964
function typeOf(value: JsonValue | undefined): string {
3065
if (value === null) {
3166
return 'null';
@@ -47,8 +82,8 @@ const errors = {
4782
type(key: string, context: string, typeName: string, expected: string) {
4883
return `Invalid type in config for key '${key}' in '${context}', got ${typeName}, wanted ${expected}`;
4984
},
50-
missing(key: string) {
51-
return `Missing required config value at '${key}'`;
85+
missing(key: string, context: string) {
86+
return `Missing required config value at '${key}' in '${context}'`;
5287
},
5388
convert(key: string, context: string, expected: string) {
5489
return `Unable to convert config value for key '${key}' in '${context}' to a ${expected}`;
@@ -114,6 +149,9 @@ export class ConfigReader implements Config {
114149
/** {@inheritdoc Config.has} */
115150
has(key: string): boolean {
116151
const value = this.readValue(key);
152+
if (value === null) {
153+
return false;
154+
}
117155
if (value !== undefined) {
118156
return true;
119157
}
@@ -124,23 +162,28 @@ export class ConfigReader implements Config {
124162
keys(): string[] {
125163
const localKeys = this.data ? Object.keys(this.data) : [];
126164
const fallbackKeys = this.fallback?.keys() ?? [];
127-
return [...new Set([...localKeys, ...fallbackKeys])];
165+
return [...new Set([...localKeys, ...fallbackKeys])].filter(
166+
k => this.data?.[k] !== null,
167+
);
128168
}
129169

130170
/** {@inheritdoc Config.get} */
131171
get<T = JsonValue>(key?: string): T {
132172
const value = this.getOptional(key);
133173
if (value === undefined) {
134-
throw new Error(errors.missing(this.fullKey(key ?? '')));
174+
throw new Error(errors.missing(this.fullKey(key ?? ''), this.context));
135175
}
136176
return value as T;
137177
}
138178

139179
/** {@inheritdoc Config.getOptional} */
140180
getOptional<T = JsonValue>(key?: string): T | undefined {
141181
const value = cloneDeep(this.readValue(key));
142-
const fallbackValue = this.fallback?.getOptional<T>(key);
182+
const fallbackValue = this.fallback?.getOptional(key);
143183

184+
if (value === null) {
185+
return undefined;
186+
}
144187
if (value === undefined) {
145188
if (process.env.NODE_ENV === 'development') {
146189
if (fallbackValue === undefined && key) {
@@ -158,23 +201,19 @@ export class ConfigReader implements Config {
158201
}
159202
}
160203
}
161-
return fallbackValue;
204+
return merge(fallbackValue) as T;
162205
} else if (fallbackValue === undefined) {
163-
return value as T;
206+
return merge(value) as T;
164207
}
165208

166-
// Avoid merging arrays and primitive values, since that's how merging works for other
167-
// methods for reading config.
168-
return mergeWith({}, { value: fallbackValue }, { value }, (into, from) =>
169-
!isObject(from) || !isObject(into) ? from : undefined,
170-
).value as T;
209+
return merge(value, fallbackValue) as T;
171210
}
172211

173212
/** {@inheritdoc Config.getConfig} */
174213
getConfig(key: string): ConfigReader {
175214
const value = this.getOptionalConfig(key);
176215
if (value === undefined) {
177-
throw new Error(errors.missing(this.fullKey(key)));
216+
throw new Error(errors.missing(this.fullKey(key), this.context));
178217
}
179218
return value;
180219
}
@@ -187,6 +226,9 @@ export class ConfigReader implements Config {
187226
if (isObject(value)) {
188227
return this.copy(value, key, fallbackConfig);
189228
}
229+
if (value === null) {
230+
return undefined;
231+
}
190232
if (value !== undefined) {
191233
throw new TypeError(
192234
errors.type(this.fullKey(key), this.context, typeOf(value), 'object'),
@@ -199,7 +241,7 @@ export class ConfigReader implements Config {
199241
getConfigArray(key: string): ConfigReader[] {
200242
const value = this.getOptionalConfigArray(key);
201243
if (value === undefined) {
202-
throw new Error(errors.missing(this.fullKey(key)));
244+
throw new Error(errors.missing(this.fullKey(key), this.context));
203245
}
204246
return value;
205247
}
@@ -244,7 +286,7 @@ export class ConfigReader implements Config {
244286
getNumber(key: string): number {
245287
const value = this.getOptionalNumber(key);
246288
if (value === undefined) {
247-
throw new Error(errors.missing(this.fullKey(key)));
289+
throw new Error(errors.missing(this.fullKey(key), this.context));
248290
}
249291
return value;
250292
}
@@ -273,7 +315,7 @@ export class ConfigReader implements Config {
273315
getBoolean(key: string): boolean {
274316
const value = this.getOptionalBoolean(key);
275317
if (value === undefined) {
276-
throw new Error(errors.missing(this.fullKey(key)));
318+
throw new Error(errors.missing(this.fullKey(key), this.context));
277319
}
278320
return value;
279321
}
@@ -305,7 +347,7 @@ export class ConfigReader implements Config {
305347
getString(key: string): string {
306348
const value = this.getOptionalString(key);
307349
if (value === undefined) {
308-
throw new Error(errors.missing(this.fullKey(key)));
350+
throw new Error(errors.missing(this.fullKey(key), this.context));
309351
}
310352
return value;
311353
}
@@ -323,7 +365,7 @@ export class ConfigReader implements Config {
323365
getStringArray(key: string): string[] {
324366
const value = this.getOptionalStringArray(key);
325367
if (value === undefined) {
326-
throw new Error(errors.missing(this.fullKey(key)));
368+
throw new Error(errors.missing(this.fullKey(key), this.context));
327369
}
328370
return value;
329371
}
@@ -384,6 +426,9 @@ export class ConfigReader implements Config {
384426

385427
return this.fallback?.readConfigValue(key, validate);
386428
}
429+
if (value === null) {
430+
return undefined;
431+
}
387432
const result = validate(value);
388433
if (result !== true) {
389434
const { key: keyName = key, value: theValue = value, expected } = result;
@@ -416,7 +461,7 @@ export class ConfigReader implements Config {
416461
for (const [index, part] of parts.entries()) {
417462
if (isObject(value)) {
418463
value = value[part];
419-
} else if (value !== undefined) {
464+
} else if (value !== undefined && value !== null) {
420465
const badKey = this.fullKey(parts.slice(0, index).join('.'));
421466
throw new TypeError(
422467
errors.type(badKey, this.context, typeOf(value), 'object'),

yarn.lock

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3803,7 +3803,6 @@ __metadata:
38033803
"@backstage/errors": "workspace:^"
38043804
"@backstage/test-utils": "workspace:^"
38053805
"@backstage/types": "workspace:^"
3806-
lodash: ^4.17.21
38073806
languageName: unknown
38083807
linkType: soft
38093808

0 commit comments

Comments
 (0)