backstage

Форк
0
429 строк · 13.2 Кб
1
/*
2
 * Copyright 2020 The Backstage Authors
3
 *
4
 * Licensed under the Apache License, Version 2.0 (the "License");
5
 * you may not use this file except in compliance with the License.
6
 * You may obtain a copy of the License at
7
 *
8
 *     http://www.apache.org/licenses/LICENSE-2.0
9
 *
10
 * Unless required by applicable law or agreed to in writing, software
11
 * distributed under the License is distributed on an "AS IS" BASIS,
12
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
 * See the License for the specific language governing permissions and
14
 * limitations under the License.
15
 */
16

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';
21

22
// Update the same pattern in config-loader package if this is changed
23
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
24

25
function isObject(value: JsonValue | undefined): value is JsonObject {
26
  return typeof value === 'object' && value !== null && !Array.isArray(value);
27
}
28

29
function typeOf(value: JsonValue | undefined): string {
30
  if (value === null) {
31
    return 'null';
32
  } else if (Array.isArray(value)) {
33
    return 'array';
34
  }
35
  const type = typeof value;
36
  if (type === 'number' && isNaN(value as number)) {
37
    return 'nan';
38
  }
39
  if (type === 'string' && value === '') {
40
    return 'empty-string';
41
  }
42
  return type;
43
}
44

45
// Separate out a couple of common error messages to reduce bundle size.
46
const errors = {
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}`;
49
  },
50
  missing(key: string) {
51
    return `Missing required config value at '${key}'`;
52
  },
53
  convert(key: string, context: string, expected: string) {
54
    return `Unable to convert config value for key '${key}' in '${context}' to a ${expected}`;
55
  },
56
};
57

58
/**
59
 * An implementation of the `Config` interface that uses a plain JavaScript object
60
 * for the backing data, with the ability of linking multiple readers together.
61
 *
62
 * @public
63
 */
64
export class ConfigReader implements Config {
65
  /**
66
   * A set of key paths that where removed from the config due to not being visible.
67
   *
68
   * This was added as a mutable private member to avoid changes to the public API.
69
   * Its only purpose of this is to warn users of missing visibility when running
70
   * the frontend in development mode.
71
   */
72
  private filteredKeys?: string[];
73
  private notifiedFilteredKeys = new Set<string>();
74

75
  /**
76
   * Instantiates the config reader from a list of application config objects.
77
   */
78
  static fromConfigs(configs: AppConfig[]): ConfigReader {
79
    if (configs.length === 0) {
80
      return new ConfigReader(undefined);
81
    }
82

83
    // Merge together all configs into a single config with recursive fallback
84
    // readers, giving the first config object in the array the lowest priority.
85
    return configs.reduce<ConfigReader>(
86
      (previousReader, { data, context, filteredKeys, deprecatedKeys }) => {
87
        const reader = new ConfigReader(data, context, previousReader);
88
        reader.filteredKeys = filteredKeys;
89

90
        if (deprecatedKeys) {
91
          for (const { key, description } of deprecatedKeys) {
92
            // eslint-disable-next-line no-console
93
            console.warn(
94
              `The configuration key '${key}' of ${context} is deprecated and may be removed soon. ${
95
                description || ''
96
              }`,
97
            );
98
          }
99
        }
100

101
        return reader;
102
      },
103
      undefined!,
104
    );
105
  }
106

107
  constructor(
108
    private readonly data: JsonObject | undefined,
109
    private readonly context: string = 'mock-config',
110
    private readonly fallback?: ConfigReader,
111
    private readonly prefix: string = '',
112
  ) {}
113

114
  /** {@inheritdoc Config.has} */
115
  has(key: string): boolean {
116
    const value = this.readValue(key);
117
    if (value !== undefined) {
118
      return true;
119
    }
120
    return this.fallback?.has(key) ?? false;
121
  }
122

123
  /** {@inheritdoc Config.keys} */
124
  keys(): string[] {
125
    const localKeys = this.data ? Object.keys(this.data) : [];
126
    const fallbackKeys = this.fallback?.keys() ?? [];
127
    return [...new Set([...localKeys, ...fallbackKeys])];
128
  }
129

130
  /** {@inheritdoc Config.get} */
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 ?? '')));
135
    }
136
    return value as T;
137
  }
138

139
  /** {@inheritdoc Config.getOptional} */
140
  getOptional<T = JsonValue>(key?: string): T | undefined {
141
    const value = cloneDeep(this.readValue(key));
142
    const fallbackValue = this.fallback?.getOptional<T>(key);
143

144
    if (value === undefined) {
145
      if (process.env.NODE_ENV === 'development') {
146
        if (fallbackValue === undefined && key) {
147
          const fullKey = this.fullKey(key);
148
          if (
149
            this.filteredKeys?.includes(fullKey) &&
150
            !this.notifiedFilteredKeys.has(fullKey)
151
          ) {
152
            this.notifiedFilteredKeys.add(fullKey);
153
            // eslint-disable-next-line no-console
154
            console.warn(
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.',
157
            );
158
          }
159
        }
160
      }
161
      return fallbackValue;
162
    } else if (fallbackValue === undefined) {
163
      return value as T;
164
    }
165

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;
171
  }
172

173
  /** {@inheritdoc Config.getConfig} */
174
  getConfig(key: string): ConfigReader {
175
    const value = this.getOptionalConfig(key);
176
    if (value === undefined) {
177
      throw new Error(errors.missing(this.fullKey(key)));
178
    }
179
    return value;
180
  }
181

182
  /** {@inheritdoc Config.getOptionalConfig} */
183
  getOptionalConfig(key: string): ConfigReader | undefined {
184
    const value = this.readValue(key);
185
    const fallbackConfig = this.fallback?.getOptionalConfig(key);
186

187
    if (isObject(value)) {
188
      return this.copy(value, key, fallbackConfig);
189
    }
190
    if (value !== undefined) {
191
      throw new TypeError(
192
        errors.type(this.fullKey(key), this.context, typeOf(value), 'object'),
193
      );
194
    }
195
    return fallbackConfig;
196
  }
197

198
  /** {@inheritdoc Config.getConfigArray} */
199
  getConfigArray(key: string): ConfigReader[] {
200
    const value = this.getOptionalConfigArray(key);
201
    if (value === undefined) {
202
      throw new Error(errors.missing(this.fullKey(key)));
203
    }
204
    return value;
205
  }
206

207
  /** {@inheritdoc Config.getOptionalConfigArray} */
208
  getOptionalConfigArray(key: string): ConfigReader[] | undefined {
209
    const configs = this.readConfigValue<JsonObject[]>(key, values => {
210
      if (!Array.isArray(values)) {
211
        return { expected: 'object-array' };
212
      }
213

214
      for (const [index, value] of values.entries()) {
215
        if (!isObject(value)) {
216
          return { expected: 'object-array', value, key: `${key}[${index}]` };
217
        }
218
      }
219
      return true;
220
    });
221

222
    if (!configs) {
223
      if (process.env.NODE_ENV === 'development') {
224
        const fullKey = this.fullKey(key);
225
        if (
226
          this.filteredKeys?.some(k => k.startsWith(fullKey)) &&
227
          !this.notifiedFilteredKeys.has(key)
228
        ) {
229
          this.notifiedFilteredKeys.add(key);
230
          // eslint-disable-next-line no-console
231
          console.warn(
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.',
234
          );
235
        }
236
      }
237
      return undefined;
238
    }
239

240
    return configs.map((obj, index) => this.copy(obj, `${key}[${index}]`));
241
  }
242

243
  /** {@inheritdoc Config.getNumber} */
244
  getNumber(key: string): number {
245
    const value = this.getOptionalNumber(key);
246
    if (value === undefined) {
247
      throw new Error(errors.missing(this.fullKey(key)));
248
    }
249
    return value;
250
  }
251

252
  /** {@inheritdoc Config.getOptionalNumber} */
253
  getOptionalNumber(key: string): number | undefined {
254
    const value = this.readConfigValue<string | number>(
255
      key,
256
      val =>
257
        typeof val === 'number' ||
258
        typeof val === 'string' || { expected: 'number' },
259
    );
260
    if (typeof value === 'number' || value === undefined) {
261
      return value;
262
    }
263
    const number = Number(value);
264
    if (!Number.isFinite(number)) {
265
      throw new Error(
266
        errors.convert(this.fullKey(key), this.context, 'number'),
267
      );
268
    }
269
    return number;
270
  }
271

272
  /** {@inheritdoc Config.getBoolean} */
273
  getBoolean(key: string): boolean {
274
    const value = this.getOptionalBoolean(key);
275
    if (value === undefined) {
276
      throw new Error(errors.missing(this.fullKey(key)));
277
    }
278
    return value;
279
  }
280

281
  /** {@inheritdoc Config.getOptionalBoolean} */
282
  getOptionalBoolean(key: string): boolean | undefined {
283
    const value = this.readConfigValue<string | number | boolean>(
284
      key,
285
      val =>
286
        typeof val === 'boolean' ||
287
        typeof val === 'number' ||
288
        typeof val === 'string' || { expected: 'boolean' },
289
    );
290
    if (typeof value === 'boolean' || value === undefined) {
291
      return value;
292
    }
293
    const valueString = String(value).trim();
294

295
    if (/^(?:y|yes|true|1|on)$/i.test(valueString)) {
296
      return true;
297
    }
298
    if (/^(?:n|no|false|0|off)$/i.test(valueString)) {
299
      return false;
300
    }
301
    throw new Error(errors.convert(this.fullKey(key), this.context, 'boolean'));
302
  }
303

304
  /** {@inheritdoc Config.getString} */
305
  getString(key: string): string {
306
    const value = this.getOptionalString(key);
307
    if (value === undefined) {
308
      throw new Error(errors.missing(this.fullKey(key)));
309
    }
310
    return value;
311
  }
312

313
  /** {@inheritdoc Config.getOptionalString} */
314
  getOptionalString(key: string): string | undefined {
315
    return this.readConfigValue(
316
      key,
317
      value =>
318
        (typeof value === 'string' && value !== '') || { expected: 'string' },
319
    );
320
  }
321

322
  /** {@inheritdoc Config.getStringArray} */
323
  getStringArray(key: string): string[] {
324
    const value = this.getOptionalStringArray(key);
325
    if (value === undefined) {
326
      throw new Error(errors.missing(this.fullKey(key)));
327
    }
328
    return value;
329
  }
330

331
  /** {@inheritdoc Config.getOptionalStringArray} */
332
  getOptionalStringArray(key: string): string[] | undefined {
333
    return this.readConfigValue(key, values => {
334
      if (!Array.isArray(values)) {
335
        return { expected: 'string-array' };
336
      }
337
      for (const [index, value] of values.entries()) {
338
        if (typeof value !== 'string' || value === '') {
339
          return { expected: 'string-array', value, key: `${key}[${index}]` };
340
        }
341
      }
342
      return true;
343
    });
344
  }
345

346
  private fullKey(key: string): string {
347
    return `${this.prefix}${this.prefix ? '.' : ''}${key}`;
348
  }
349

350
  private copy(data: JsonObject, key: string, fallback?: ConfigReader) {
351
    const reader = new ConfigReader(
352
      data,
353
      this.context,
354
      fallback,
355
      this.fullKey(key),
356
    );
357
    reader.filteredKeys = this.filteredKeys;
358
    return reader;
359
  }
360

361
  private readConfigValue<T extends JsonValue>(
362
    key: string,
363
    validate: (
364
      value: JsonValue,
365
    ) => { expected: string; value?: JsonValue; key?: string } | true,
366
  ): T | undefined {
367
    const value = this.readValue(key);
368

369
    if (value === undefined) {
370
      if (process.env.NODE_ENV === 'development') {
371
        const fullKey = this.fullKey(key);
372
        if (
373
          this.filteredKeys?.includes(fullKey) &&
374
          !this.notifiedFilteredKeys.has(fullKey)
375
        ) {
376
          this.notifiedFilteredKeys.add(fullKey);
377
          // eslint-disable-next-line no-console
378
          console.warn(
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.',
381
          );
382
        }
383
      }
384

385
      return this.fallback?.readConfigValue(key, validate);
386
    }
387
    const result = validate(value);
388
    if (result !== true) {
389
      const { key: keyName = key, value: theValue = value, expected } = result;
390
      throw new TypeError(
391
        errors.type(
392
          this.fullKey(keyName),
393
          this.context,
394
          typeOf(theValue),
395
          expected,
396
        ),
397
      );
398
    }
399

400
    return value as T;
401
  }
402

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}'`);
408
      }
409
    }
410

411
    if (this.data === undefined) {
412
      return undefined;
413
    }
414

415
    let value: JsonValue | undefined = this.data;
416
    for (const [index, part] of parts.entries()) {
417
      if (isObject(value)) {
418
        value = value[part];
419
      } else if (value !== undefined) {
420
        const badKey = this.fullKey(parts.slice(0, index).join('.'));
421
        throw new TypeError(
422
          errors.type(badKey, this.context, typeOf(value), 'object'),
423
        );
424
      }
425
    }
426

427
    return value;
428
  }
429
}
430

Использование cookies

Мы используем файлы cookie в соответствии с Политикой конфиденциальности и Политикой использования cookies.

Нажимая кнопку «Принимаю», Вы даете АО «СберТех» согласие на обработку Ваших персональных данных в целях совершенствования нашего веб-сайта и Сервиса GitVerse, а также повышения удобства их использования.

Запретить использование cookies Вы можете самостоятельно в настройках Вашего браузера.