Merge pull request #1046 from spotify/rugvip/config

packages/core-api: add ConfigApi + implementation and createApp option
This commit is contained in:
Patrik Oldsberg
2020-05-31 19:02:41 +02:00
committed by GitHub
12 changed files with 566 additions and 17 deletions
+12
View File
@@ -24,6 +24,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();
@@ -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<ConfigApi>({
id: 'core.config',
description: 'Used to access runtime configuration',
});
@@ -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';
@@ -0,0 +1,226 @@
/*
* 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 {}[],
};
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({});
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);
expectValidValues(config);
});
it('should fail to read invalid values', () => {
const config = new ConfigReader(DATA);
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);
});
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();
});
});
@@ -0,0 +1,168 @@
/*
* 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,
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, fallbackConfig);
}
if (value !== undefined) {
throw new TypeError(typeErrorMessage(key, typeOf(value), 'object'));
}
return fallbackConfig ?? 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 this.fallback?.getConfigArray(key) ?? [];
}
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 this.fallback?.getNumber(key);
}
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 this.fallback?.getBoolean(key);
}
getString(key: string): string | undefined {
const value = this.readValue(key);
if (validateString(key, value)) {
return value;
}
return this.fallback?.getString(key);
}
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 this.fallback?.getStringArray(key);
}
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;
}
}
@@ -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';
@@ -22,5 +22,6 @@ export * from './auth';
export * from './AlertApi';
export * from './AppThemeApi';
export * from './ConfigApi';
export * from './ErrorApi';
export * from './OAuthRequestApi';
+33 -12
View File
@@ -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,33 @@ 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);
const Provider: FC<{}> = ({ children }) => (
<ApiProvider apis={apis}>
<AppContextProvider app={this}>
<AppThemeProvider>{children}</AppThemeProvider>
</AppContextProvider>
</ApiProvider>
);
let childNode = children;
if (config.loading) {
const { Progress } = this.components;
childNode = <Progress />;
} else if (config.error) {
const { BootErrorPage } = this.components;
childNode = <BootErrorPage step="load-config" error={config.error} />;
}
const appApis = ApiRegistry.from([
[appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)],
[configApiRef, new ConfigReader(config.value ?? {})],
]);
const apis = new ApiAggregator(this.apis, appApis);
return (
<ApiProvider apis={apis}>
<AppContextProvider app={this}>
<AppThemeProvider>{childNode}</AppThemeProvider>
</AppContextProvider>
</ApiProvider>
);
};
return Provider;
}
+28
View File
@@ -20,10 +20,27 @@ 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<BootErrorPageProps>;
Progress: 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<AppConfig>;
export type AppOptions = {
/**
* A holder of all APIs available in the app.
@@ -68,6 +85,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 = {
+27 -2
View File
@@ -14,14 +14,17 @@
* 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 Progress from '../components/Progress';
import { lightTheme, darkTheme } from '@backstage/theme';
const { PrivateAppImpl } = privateExports;
@@ -38,12 +41,26 @@ export function createApp(options?: AppOptions) {
const DefaultNotFoundPage = () => (
<ErrorPage status="404" statusMessage="PAGE NOT FOUND" />
);
const DefaultBootErrorPage: FC<BootErrorPageProps> = ({ 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 (
<Router>
<ErrorPage status="501" statusMessage={message} />
</Router>
);
};
const apis = options?.apis ?? ApiRegistry.from([]);
const icons = { ...defaultSystemIcons, ...options?.icons };
const plugins = options?.plugins ?? [];
const components = {
NotFoundErrorPage: DefaultNotFoundPage,
BootErrorPage: DefaultBootErrorPage,
Progress: Progress,
...options?.components,
};
const themes = options?.themes ?? [
@@ -60,8 +77,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();
@@ -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(
<ApiProvider
apis={ApiRegistry.from([[errorApiRef, { post: jest.fn() }]])}
apis={ApiRegistry.from([
[errorApiRef, { post: jest.fn() }],
[configApiRef, new ConfigReader({})],
])}
>
<ThemeProvider theme={lightTheme}>
<WelcomePage />
@@ -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 (
<Page theme={pageTheme.home}>
<Header
title={`Welcome ${profile.givenName || 'to Backstage'}`}
title={`Welcome ${profile.givenName || `to ${appTitle}`}`}
subtitle="Let's start building a better developer experience"
>
<HomepageTimer />