From ce22236c3cba8f8add0224196c2aaf8b7914fe86 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 26 May 2020 18:14:50 +0200 Subject: [PATCH 1/8] packages/core-api: added initial ConfigApi --- .../src/apis/definitions/ConfigApi.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 packages/core-api/src/apis/definitions/ConfigApi.ts diff --git a/packages/core-api/src/apis/definitions/ConfigApi.ts b/packages/core-api/src/apis/definitions/ConfigApi.ts new file mode 100644 index 0000000000..20676df899 --- /dev/null +++ b/packages/core-api/src/apis/definitions/ConfigApi.ts @@ -0,0 +1,38 @@ +/* + * 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 { createApiRef } from '../ApiRef'; + +export type Config = { + getConfig(key: string): Config; + + getConfigArray(key: string): Config[]; + + getNumber(key: string): number | undefined; + + getBoolean(key: string): boolean | undefined; + + getString(key: string): string | undefined; + + getStringArray(key: string): string[] | undefined; +}; + +// Using interface to make the ConfigApi name show up in docs +export interface ConfigApi extends Config {} + +export const configApiRef = createApiRef({ + id: 'core.config', + description: 'Used to access runtime configuration', +}); From 0c6dbe0545360cce2ef41f0e8c600c2cc16759db Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 26 May 2020 20:46:31 +0200 Subject: [PATCH 2/8] packages/core-api: added initial ConfigReader --- .../ConfigApi/ConfigReader.test.ts | 126 ++++++++++++++ .../implementations/ConfigApi/ConfigReader.ts | 164 ++++++++++++++++++ .../apis/implementations/ConfigApi/index.ts | 17 ++ 3 files changed, 307 insertions(+) create mode 100644 packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.test.ts create mode 100644 packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.ts create mode 100644 packages/core-api/src/apis/implementations/ConfigApi/index.ts diff --git a/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.test.ts b/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.test.ts new file mode 100644 index 0000000000..206527b95d --- /dev/null +++ b/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.test.ts @@ -0,0 +1,126 @@ +/* + * 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 { ConfigReader } from './ConfigReader'; + +const DATA = { + zero: 0, + one: 1, + true: true, + false: false, + null: null, + string: 'string', + emptyString: '', + strings: ['string1', 'string2'], + badStrings: ['string1', ''], + worseStrings: ['string1', 3] as string[], + worstStrings: ['string1', 'string2', {}] as string[], + nested: { + one: 1, + string: 'string', + strings: ['string1', 'string2'], + }, + nestlings: [{ boolean: true }, { string: 'string' }, { number: 42 }] as {}[], +}; + +describe('ConfigReader', () => { + it('should read empty config with valid keys', () => { + const config = new ConfigReader({}); + expect(config.getString('x')).toBeUndefined(); + expect(config.getString('x_x')).toBeUndefined(); + expect(config.getString('x-X')).toBeUndefined(); + expect(config.getString('x0')).toBeUndefined(); + expect(config.getString('X-x2')).toBeUndefined(); + expect(config.getString('x0_x0')).toBeUndefined(); + expect(config.getString('x_x-x_x')).toBeUndefined(); + }); + + it('should throw on invalid keys', () => { + const config = new ConfigReader({}); + + expect(() => config.getString('.')).toThrow(/^Invalid config key/); + expect(() => config.getString('0')).toThrow(/^Invalid config key/); + expect(() => config.getString('(')).toThrow(/^Invalid config key/); + expect(() => config.getString('z-_')).toThrow(/^Invalid config key/); + expect(() => config.getString('-')).toThrow(/^Invalid config key/); + expect(() => config.getString('.a')).toThrow(/^Invalid config key/); + expect(() => config.getString('0.a')).toThrow(/^Invalid config key/); + expect(() => config.getString('0a')).toThrow(/^Invalid config key/); + expect(() => config.getString('a.0a')).toThrow(/^Invalid config key/); + expect(() => config.getString('a..a')).toThrow(/^Invalid config key/); + expect(() => config.getString('a.')).toThrow(/^Invalid config key/); + expect(() => config.getString('a...')).toThrow(/^Invalid config key/); + expect(() => config.getString('a.a.a.a.')).toThrow(/^Invalid config key/); + expect(() => config.getString('a._')).toThrow(/^Invalid config key/); + expect(() => config.getString('a.-.a')).toThrow(/^Invalid config key/); + }); + + it('should read valid values', () => { + const config = new ConfigReader(DATA); + expect(config.getNumber('zero')).toBe(0); + expect(config.getNumber('one')).toBe(1); + expect(config.getBoolean('true')).toBe(true); + expect(config.getBoolean('false')).toBe(false); + expect(config.getString('string')).toBe('string'); + expect(config.getStringArray('strings')).toEqual(['string1', 'string2']); + expect(config.getConfig('nested').getNumber('one')).toBe(1); + expect(config.getConfig('nested').getString('string')).toBe('string'); + expect(config.getConfig('nested').getStringArray('strings')).toEqual([ + 'string1', + 'string2', + ]); + + const [config1, config2, config3] = config.getConfigArray('nestlings'); + expect(config1.getBoolean('boolean')).toBe(true); + expect(config2.getString('string')).toBe('string'); + expect(config3.getNumber('number')).toBe(42); + }); + + it('should fail to read invalid values', () => { + const config = new ConfigReader(DATA); + + expect(() => config.getNumber('string')).toThrow( + 'Invalid type in config for key string, got string, wanted number', + ); + expect(() => config.getString('one')).toThrow( + 'Invalid type in config for key one, got number, wanted string', + ); + expect(() => config.getNumber('true')).toThrow( + 'Invalid type in config for key true, got boolean, wanted number', + ); + expect(() => config.getStringArray('null')).toThrow( + 'Invalid type in config for key null, got null, wanted string-array', + ); + expect(() => config.getString('emptyString')).toThrow( + 'Invalid type in config for key emptyString, got empty-string, wanted string', + ); + expect(() => config.getStringArray('badStrings')).toThrow( + 'Invalid type in config for key badStrings[1], got empty-string, wanted string', + ); + expect(() => config.getStringArray('worseStrings')).toThrow( + 'Invalid type in config for key worseStrings[1], got number, wanted string', + ); + expect(() => config.getStringArray('worstStrings')).toThrow( + 'Invalid type in config for key worstStrings[2], got object, wanted string', + ); + expect(() => config.getConfig('one')).toThrow( + 'Invalid type in config for key one, got number, wanted object', + ); + expect(() => config.getConfigArray('one')).toThrow( + 'Invalid type in config for key one, got number, wanted object-array', + ); + }); +}); diff --git a/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.ts b/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.ts new file mode 100644 index 0000000000..ac725ba59d --- /dev/null +++ b/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.ts @@ -0,0 +1,164 @@ +/* + * 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 { ConfigApi, Config } from '../../definitions/ConfigApi'; + +const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i; + +type JsonObject = { [key in string]: JsonValue }; +type JsonArray = JsonValue[]; +type JsonValue = JsonObject | JsonArray | number | string | boolean | null; + +function isObject(value: JsonValue | undefined): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function typeOf(value: JsonValue | undefined): string { + if (value === null) { + return 'null'; + } else if (Array.isArray(value)) { + return 'array'; + } + const type = typeof value; + if (type === 'number' && isNaN(value as number)) { + return 'nan'; + } + return type; +} + +function typeErrorMessage(key: string, got: string, wanted: string) { + return `Invalid type in config for key ${key}, got ${got}, wanted ${wanted}`; +} + +function validateString( + key: string, + value: JsonValue | undefined, +): value is string { + if (typeof value === 'string' && value.length > 0) { + return true; + } + if (value === '') { + throw new TypeError(typeErrorMessage(key, 'empty-string', 'string')); + } + if (value !== undefined) { + throw new TypeError(typeErrorMessage(key, typeOf(value), 'string')); + } + return false; +} + +export class ConfigReader implements ConfigApi { + static nullReader = new ConfigReader({}); + + constructor(private readonly data: JsonObject) {} + + getConfig(key: string): Config { + const value = this.readValue(key); + if (isObject(value)) { + return new ConfigReader(value); + } + if (value !== undefined) { + throw new TypeError(typeErrorMessage(key, typeOf(value), 'object')); + } + return ConfigReader.nullReader; + } + + getConfigArray(key: string): Config[] { + const values = this.readValue(key); + if (Array.isArray(values)) { + return values.map((value, index) => { + if (isObject(value)) { + return new ConfigReader(value); + } + throw new TypeError( + typeErrorMessage(`${key}[${index}]`, typeOf(value), 'object'), + ); + }); + } + if (values !== undefined) { + throw new TypeError( + typeErrorMessage(key, typeOf(values), 'object-array'), + ); + } + return []; + } + + getNumber(key: string): number | undefined { + const value = this.readValue(key); + if (typeof value === 'number' && !isNaN(value)) { + return value; + } + if (value !== undefined) { + throw new TypeError(typeErrorMessage(key, typeOf(value), 'number')); + } + return undefined; + } + + getBoolean(key: string): boolean | undefined { + const value = this.readValue(key); + if (typeof value === 'boolean') { + return value; + } + if (value !== undefined) { + throw new TypeError(typeErrorMessage(key, typeOf(value), 'boolean')); + } + return undefined; + } + + getString(key: string): string | undefined { + const value = this.readValue(key); + if (validateString(key, value)) { + return value; + } + return undefined; + } + + getStringArray(key: string): string[] | undefined { + const values = this.readValue(key); + if (Array.isArray(values)) { + for (const [index, value] of values.entries()) { + const iKey = `${key}[${index}]`; + if (!validateString(iKey, value)) { + throw new TypeError(typeErrorMessage(iKey, typeOf(value), 'string')); + } + } + return values as string[]; + } + if (values !== undefined) { + throw new TypeError( + typeErrorMessage(key, typeOf(values), 'string-array'), + ); + } + return undefined; + } + + private readValue(key: string): JsonValue | undefined { + const parts = key.split('.'); + + let value: JsonValue | undefined = this.data; + for (const part of parts) { + if (!CONFIG_KEY_PART_PATTERN.test(part)) { + throw new TypeError(`Invalid config key '${key}'`); + } + if (isObject(value)) { + value = value[part]; + } else { + value = undefined; + } + } + + return value; + } +} diff --git a/packages/core-api/src/apis/implementations/ConfigApi/index.ts b/packages/core-api/src/apis/implementations/ConfigApi/index.ts new file mode 100644 index 0000000000..8839cb948e --- /dev/null +++ b/packages/core-api/src/apis/implementations/ConfigApi/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { ConfigReader } from './ConfigReader'; From d471182708b55fd617008416da6908c724e33687 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 May 2020 12:57:33 +0200 Subject: [PATCH 3/8] packages/core-api: add fallback capability to ConfigReader --- .../ConfigApi/ConfigReader.test.ts | 133 +++++++++++------- .../implementations/ConfigApi/ConfigReader.ts | 20 +-- 2 files changed, 97 insertions(+), 56 deletions(-) diff --git a/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.test.ts b/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.test.ts index 206527b95d..a9731c0d2c 100644 --- a/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.test.ts +++ b/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.test.ts @@ -36,6 +36,59 @@ const DATA = { nestlings: [{ boolean: true }, { string: 'string' }, { number: 42 }] as {}[], }; +function expectValidValues(config: ConfigReader) { + expect(config.getNumber('zero')).toBe(0); + expect(config.getNumber('one')).toBe(1); + expect(config.getBoolean('true')).toBe(true); + expect(config.getBoolean('false')).toBe(false); + expect(config.getString('string')).toBe('string'); + expect(config.getStringArray('strings')).toEqual(['string1', 'string2']); + expect(config.getConfig('nested').getNumber('one')).toBe(1); + expect(config.getConfig('nested').getString('string')).toBe('string'); + expect(config.getConfig('nested').getStringArray('strings')).toEqual([ + 'string1', + 'string2', + ]); + + const [config1, config2, config3] = config.getConfigArray('nestlings'); + expect(config1.getBoolean('boolean')).toBe(true); + expect(config2.getString('string')).toBe('string'); + expect(config3.getNumber('number')).toBe(42); +} + +function expectInvalidValues(config: ConfigReader) { + expect(() => config.getNumber('string')).toThrow( + 'Invalid type in config for key string, got string, wanted number', + ); + expect(() => config.getString('one')).toThrow( + 'Invalid type in config for key one, got number, wanted string', + ); + expect(() => config.getNumber('true')).toThrow( + 'Invalid type in config for key true, got boolean, wanted number', + ); + expect(() => config.getStringArray('null')).toThrow( + 'Invalid type in config for key null, got null, wanted string-array', + ); + expect(() => config.getString('emptyString')).toThrow( + 'Invalid type in config for key emptyString, got empty-string, wanted string', + ); + expect(() => config.getStringArray('badStrings')).toThrow( + 'Invalid type in config for key badStrings[1], got empty-string, wanted string', + ); + expect(() => config.getStringArray('worseStrings')).toThrow( + 'Invalid type in config for key worseStrings[1], got number, wanted string', + ); + expect(() => config.getStringArray('worstStrings')).toThrow( + 'Invalid type in config for key worstStrings[2], got object, wanted string', + ); + expect(() => config.getConfig('one')).toThrow( + 'Invalid type in config for key one, got number, wanted object', + ); + expect(() => config.getConfigArray('one')).toThrow( + 'Invalid type in config for key one, got number, wanted object-array', + ); +} + describe('ConfigReader', () => { it('should read empty config with valid keys', () => { const config = new ConfigReader({}); @@ -70,57 +123,41 @@ describe('ConfigReader', () => { it('should read valid values', () => { const config = new ConfigReader(DATA); - expect(config.getNumber('zero')).toBe(0); - expect(config.getNumber('one')).toBe(1); - expect(config.getBoolean('true')).toBe(true); - expect(config.getBoolean('false')).toBe(false); - expect(config.getString('string')).toBe('string'); - expect(config.getStringArray('strings')).toEqual(['string1', 'string2']); - expect(config.getConfig('nested').getNumber('one')).toBe(1); - expect(config.getConfig('nested').getString('string')).toBe('string'); - expect(config.getConfig('nested').getStringArray('strings')).toEqual([ - 'string1', - 'string2', - ]); - - const [config1, config2, config3] = config.getConfigArray('nestlings'); - expect(config1.getBoolean('boolean')).toBe(true); - expect(config2.getString('string')).toBe('string'); - expect(config3.getNumber('number')).toBe(42); + expectValidValues(config); }); it('should fail to read invalid values', () => { const config = new ConfigReader(DATA); - - expect(() => config.getNumber('string')).toThrow( - 'Invalid type in config for key string, got string, wanted number', - ); - expect(() => config.getString('one')).toThrow( - 'Invalid type in config for key one, got number, wanted string', - ); - expect(() => config.getNumber('true')).toThrow( - 'Invalid type in config for key true, got boolean, wanted number', - ); - expect(() => config.getStringArray('null')).toThrow( - 'Invalid type in config for key null, got null, wanted string-array', - ); - expect(() => config.getString('emptyString')).toThrow( - 'Invalid type in config for key emptyString, got empty-string, wanted string', - ); - expect(() => config.getStringArray('badStrings')).toThrow( - 'Invalid type in config for key badStrings[1], got empty-string, wanted string', - ); - expect(() => config.getStringArray('worseStrings')).toThrow( - 'Invalid type in config for key worseStrings[1], got number, wanted string', - ); - expect(() => config.getStringArray('worstStrings')).toThrow( - 'Invalid type in config for key worstStrings[2], got object, wanted string', - ); - expect(() => config.getConfig('one')).toThrow( - 'Invalid type in config for key one, got number, wanted object', - ); - expect(() => config.getConfigArray('one')).toThrow( - 'Invalid type in config for key one, got number, wanted object-array', - ); + expectInvalidValues(config); + }); +}); + +describe('ConfigReader with fallback', () => { + it('should behave as if without fallback', () => { + const config = new ConfigReader({}, new ConfigReader(DATA)); + expect(config.getString('x')).toBeUndefined(); + expect(() => config.getString('.')).toThrow(/^Invalid config key/); + expect(() => config.getString('a.')).toThrow(/^Invalid config key/); + }); + + it('should read values from itself', () => { + const config = new ConfigReader(DATA, new ConfigReader({})); + expectValidValues(config); + expectInvalidValues(config); + }); + + it('should read values from a fallback', () => { + const config = new ConfigReader({}, new ConfigReader(DATA)); + expectValidValues(config); + expectInvalidValues(config); + }); + + it('should read values from multiple levels of fallbacks', () => { + const config = new ConfigReader( + {}, + new ConfigReader({}, new ConfigReader({}, new ConfigReader(DATA))), + ); + expectValidValues(config); + expectInvalidValues(config); }); }); diff --git a/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.ts b/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.ts index ac725ba59d..7a5cf3b185 100644 --- a/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.ts +++ b/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.ts @@ -62,17 +62,21 @@ function validateString( export class ConfigReader implements ConfigApi { static nullReader = new ConfigReader({}); - constructor(private readonly data: JsonObject) {} + constructor( + private readonly data: JsonObject, + private readonly fallback?: ConfigApi, + ) {} getConfig(key: string): Config { const value = this.readValue(key); + const fallbackConfig = this.fallback?.getConfig(key); if (isObject(value)) { - return new ConfigReader(value); + return new ConfigReader(value, fallbackConfig); } if (value !== undefined) { throw new TypeError(typeErrorMessage(key, typeOf(value), 'object')); } - return ConfigReader.nullReader; + return fallbackConfig ?? ConfigReader.nullReader; } getConfigArray(key: string): Config[] { @@ -92,7 +96,7 @@ export class ConfigReader implements ConfigApi { typeErrorMessage(key, typeOf(values), 'object-array'), ); } - return []; + return this.fallback?.getConfigArray(key) ?? []; } getNumber(key: string): number | undefined { @@ -103,7 +107,7 @@ export class ConfigReader implements ConfigApi { if (value !== undefined) { throw new TypeError(typeErrorMessage(key, typeOf(value), 'number')); } - return undefined; + return this.fallback?.getNumber(key); } getBoolean(key: string): boolean | undefined { @@ -114,7 +118,7 @@ export class ConfigReader implements ConfigApi { if (value !== undefined) { throw new TypeError(typeErrorMessage(key, typeOf(value), 'boolean')); } - return undefined; + return this.fallback?.getBoolean(key); } getString(key: string): string | undefined { @@ -122,7 +126,7 @@ export class ConfigReader implements ConfigApi { if (validateString(key, value)) { return value; } - return undefined; + return this.fallback?.getString(key); } getStringArray(key: string): string[] | undefined { @@ -141,7 +145,7 @@ export class ConfigReader implements ConfigApi { typeErrorMessage(key, typeOf(values), 'string-array'), ); } - return undefined; + return this.fallback?.getStringArray(key); } private readValue(key: string): JsonValue | undefined { From 810de67f9c7a378d587c303bc271746a6bdab7f0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 May 2020 13:16:59 +0200 Subject: [PATCH 4/8] packages/core-api: some more tests for ConfigReader to make sure deep merge is correct --- .../ConfigApi/ConfigReader.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.test.ts b/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.test.ts index a9731c0d2c..68c1fd5353 100644 --- a/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.test.ts +++ b/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.test.ts @@ -160,4 +160,67 @@ describe('ConfigReader with fallback', () => { expectValidValues(config); expectInvalidValues(config); }); + + it('should read merged objects', () => { + const a = { + merged: { + x: 'x', + z: 'z1', + arr: ['a', 'b'], + config: { d: 'd' }, + configs: [{ a: 'a' }], + }, + }; + const b = { + merged: { + y: 'y', + z: 'z2', + arr: ['c'], + config: { e: 'e' }, + configs: [{ b: 'b' }], + }, + }; + + const config = new ConfigReader(a, new ConfigReader(b)); + + expect(config.getString('merged.x')).toBe('x'); + expect(config.getString('merged.y')).toBe('y'); + expect(config.getString('merged.z')).toBe('z1'); + expect(config.getConfig('merged').getString('x')).toBe('x'); + expect(config.getConfig('merged').getString('y')).toBe('y'); + expect(config.getConfig('merged').getString('z')).toBe('z1'); + expect(config.getString('merged.config.d')).toBe('d'); + expect(config.getString('merged.config.e')).toBe('e'); + expect(config.getConfig('merged').getString('config.d')).toBe('d'); + expect(config.getConfig('merged').getString('config.e')).toBe('e'); + expect(config.getConfig('merged').getConfig('config').getString('d')).toBe( + 'd', + ); + expect(config.getConfig('merged').getConfig('config').getString('e')).toBe( + 'e', + ); + + // Arrays are not merged + expect(config.getStringArray('merged.arr')).toEqual(['a', 'b']); + expect(config.getConfig('merged').getStringArray('arr')).toEqual([ + 'a', + 'b', + ]); + + // Config arrays aren't merged either + expect(config.getConfigArray('merged.configs').length).toBe(1); + expect(config.getConfigArray('merged.configs')[0].getString('a')).toBe('a'); + expect( + config.getConfigArray('merged.configs')[0].getString('b'), + ).toBeUndefined(); + + // Config arrays aren't merged either + expect(config.getConfig('merged').getConfigArray('configs').length).toBe(1); + expect( + config.getConfig('merged').getConfigArray('configs')[0].getString('a'), + ).toBe('a'); + expect( + config.getConfig('merged').getConfigArray('configs')[0].getString('b'), + ).toBeUndefined(); + }); }); From 1ab6499bbbe1abede1b0d3ab1751557f72a6228c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 May 2020 13:53:06 +0200 Subject: [PATCH 5/8] packages/core-api: export ConfigApi + implementation --- packages/core-api/src/apis/definitions/index.ts | 1 + packages/core-api/src/apis/implementations/index.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/core-api/src/apis/definitions/index.ts b/packages/core-api/src/apis/definitions/index.ts index 2e008965cd..2e9db325dc 100644 --- a/packages/core-api/src/apis/definitions/index.ts +++ b/packages/core-api/src/apis/definitions/index.ts @@ -24,6 +24,7 @@ export * from './auth'; export * from './AlertApi'; export * from './AppThemeApi'; +export * from './ConfigApi'; export * from './ErrorApi'; export * from './FeatureFlagsApi'; export * from './OAuthRequestApi'; diff --git a/packages/core-api/src/apis/implementations/index.ts b/packages/core-api/src/apis/implementations/index.ts index bb77cf5bd3..b5cc250ae4 100644 --- a/packages/core-api/src/apis/implementations/index.ts +++ b/packages/core-api/src/apis/implementations/index.ts @@ -22,5 +22,6 @@ export * from './auth'; export * from './AlertApi'; export * from './AppThemeApi'; +export * from './ConfigApi'; export * from './ErrorApi'; export * from './OAuthRequestApi'; From bdf32a00a031bc6cf67bb73940138871e16f1abe Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 May 2020 14:16:43 +0200 Subject: [PATCH 6/8] packages/core-api: add configLoader option to App --- packages/core-api/src/app/App.tsx | 44 ++++++++++++++------ packages/core-api/src/app/types.ts | 27 ++++++++++++ packages/core/src/api-wrappers/createApp.tsx | 27 +++++++++++- 3 files changed, 84 insertions(+), 14 deletions(-) diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 844c38ecce..c3ea77d87f 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -17,7 +17,7 @@ import React, { ComponentType, FC } from 'react'; import { Route, Switch, Redirect } from 'react-router-dom'; import { AppContextProvider } from './AppContext'; -import { BackstageApp, AppComponents } from './types'; +import { BackstageApp, AppComponents, AppConfigLoader } from './types'; import { BackstagePlugin } from '../plugin'; import { FeatureFlagsRegistryItem } from './FeatureFlags'; import { featureFlagsApiRef } from '../apis/definitions'; @@ -31,8 +31,11 @@ import { AppTheme, AppThemeSelector, appThemeApiRef, + configApiRef, + ConfigReader, } from '../apis'; import { ApiAggregator } from '../apis/ApiAggregator'; +import { useAsync } from 'react-use'; type FullAppOptions = { apis: ApiHolder; @@ -40,6 +43,7 @@ type FullAppOptions = { plugins: BackstagePlugin[]; components: AppComponents; themes: AppTheme[]; + configLoader: AppConfigLoader; }; export class PrivateAppImpl implements BackstageApp { @@ -48,6 +52,7 @@ export class PrivateAppImpl implements BackstageApp { private readonly plugins: BackstagePlugin[]; private readonly components: AppComponents; private readonly themes: AppTheme[]; + private readonly configLoader: AppConfigLoader; constructor(options: FullAppOptions) { this.apis = options.apis; @@ -55,6 +60,7 @@ export class PrivateAppImpl implements BackstageApp { this.plugins = options.plugins; this.components = options.components; this.themes = options.themes; + this.configLoader = options.configLoader; } getApis(): ApiHolder { @@ -141,18 +147,32 @@ export class PrivateAppImpl implements BackstageApp { } getProvider(): ComponentType<{}> { - const appApis = ApiRegistry.from([ - [appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)], - ]); - const apis = new ApiAggregator(this.apis, appApis); + const Provider: FC<{}> = ({ children }) => { + const config = useAsync(this.configLoader); + if (config.loading) { + return null; + } - const Provider: FC<{}> = ({ children }) => ( - - - {children} - - - ); + let errorPage = undefined; + if (config.error) { + const { BootErrorPage } = this.components; + errorPage = ; + } + + const appApis = ApiRegistry.from([ + [appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)], + [configApiRef, new ConfigReader(config.value ?? {})], + ]); + const apis = new ApiAggregator(this.apis, appApis); + + return ( + + + {errorPage ?? children} + + + ); + }; return Provider; } diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index ea3a812557..e43a625569 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -20,10 +20,26 @@ import { BackstagePlugin } from '../plugin'; import { ApiHolder } from '../apis'; import { AppTheme } from '../apis/definitions'; +export type BootErrorPageProps = { + step: 'load-config'; + error: Error; +}; + export type AppComponents = { NotFoundErrorPage: ComponentType<{}>; + BootErrorPage: ComponentType; }; +/** + * TBD + */ +export type AppConfig = any; + +/** + * A function that loads in the App config that will be accessible via the ConfigApi. + */ +export type AppConfigLoader = () => Promise; + export type AppOptions = { /** * A holder of all APIs available in the app. @@ -68,6 +84,17 @@ export type AppOptions = { * ``` */ themes?: AppTheme[]; + + /** + * A function that loads in App configuration that will be accessible via + * the ConfigApi. + * + * Defaults to an empty config. + * + * TODO(Rugvip): Omitting this should instead default to loading in configuration + * that was packaged by the backstage-cli and default docker container boot script. + */ + configLoader?: AppConfigLoader; }; export type BackstageApp = { diff --git a/packages/core/src/api-wrappers/createApp.tsx b/packages/core/src/api-wrappers/createApp.tsx index 03adc41ec6..cc083b0e24 100644 --- a/packages/core/src/api-wrappers/createApp.tsx +++ b/packages/core/src/api-wrappers/createApp.tsx @@ -14,12 +14,14 @@ * limitations under the License. */ -import React from 'react'; +import React, { FC } from 'react'; import privateExports, { AppOptions, ApiRegistry, defaultSystemIcons, + BootErrorPageProps, } from '@backstage/core-api'; +import { BrowserRouter as Router } from 'react-router-dom'; import { ErrorPage } from '../layout/ErrorPage'; import { lightTheme, darkTheme } from '@backstage/theme'; @@ -38,12 +40,25 @@ export function createApp(options?: AppOptions) { const DefaultNotFoundPage = () => ( ); + const DefaultBootErrorPage: FC = ({ step, error }) => { + let message = ''; + if (step === 'load-config') { + message = `The configuration failed to load, someone should have a look at this error: ${error.message}`; + } + // TODO: figure out a nicer way to handle routing on the error page, when it can be done. + return ( + + + + ); + }; const apis = options?.apis ?? ApiRegistry.from([]); const icons = { ...defaultSystemIcons, ...options?.icons }; const plugins = options?.plugins ?? []; const components = { NotFoundErrorPage: DefaultNotFoundPage, + BootErrorPage: DefaultBootErrorPage, ...options?.components, }; const themes = options?.themes ?? [ @@ -60,8 +75,16 @@ export function createApp(options?: AppOptions) { theme: darkTheme, }, ]; + const configLoader = options?.configLoader ?? (async () => ({})); - const app = new PrivateAppImpl({ apis, icons, plugins, components, themes }); + const app = new PrivateAppImpl({ + apis, + icons, + plugins, + components, + themes, + configLoader, + }); app.verify(); From a115bd63311f8134be0d173d8918af22052d1068 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 May 2020 14:26:21 +0200 Subject: [PATCH 7/8] packages/app: add mock config loader and use in welcome plugin --- packages/app/src/App.tsx | 12 ++++++++++++ .../src/components/WelcomePage/WelcomePage.test.tsx | 13 +++++++++++-- .../src/components/WelcomePage/WelcomePage.tsx | 5 ++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 56faf4402b..461d8f193b 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -29,6 +29,18 @@ import apis from './apis'; const app = createApp({ apis, plugins: Object.values(plugins), + configLoader: async () => ({ + app: { + title: 'Backstage Example App', + baseUrl: 'http://localhost:3000', + }, + backend: { + baseUrl: 'http://localhost:7000', + }, + organization: { + name: 'Spotify', + }, + }), }); const AppProvider = app.getProvider(); diff --git a/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx b/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx index 9cba76fddc..b639da9d50 100644 --- a/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx +++ b/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx @@ -19,14 +19,23 @@ import { render } from '@testing-library/react'; import WelcomePage from './WelcomePage'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; -import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; +import { + ApiProvider, + ApiRegistry, + errorApiRef, + configApiRef, + ConfigReader, +} from '@backstage/core'; describe('WelcomePage', () => { it('should render', () => { // TODO: use common test app with mock implementations of all core APIs const rendered = render( diff --git a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx index 1036096ae5..6a1b0e0008 100644 --- a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx +++ b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx @@ -34,15 +34,18 @@ import { ContentHeader, SupportButton, WarningPanel, + useApi, + configApiRef, } from '@backstage/core'; const WelcomePage: FC<{}> = () => { + const appTitle = useApi(configApiRef).getString('app.title') ?? 'Backstage'; const profile = { givenName: '' }; return (
From d120e5f8cb9116aa89eb1c479939ed410ce29c29 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 May 2020 19:55:02 +0200 Subject: [PATCH 8/8] packages/core-api: add Progress as a configurable app component --- packages/core-api/src/app/App.tsx | 15 ++++++++------- packages/core-api/src/app/types.ts | 1 + packages/core/src/api-wrappers/createApp.tsx | 2 ++ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index c3ea77d87f..49d2291acd 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -149,14 +149,15 @@ export class PrivateAppImpl implements BackstageApp { getProvider(): ComponentType<{}> { const Provider: FC<{}> = ({ children }) => { const config = useAsync(this.configLoader); - if (config.loading) { - return null; - } - let errorPage = undefined; - if (config.error) { + let childNode = children; + + if (config.loading) { + const { Progress } = this.components; + childNode = ; + } else if (config.error) { const { BootErrorPage } = this.components; - errorPage = ; + childNode = ; } const appApis = ApiRegistry.from([ @@ -168,7 +169,7 @@ export class PrivateAppImpl implements BackstageApp { return ( - {errorPage ?? children} + {childNode} ); diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index e43a625569..defc155a82 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -28,6 +28,7 @@ export type BootErrorPageProps = { export type AppComponents = { NotFoundErrorPage: ComponentType<{}>; BootErrorPage: ComponentType; + Progress: ComponentType<{}>; }; /** diff --git a/packages/core/src/api-wrappers/createApp.tsx b/packages/core/src/api-wrappers/createApp.tsx index cc083b0e24..7c605c4e18 100644 --- a/packages/core/src/api-wrappers/createApp.tsx +++ b/packages/core/src/api-wrappers/createApp.tsx @@ -24,6 +24,7 @@ import privateExports, { import { BrowserRouter as Router } from 'react-router-dom'; import { ErrorPage } from '../layout/ErrorPage'; +import Progress from '../components/Progress'; import { lightTheme, darkTheme } from '@backstage/theme'; const { PrivateAppImpl } = privateExports; @@ -59,6 +60,7 @@ export function createApp(options?: AppOptions) { const components = { NotFoundErrorPage: DefaultNotFoundPage, BootErrorPage: DefaultBootErrorPage, + Progress: Progress, ...options?.components, }; const themes = options?.themes ?? [