From 650779412c93b86923d7ca05f15bce7e543da1f9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 6 Jun 2020 17:02:31 +0200 Subject: [PATCH] packages/config-loader: add build- and dev-time config through env + tests --- packages/config-loader/src/loader.test.ts | 106 ++++++++++++++++++++++ packages/config-loader/src/loader.ts | 73 ++++++++++++++- packages/config/src/reader.ts | 1 + 3 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 packages/config-loader/src/loader.test.ts diff --git a/packages/config-loader/src/loader.test.ts b/packages/config-loader/src/loader.test.ts new file mode 100644 index 0000000000..4c7a120c63 --- /dev/null +++ b/packages/config-loader/src/loader.test.ts @@ -0,0 +1,106 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { readEnv } from './loader'; + +describe('readEnv', () => { + it('should return empty config for empty env', () => { + expect(readEnv({})).toEqual([]); + }); + + it('should return empty config for no matching keys', () => { + expect( + readEnv({ + NODE_ENV: 'production', + NOPE_ENV: 'development', + APP_CONFIG: 'foo', + APP__CONFIG_derp: 'herp', + }), + ).toEqual([]); + }); + + it('should create config from env', () => { + expect( + readEnv({ + NODE_ENV: 'production', + APP_CONFIG_foo: '"bar"', + APP_CONFIG_numbers_a: '1', + APP_CONFIG_numbers_b: '2', + APP_CONFIG_numbers_c: 'false', + APP_CONFIG_numbers_d: undefined, + APP_CONFIG_very_deep_nested_config_object: '{}', + }), + ).toEqual([ + { + foo: 'bar', + numbers: { a: 1, b: 2, c: false }, + very: { deep: { nested: { config: { object: {} } } } }, + }, + ]); + }); + + it.each([ + ['APP_CONFIG__foo'], + ['APP_CONFIG_foo_'], + ['APP_CONFIG_fo_0'], + ['APP_CONFIG_fo/o'], + ['APP_CONFIG_fo o'], + ['APP_CONFIG_foo_(foo)_foo'], + ])('should reject invalid key %p', key => { + expect(() => readEnv({ [key]: '0' })).toThrow( + `Invalid env config key '${key.replace('APP_CONFIG_', '')}'`, + ); + }); + + it.each([['hello'], ['"hello'], ['{'], ['}'], ['123abc']])( + 'should reject invalid value %p', + value => { + expect(() => readEnv({ APP_CONFIG_foo: value })).toThrow( + /^Failed to parse JSON-serialized config value for key 'foo', SyntaxError: /, + ); + }, + ); + + it('should not allow null as a value', () => { + expect(() => + readEnv({ + APP_CONFIG_foo: 'null', + }), + ).toThrow( + "Failed to parse JSON-serialized config value for key 'foo', Error: value may not be null", + ); + }); + + it('should not allow duplicate values', () => { + expect(() => + readEnv({ + APP_CONFIG_foo_bar: '1', + APP_CONFIG_foo_bar_baz: '2', + }), + ).toThrow( + "Could not nest config for key 'foo_bar_baz' under existing value 'foo_bar'", + ); + }); + + it('should not allow mixing of objects and other values', () => { + expect(() => + readEnv({ + APP_CONFIG_nested_foo: '1', + APP_CONFIG_nested: '2', + }), + ).toThrow("Refusing to override existing config at key 'nested'"); + }); +}); diff --git a/packages/config-loader/src/loader.ts b/packages/config-loader/src/loader.ts index 006d241e8a..c8b5bb538d 100644 --- a/packages/config-loader/src/loader.ts +++ b/packages/config-loader/src/loader.ts @@ -17,12 +17,68 @@ import fs from 'fs-extra'; import yaml from 'yaml'; import { resolve as resolvePath } from 'path'; -import { AppConfig } from '@backstage/config'; +import { AppConfig, JsonObject } from '@backstage/config'; import { findRootPath } from './paths'; import { LoadConfigOptions } from './types'; -export async function loadConfig( - options: LoadConfigOptions = {}, +const ENV_PREFIX = 'APP_CONFIG_'; + +// Update the same pattern in config package if this is changed +const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i; + +export function readEnv(env: { + [name: string]: string | undefined; +}): AppConfig[] { + let config: JsonObject | undefined = undefined; + + for (const [name, value] of Object.entries(env)) { + if (!value) { + continue; + } + if (name.startsWith(ENV_PREFIX)) { + const key = name.replace(ENV_PREFIX, ''); + const keyParts = key.split('_'); + + let obj = (config = config ?? {}); + for (const [index, part] of keyParts.entries()) { + if (!CONFIG_KEY_PART_PATTERN.test(part)) { + throw new TypeError(`Invalid env config key '${key}'`); + } + if (index < keyParts.length - 1) { + obj = (obj[part] = obj[part] ?? {}) as JsonObject; + if (typeof obj !== 'object' || Array.isArray(obj)) { + const subKey = keyParts.slice(0, index + 1).join('_'); + throw new TypeError( + `Could not nest config for key '${key}' under existing value '${subKey}'`, + ); + } + } else { + if (part in obj) { + throw new TypeError( + `Refusing to override existing config at key '${key}'`, + ); + } + try { + const parsedValue = JSON.parse(value); + if (parsedValue === null) { + throw new Error('value may not be null'); + } + obj[part] = parsedValue; + } catch (error) { + throw new TypeError( + `Failed to parse JSON-serialized config value for key '${key}', ${error}`, + ); + } + } + } + } + } + + return config ? [config] : []; +} + +export async function readStaticConfig( + options: LoadConfigOptions, ): Promise { // TODO: We'll want this to be a bit more elaborate, probably adding configs for // specific env, and maybe local config for plugins. @@ -42,3 +98,14 @@ export async function loadConfig( throw new Error(`Failed to read static configuration file, ${error}`); } } + +export async function loadConfig( + options: LoadConfigOptions = {}, +): Promise { + const configs = []; + + configs.push(...readEnv(process.env)); + configs.push(...(await readStaticConfig(options))); + + return configs; +} diff --git a/packages/config/src/reader.ts b/packages/config/src/reader.ts index dfdf4cd921..95c4f47383 100644 --- a/packages/config/src/reader.ts +++ b/packages/config/src/reader.ts @@ -16,6 +16,7 @@ import { AppConfig, Config, JsonValue, JsonObject } from './types'; +// Update the same pattern in config-loader package if this is changed const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i; function isObject(value: JsonValue | undefined): value is JsonObject {