17
import { JsonValue, JsonObject } from '@backstage/types';
18
import { AppConfig, Config } from './types';
19
import cloneDeep from 'lodash/cloneDeep';
20
import mergeWith from 'lodash/mergeWith';
23
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
25
function isObject(value: JsonValue | undefined): value is JsonObject {
26
return typeof value === 'object' && value !== null && !Array.isArray(value);
29
function typeOf(value: JsonValue | undefined): string {
32
} else if (Array.isArray(value)) {
35
const type = typeof value;
36
if (type === 'number' && isNaN(value as number)) {
39
if (type === 'string' && value === '') {
40
return 'empty-string';
47
type(key: string, context: string, typeName: string, expected: string) {
48
return `Invalid type in config for key '${key}' in '${context}', got ${typeName}, wanted ${expected}`;
50
missing(key: string) {
51
return `Missing required config value at '${key}'`;
53
convert(key: string, context: string, expected: string) {
54
return `Unable to convert config value for key '${key}' in '${context}' to a ${expected}`;
64
export class ConfigReader implements Config {
72
private filteredKeys?: string[];
73
private notifiedFilteredKeys = new Set<string>();
78
static fromConfigs(configs: AppConfig[]): ConfigReader {
79
if (configs.length === 0) {
80
return new ConfigReader(undefined);
85
return configs.reduce<ConfigReader>(
86
(previousReader, { data, context, filteredKeys, deprecatedKeys }) => {
87
const reader = new ConfigReader(data, context, previousReader);
88
reader.filteredKeys = filteredKeys;
91
for (const { key, description } of deprecatedKeys) {
94
`The configuration key '${key}' of ${context} is deprecated and may be removed soon. ${
108
private readonly data: JsonObject | undefined,
109
private readonly context: string = 'mock-config',
110
private readonly fallback?: ConfigReader,
111
private readonly prefix: string = '',
115
has(key: string): boolean {
116
const value = this.readValue(key);
117
if (value !== undefined) {
120
return this.fallback?.has(key) ?? false;
125
const localKeys = this.data ? Object.keys(this.data) : [];
126
const fallbackKeys = this.fallback?.keys() ?? [];
127
return [...new Set([...localKeys, ...fallbackKeys])];
131
get<T = JsonValue>(key?: string): T {
132
const value = this.getOptional(key);
133
if (value === undefined) {
134
throw new Error(errors.missing(this.fullKey(key ?? '')));
140
getOptional<T = JsonValue>(key?: string): T | undefined {
141
const value = cloneDeep(this.readValue(key));
142
const fallbackValue = this.fallback?.getOptional<T>(key);
144
if (value === undefined) {
145
if (process.env.NODE_ENV === 'development') {
146
if (fallbackValue === undefined && key) {
147
const fullKey = this.fullKey(key);
149
this.filteredKeys?.includes(fullKey) &&
150
!this.notifiedFilteredKeys.has(fullKey)
152
this.notifiedFilteredKeys.add(fullKey);
155
`Failed to read configuration value at '${fullKey}' as it is not visible. ` +
156
'See https://backstage.io/docs/conf/defining#visibility for instructions on how to make it visible.',
161
return fallbackValue;
162
} else if (fallbackValue === undefined) {
168
return mergeWith({}, { value: fallbackValue }, { value }, (into, from) =>
169
!isObject(from) || !isObject(into) ? from : undefined,
174
getConfig(key: string): ConfigReader {
175
const value = this.getOptionalConfig(key);
176
if (value === undefined) {
177
throw new Error(errors.missing(this.fullKey(key)));
183
getOptionalConfig(key: string): ConfigReader | undefined {
184
const value = this.readValue(key);
185
const fallbackConfig = this.fallback?.getOptionalConfig(key);
187
if (isObject(value)) {
188
return this.copy(value, key, fallbackConfig);
190
if (value !== undefined) {
192
errors.type(this.fullKey(key), this.context, typeOf(value), 'object'),
195
return fallbackConfig;
199
getConfigArray(key: string): ConfigReader[] {
200
const value = this.getOptionalConfigArray(key);
201
if (value === undefined) {
202
throw new Error(errors.missing(this.fullKey(key)));
208
getOptionalConfigArray(key: string): ConfigReader[] | undefined {
209
const configs = this.readConfigValue<JsonObject[]>(key, values => {
210
if (!Array.isArray(values)) {
211
return { expected: 'object-array' };
214
for (const [index, value] of values.entries()) {
215
if (!isObject(value)) {
216
return { expected: 'object-array', value, key: `${key}[${index}]` };
223
if (process.env.NODE_ENV === 'development') {
224
const fullKey = this.fullKey(key);
226
this.filteredKeys?.some(k => k.startsWith(fullKey)) &&
227
!this.notifiedFilteredKeys.has(key)
229
this.notifiedFilteredKeys.add(key);
232
`Failed to read configuration array at '${key}' as it does not have any visible elements. ` +
233
'See https://backstage.io/docs/conf/defining#visibility for instructions on how to make it visible.',
240
return configs.map((obj, index) => this.copy(obj, `${key}[${index}]`));
244
getNumber(key: string): number {
245
const value = this.getOptionalNumber(key);
246
if (value === undefined) {
247
throw new Error(errors.missing(this.fullKey(key)));
253
getOptionalNumber(key: string): number | undefined {
254
const value = this.readConfigValue<string | number>(
257
typeof val === 'number' ||
258
typeof val === 'string' || { expected: 'number' },
260
if (typeof value === 'number' || value === undefined) {
263
const number = Number(value);
264
if (!Number.isFinite(number)) {
266
errors.convert(this.fullKey(key), this.context, 'number'),
273
getBoolean(key: string): boolean {
274
const value = this.getOptionalBoolean(key);
275
if (value === undefined) {
276
throw new Error(errors.missing(this.fullKey(key)));
282
getOptionalBoolean(key: string): boolean | undefined {
283
const value = this.readConfigValue<string | number | boolean>(
286
typeof val === 'boolean' ||
287
typeof val === 'number' ||
288
typeof val === 'string' || { expected: 'boolean' },
290
if (typeof value === 'boolean' || value === undefined) {
293
const valueString = String(value).trim();
295
if (/^(?:y|yes|true|1|on)$/i.test(valueString)) {
298
if (/^(?:n|no|false|0|off)$/i.test(valueString)) {
301
throw new Error(errors.convert(this.fullKey(key), this.context, 'boolean'));
305
getString(key: string): string {
306
const value = this.getOptionalString(key);
307
if (value === undefined) {
308
throw new Error(errors.missing(this.fullKey(key)));
314
getOptionalString(key: string): string | undefined {
315
return this.readConfigValue(
318
(typeof value === 'string' && value !== '') || { expected: 'string' },
323
getStringArray(key: string): string[] {
324
const value = this.getOptionalStringArray(key);
325
if (value === undefined) {
326
throw new Error(errors.missing(this.fullKey(key)));
332
getOptionalStringArray(key: string): string[] | undefined {
333
return this.readConfigValue(key, values => {
334
if (!Array.isArray(values)) {
335
return { expected: 'string-array' };
337
for (const [index, value] of values.entries()) {
338
if (typeof value !== 'string' || value === '') {
339
return { expected: 'string-array', value, key: `${key}[${index}]` };
346
private fullKey(key: string): string {
347
return `${this.prefix}${this.prefix ? '.' : ''}${key}`;
350
private copy(data: JsonObject, key: string, fallback?: ConfigReader) {
351
const reader = new ConfigReader(
357
reader.filteredKeys = this.filteredKeys;
361
private readConfigValue<T extends JsonValue>(
365
) => { expected: string; value?: JsonValue; key?: string } | true,
367
const value = this.readValue(key);
369
if (value === undefined) {
370
if (process.env.NODE_ENV === 'development') {
371
const fullKey = this.fullKey(key);
373
this.filteredKeys?.includes(fullKey) &&
374
!this.notifiedFilteredKeys.has(fullKey)
376
this.notifiedFilteredKeys.add(fullKey);
379
`Failed to read configuration value at '${fullKey}' as it is not visible. ` +
380
'See https://backstage.io/docs/conf/defining#visibility for instructions on how to make it visible.',
385
return this.fallback?.readConfigValue(key, validate);
387
const result = validate(value);
388
if (result !== true) {
389
const { key: keyName = key, value: theValue = value, expected } = result;
392
this.fullKey(keyName),
403
private readValue(key?: string): JsonValue | undefined {
404
const parts = key ? key.split('.') : [];
405
for (const part of parts) {
406
if (!CONFIG_KEY_PART_PATTERN.test(part)) {
407
throw new TypeError(`Invalid config key '${key}'`);
411
if (this.data === undefined) {
415
let value: JsonValue | undefined = this.data;
416
for (const [index, part] of parts.entries()) {
417
if (isObject(value)) {
419
} else if (value !== undefined) {
420
const badKey = this.fullKey(parts.slice(0, index).join('.'));
422
errors.type(badKey, this.context, typeOf(value), 'object'),