packages/core: make AppConfigLoader return an array, and added defaultConfigLoader + tests

This commit is contained in:
Patrik Oldsberg
2020-06-02 17:00:01 +02:00
parent 5774ec46ce
commit 483fa94d0e
7 changed files with 152 additions and 15 deletions
+13 -11
View File
@@ -24,18 +24,20 @@ import apis from './apis';
const app = createApp({
apis,
plugins: Object.values(plugins),
configLoader: async () => ({
app: {
title: 'Backstage Example App',
baseUrl: 'http://localhost:3000',
configLoader: async () => [
{
app: {
title: 'Backstage Example App',
baseUrl: 'http://localhost:3000',
},
backend: {
baseUrl: 'http://localhost:7000',
},
organization: {
name: 'Spotify',
},
},
backend: {
baseUrl: 'http://localhost:7000',
},
organization: {
name: 'Spotify',
},
}),
],
});
const AppProvider = app.getProvider();
+6
View File
@@ -51,6 +51,12 @@ export function createConfig(
);
}
plugins.push(
new webpack.EnvironmentPlugin({
APP_CONFIG: [],
}),
);
return {
mode: isDev ? 'development' : 'production',
profile: false,
@@ -15,6 +15,7 @@
*/
import { ConfigApi, Config } from '../../definitions/ConfigApi';
import { AppConfig } from '../../../app';
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
@@ -62,6 +63,18 @@ function validateString(
export class ConfigReader implements ConfigApi {
static nullReader = new ConfigReader({});
static fromConfigs(configs: AppConfig[]): ConfigReader {
if (configs.length === 0) {
return new ConfigReader({});
}
// Merge together all configs info a single config with recursive fallback
// readers, giving the first config object in the array the highest priority.
return configs.reduceRight((previousReader, nextConfig) => {
return new ConfigReader(nextConfig, previousReader);
}, undefined);
}
constructor(
private readonly data: JsonObject,
private readonly fallback?: ConfigApi,
+2 -2
View File
@@ -150,7 +150,7 @@ export class PrivateAppImpl implements BackstageApp {
const Provider: FC<{}> = ({ children }) => {
// Keeping this synchronous when a config loader isn't set simplifies tests a lot
const hasConfig = Boolean(this.configLoader);
const config = useAsync(this.configLoader || (() => Promise.resolve({})));
const config = useAsync(this.configLoader || (() => Promise.resolve([])));
let childNode = children;
@@ -164,7 +164,7 @@ export class PrivateAppImpl implements BackstageApp {
const appApis = ApiRegistry.from([
[appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)],
[configApiRef, new ConfigReader(config.value ?? {})],
[configApiRef, ConfigReader.fromConfigs(config.value ?? [])],
]);
const apis = new ApiAggregator(this.apis, appApis);
+4 -1
View File
@@ -38,8 +38,11 @@ export type AppConfig = any;
/**
* A function that loads in the App config that will be accessible via the ConfigApi.
*
* If multiple config objects are returned in the array, values in the earlier configs
* will override later ones.
*/
export type AppConfigLoader = () => Promise<AppConfig>;
export type AppConfigLoader = () => Promise<AppConfig[]>;
export type AppOptions = {
/**
@@ -0,0 +1,74 @@
/*
* 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 { defaultConfigLoader } from './createApp';
describe('defaultConfigLoader', () => {
afterEach(() => {
delete process.env.APP_CONFIG;
});
it('loads static config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [{ my: 'config' }, { my: 'override-config' }] as any,
});
const configs = await defaultConfigLoader();
expect(configs).toEqual([{ my: 'config' }, { my: 'override-config' }]);
});
it('loads runtime config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [{ my: 'override-config' }, { my: 'config' }] as any,
});
const configs = await (defaultConfigLoader as any)(
'{"my":"runtime-config"}',
);
expect(configs).toEqual([
{ my: 'runtime-config' },
{ my: 'override-config' },
{ my: 'config' },
]);
});
it('fails to load invalid missing config', async () => {
await expect(defaultConfigLoader()).rejects.toThrow(
'No static configuration provided',
);
});
it('fails to load invalid static config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: { my: 'invalid-config' } as any,
});
await expect(defaultConfigLoader()).rejects.toThrow(
'Static configuration has invalid format',
);
});
it('fails to load bad runtime config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [{ my: 'config' }] as any,
});
await expect((defaultConfigLoader as any)('}')).rejects.toThrow(
'Failed to load runtime configuration, SyntaxError: Unexpected token } in JSON at position 0',
);
});
});
+40 -1
View File
@@ -20,6 +20,8 @@ import privateExports, {
ApiRegistry,
defaultSystemIcons,
BootErrorPageProps,
AppConfigLoader,
AppConfig,
} from '@backstage/core-api';
import { BrowserRouter as Router } from 'react-router-dom';
@@ -29,6 +31,43 @@ import { lightTheme, darkTheme } from '@backstage/theme';
const { PrivateAppImpl } = privateExports;
/**
* The default config loader, which expects that config is available at compile-time
* in `process.env.APP_CONFIG`. APP_CONFIG should be an array of config objects as
* returned by the config loader.
*
* It will also load runtime config from the __APP_INJECTED_RUNTIME_CONFIG__ string,
* which can be rewritten at runtime to contain an additional JSON config object.
* If runtime config is present, it will be placed first in the config array, overriding
* other config values.
*/
export const defaultConfigLoader: AppConfigLoader = async (
// This string may be replaced at runtime to provide additional config.
// It should be replaced by a JSON-serialized config object.
// It's a param so we can test it, but at runtime this will always fall back to default.
runtimeConfigJson: string = '__APP_INJECTED_RUNTIME_CONFIG__',
) => {
const appConfig = process.env.APP_CONFIG;
if (!appConfig) {
throw new Error('No static configuration provided');
}
if (!Array.isArray(appConfig)) {
throw new Error('Static configuration has invalid format');
}
const configs = (appConfig.slice() as unknown) as AppConfig[];
// Avoiding this string also being replaced at runtime
if (runtimeConfigJson !== '__app_injected_runtime_config__'.toUpperCase()) {
try {
configs.unshift(JSON.parse(runtimeConfigJson));
} catch (error) {
throw new Error(`Failed to load runtime configuration, ${error}`);
}
}
return configs;
};
// createApp is defined in core, and not core-api, since we need access
// to the components inside core to provide defaults.
// The actual implementation of the app class still lives in core-api,
@@ -77,7 +116,7 @@ export function createApp(options?: AppOptions) {
theme: darkTheme,
},
];
const configLoader = options?.configLoader ?? (async () => ({}));
const configLoader = options?.configLoader ?? defaultConfigLoader;
const app = new PrivateAppImpl({
apis,