From 8bb3c0a578f417447697ce29adfb9163e4bcdeb8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 25 Sep 2021 13:45:18 +0200 Subject: [PATCH] backend-common: enable subscriptions for sub configs Signed-off-by: Patrik Oldsberg --- .changeset/angry-bobcats-sit.md | 5 + packages/backend-common/src/config.test.ts | 135 +++++++++++++++++++++ packages/backend-common/src/config.ts | 67 +++++++--- packages/backend-common/src/index.ts | 2 +- 4 files changed, 190 insertions(+), 19 deletions(-) create mode 100644 .changeset/angry-bobcats-sit.md create mode 100644 packages/backend-common/src/config.test.ts diff --git a/.changeset/angry-bobcats-sit.md b/.changeset/angry-bobcats-sit.md new file mode 100644 index 0000000000..ad02ff68da --- /dev/null +++ b/.changeset/angry-bobcats-sit.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +The `subscribe` method on the `Config` returned by `loadBackendConfig` is now forwarded through `getConfig` and `getOptionalConfig`. diff --git a/packages/backend-common/src/config.test.ts b/packages/backend-common/src/config.test.ts new file mode 100644 index 0000000000..a60b28ac15 --- /dev/null +++ b/packages/backend-common/src/config.test.ts @@ -0,0 +1,135 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { Logger } from 'winston'; +import { ConfigReader } from '@backstage/config'; +import { ObservableConfigProxy } from './config'; + +describe('ObservableConfigProxy', () => { + const errLogger = { + error: (message: string) => { + throw new Error(message); + }, + } as unknown as Logger; + + it('should notify subscribers', () => { + const config = new ObservableConfigProxy(errLogger); + + const fn = jest.fn(); + const sub = config.subscribe(fn); + expect(config.getOptionalNumber('x')).toBe(undefined); + + config.setConfig(new ConfigReader({})); + expect(fn).toHaveBeenCalledTimes(1); + expect(config.getOptionalNumber('x')).toBe(undefined); + + config.setConfig(new ConfigReader({ x: 1 })); + expect(fn).toHaveBeenCalledTimes(2); + expect(config.getOptionalNumber('x')).toBe(1); + + config.setConfig(new ConfigReader({ x: 3 })); + expect(fn).toHaveBeenCalledTimes(3); + sub.unsubscribe(); + expect(config.getOptionalNumber('x')).toBe(3); + + config.setConfig(new ConfigReader({ x: 5 })); + expect(fn).toHaveBeenCalledTimes(3); + expect(config.getOptionalNumber('x')).toBe(5); + }); + + it('should forward subscriptions', () => { + const config1 = new ObservableConfigProxy(errLogger); + + const fn1 = jest.fn(); + const fn2 = jest.fn(); + const fn3 = jest.fn(); + const config2 = config1.getConfig('a'); + const config3 = config2.getConfig('b'); + const sub1 = config1.subscribe(fn1); + const sub2 = config2.subscribe!(fn2); + const sub3 = config3.subscribe!(fn3); + expect(config1.getOptionalNumber('x')).toBe(undefined); + expect(config2.getOptionalNumber('x')).toBe(undefined); + expect(config3.getOptionalNumber('x')).toBe(undefined); + + config1.setConfig(new ConfigReader({})); + expect(fn1).toHaveBeenCalledTimes(1); + expect(fn2).toHaveBeenCalledTimes(1); + expect(fn3).toHaveBeenCalledTimes(1); + expect(config1.getOptionalNumber('x')).toBe(undefined); + expect(config2.getOptionalNumber('x')).toBe(undefined); + expect(config3.getOptionalNumber('x')).toBe(undefined); + + config1.setConfig(new ConfigReader({ x: 1, a: { x: 2, b: { x: 3 } } })); + expect(fn1).toHaveBeenCalledTimes(2); + expect(fn2).toHaveBeenCalledTimes(2); + expect(fn3).toHaveBeenCalledTimes(2); + expect(config1.getNumber('x')).toBe(1); + expect(config2.getNumber('x')).toBe(2); + expect(config3.getNumber('x')).toBe(3); + + sub1.unsubscribe(); + sub2.unsubscribe(); + sub3.unsubscribe(); + + config1.setConfig(new ConfigReader({ x: 4, a: { x: 5, b: { x: 6 } } })); + expect(fn1).toHaveBeenCalledTimes(2); + expect(fn2).toHaveBeenCalledTimes(2); + expect(fn3).toHaveBeenCalledTimes(2); + expect(config1.getNumber('x')).toBe(4); + expect(config2.getNumber('x')).toBe(5); + expect(config3.getNumber('x')).toBe(6); + + config1.setConfig(new ConfigReader({})); + expect(() => config1.getNumber('x')).toThrow( + "Missing required config value at 'x'", + ); + expect(() => config2.getNumber('x')).toThrow( + "Missing required config value at 'a'", + ); + expect(() => config3.getNumber('x')).toThrow( + "Missing required config value at 'a'", + ); + + config1.setConfig( + new ConfigReader({ x: 's', a: { x: 's', b: { x: 's' } } }), + ); + expect(() => config1.getNumber('x')).toThrow( + "Unable to convert config value for key 'x' in 'mock-config' to a number", + ); + expect(() => config2.getNumber('x')).toThrow( + "Unable to convert config value for key 'a.x' in 'mock-config' to a number", + ); + expect(() => config3.getNumber('x')).toThrow( + "Unable to convert config value for key 'a.b.x' in 'mock-config' to a number", + ); + }); + + it('should make sub configs available as expected', () => { + const config = new ObservableConfigProxy(errLogger); + + config.setConfig(new ConfigReader({ a: { x: 1 } })); + + expect(config.getConfig('a')).toBeDefined(); + expect(config.getConfig('a').getNumber('x')).toBe(1); + expect(config.getConfig('a').getOptionalNumber('x')).toBe(1); + expect(config.getOptionalConfig('a')?.getNumber('x')).toBe(1); + expect(config.getOptionalConfig('a')?.getOptionalNumber('x')).toBe(1); + expect(config.getOptionalConfig('b')).toBeUndefined(); + expect(() => config.getConfig('b')).toBeDefined(); + expect(() => config.getConfig('b').get()).toThrow(); + }); +}); diff --git a/packages/backend-common/src/config.ts b/packages/backend-common/src/config.ts index 96907870cb..04cf1ac2bb 100644 --- a/packages/backend-common/src/config.ts +++ b/packages/backend-common/src/config.ts @@ -21,14 +21,25 @@ import { findPaths } from '@backstage/cli-common'; import { Config, ConfigReader, JsonValue } from '@backstage/config'; import { loadConfig } from '@backstage/config-loader'; -class ObservableConfigProxy implements Config { +export class ObservableConfigProxy implements Config { private config: Config = new ConfigReader({}); private readonly subscribers: (() => void)[] = []; - constructor(private readonly logger: Logger) {} + constructor( + private readonly logger: Logger, + private readonly parent?: ObservableConfigProxy, + private parentKey?: string, + ) { + if (parent && !parentKey) { + throw new Error('parentKey is required if parent is set'); + } + } setConfig(config: Config) { + if (this.parent) { + throw new Error('immutable'); + } this.config = config; for (const subscriber of this.subscribers) { try { @@ -40,6 +51,10 @@ class ObservableConfigProxy implements Config { } subscribe(onChange: () => void): { unsubscribe: () => void } { + if (this.parent) { + return this.parent.subscribe(onChange); + } + this.subscribers.push(onChange); return { unsubscribe: () => { @@ -51,53 +66,69 @@ class ObservableConfigProxy implements Config { }; } + private select(required: true): Config; + private select(required: false): Config | undefined; + private select(required: boolean): Config | undefined { + if (this.parent && this.parentKey) { + if (required) { + return this.parent.select(true).getConfig(this.parentKey); + } + return this.parent.select(false)?.getOptionalConfig(this.parentKey); + } + + return this.config; + } + has(key: string): boolean { - return this.config.has(key); + return this.select(false)?.has(key) ?? false; } keys(): string[] { - return this.config.keys(); + return this.select(false)?.keys() ?? []; } get(key?: string): T { - return this.config.get(key); + return this.select(true).get(key); } getOptional(key?: string): T | undefined { - return this.config.getOptional(key); + return this.select(false)?.getOptional(key); } getConfig(key: string): Config { - return this.config.getConfig(key); + return new ObservableConfigProxy(this.logger, this, key); } getOptionalConfig(key: string): Config | undefined { - return this.config.getOptionalConfig(key); + if (this.select(false)?.has(key)) { + return new ObservableConfigProxy(this.logger, this, key); + } + return undefined; } getConfigArray(key: string): Config[] { - return this.config.getConfigArray(key); + return this.select(true).getConfigArray(key); } getOptionalConfigArray(key: string): Config[] | undefined { - return this.config.getOptionalConfigArray(key); + return this.select(false)?.getOptionalConfigArray(key); } getNumber(key: string): number { - return this.config.getNumber(key); + return this.select(true).getNumber(key); } getOptionalNumber(key: string): number | undefined { - return this.config.getOptionalNumber(key); + return this.select(false)?.getOptionalNumber(key); } getBoolean(key: string): boolean { - return this.config.getBoolean(key); + return this.select(true).getBoolean(key); } getOptionalBoolean(key: string): boolean | undefined { - return this.config.getOptionalBoolean(key); + return this.select(false)?.getOptionalBoolean(key); } getString(key: string): string { - return this.config.getString(key); + return this.select(true).getString(key); } getOptionalString(key: string): string | undefined { - return this.config.getOptionalString(key); + return this.select(false)?.getOptionalString(key); } getStringArray(key: string): string[] { - return this.config.getStringArray(key); + return this.select(true).getStringArray(key); } getOptionalStringArray(key: string): string[] | undefined { - return this.config.getOptionalStringArray(key); + return this.select(false)?.getOptionalStringArray(key); } } diff --git a/packages/backend-common/src/index.ts b/packages/backend-common/src/index.ts index 4609459faa..c214961a2e 100644 --- a/packages/backend-common/src/index.ts +++ b/packages/backend-common/src/index.ts @@ -21,7 +21,7 @@ */ export * from './cache'; -export * from './config'; +export { loadBackendConfig } from './config'; export * from './database'; export * from './discovery'; export * from './hot';