Skip to content

Commit

Permalink
Merge pull request backstage#1113 from spotify/rugvip/config
Browse files Browse the repository at this point in the history
package/cli,core: add static configuration loading for frontend
  • Loading branch information
Rugvip authored Jun 2, 2020
2 parents 5774ec4 + d0fb818 commit 27a7c35
Show file tree
Hide file tree
Showing 18 changed files with 245 additions and 16 deletions.
9 changes: 9 additions & 0 deletions app-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
app:
title: Backstage Example App
baseUrl: http://localhost:3000

backend:
baseUrl: http://localhost:7000

organization:
name: Spotify
12 changes: 0 additions & 12 deletions packages/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,6 @@ 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();
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"url-loader": "^4.1.0",
"webpack": "^4.41.6",
"webpack-dev-server": "^3.10.3",
"yaml": "^1.10.0",
"yml-loader": "^2.1.0",
"yn": "^4.0.0"
},
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/app/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@

import { buildBundle } from '../../lib/bundler';
import { Command } from 'commander';
import { loadConfig } from '../../lib/app-config';

export default async (cmd: Command) => {
await buildBundle({
entry: 'src/index',
statsJsonEnabled: cmd.stats,
appConfig: await loadConfig(),
});
};
2 changes: 2 additions & 0 deletions packages/cli/src/commands/app/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@

import { Command } from 'commander';
import { serveBundle } from '../../lib/bundler';
import { loadConfig } from '../../lib/app-config';

export default async (cmd: Command) => {
const waitForExit = await serveBundle({
entry: 'src/index',
checksEnabled: cmd.check,
appConfig: await loadConfig(),
});

await waitForExit();
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/plugin/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@

import { Command } from 'commander';
import { serveBundle } from '../../lib/bundler';
import { loadConfig } from '../../lib/app-config';

export default async (cmd: Command) => {
const waitForExit = await serveBundle({
entry: 'dev/index',
checksEnabled: cmd.check,
appConfig: await loadConfig(),
});

await waitForExit();
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/lib/app-config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* 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 type { AppConfig } from './types';
export { loadConfig } from './loaders';
41 changes: 41 additions & 0 deletions packages/cli/src/lib/app-config/loaders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* 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 { AppConfig } from './types';
import fs from 'fs-extra';
import yaml from 'yaml';
import { paths } from '../paths';

type LoadConfigOptions = {
// Config path, defaults to app-config.yaml in project root
configPath?: string;
};

export async function loadConfig(
options: LoadConfigOptions = {},
): Promise<AppConfig[]> {
// TODO: We'll want this to be a bit more elaborate, probably adding configs for
// specific env, and maybe local config for plugins.
const { configPath = paths.resolveTargetRoot('app-config.yaml') } = options;

try {
const configYaml = await fs.readFile(configPath, 'utf8');
const config = yaml.parse(configYaml);
return [config];
} catch (error) {
throw new Error(`Failed to read static configuration file, ${error}`);
}
}
17 changes: 17 additions & 0 deletions packages/cli/src/lib/app-config/types.ts
Original file line number Diff line number Diff line change
@@ -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 type AppConfig = any;
6 changes: 6 additions & 0 deletions packages/cli/src/lib/bundler/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ export function createConfig(
);
}

plugins.push(
new webpack.EnvironmentPlugin({
APP_CONFIG: options.appConfig,
}),
);

return {
mode: isDev ? 'development' : 'production',
profile: false,
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/lib/bundler/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,20 @@
*/

import { BundlingPathsOptions } from './paths';
import { AppConfig } from '../app-config';

export type BundlingOptions = {
checksEnabled: boolean;
isDev: boolean;
appConfig: AppConfig[];
};

export type ServeOptions = BundlingPathsOptions & {
checksEnabled: boolean;
appConfig: AppConfig[];
};

export type BuildOptions = BundlingPathsOptions & {
statsJsonEnabled: boolean;
appConfig: AppConfig[];
};
5 changes: 5 additions & 0 deletions packages/cli/templates/default-app/app-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
app:
title: Scaffolded Backstage App

organization:
name: Acme Corporation
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/core-api/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);

Expand Down
5 changes: 4 additions & 1 deletion packages/core-api/src/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
/**
Expand Down
74 changes: 74 additions & 0 deletions packages/core/src/api-wrappers/createApp.test.tsx
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
41 changes: 40 additions & 1 deletion packages/core/src/api-wrappers/createApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import privateExports, {
ApiRegistry,
defaultSystemIcons,
BootErrorPageProps,
AppConfigLoader,
AppConfig,
} from '@backstage/core-api';
import { BrowserRouter as Router } from 'react-router-dom';

Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 27a7c35

Please sign in to comment.