Merge pull request #19550 from backstage/rugvip/params

frontend-app-api: add support for toggling extension instances
This commit is contained in:
Patrik Oldsberg
2023-08-24 09:57:56 +02:00
committed by GitHub
6 changed files with 341 additions and 107 deletions
+28 -103
View File
@@ -15,9 +15,9 @@
*/
import React from 'react';
import { Config, ConfigReader } from '@backstage/config';
import { ConfigReader } from '@backstage/config';
import {
ExtensionInstanceConfig,
ExtensionInstanceParameters,
BackstagePlugin,
coreExtensionData,
} from '@backstage/frontend-plugin-api';
@@ -26,65 +26,10 @@ import {
createExtensionInstance,
ExtensionInstance,
} from './createExtensionInstance';
// Since we'll never merge arrays in config the config reader context
// isn't too much of a help. Fall back to manual config reading logic
// as the Config interface makes it quite hard for us otherwise.
function readAppExtensionConfigs(
rootConfig: Config,
): Partial<ExtensionInstanceConfig>[] {
const arr = rootConfig.getOptional('app.extensions');
if (!Array.isArray(arr)) {
if (arr === undefined) {
return [];
}
// This will throw, and show which part of config had the wrong type
rootConfig.getConfigArray('app.extensions');
return [];
}
return arr.map((value, index) => {
function errorMsg(msg: string, key?: string, prop?: string) {
return `Invalid extension configuration at app.extensions[${index}]${
key ? `[${key}]` : ''
}${prop ? `.${prop}` : ''}, ${msg}`;
}
if (typeof value === 'string') {
return { id: value };
} else if (
typeof value !== 'object' ||
value === null ||
Array.isArray(value)
) {
throw new Error(errorMsg('must be a string or an object'));
}
const keys = Object.keys(value);
if (keys.length !== 1) {
const joinedKeys = `"${keys.join('", "')}"`;
throw new Error(errorMsg(`must have exactly one key, got ${joinedKeys}`));
}
const key = keys[0];
const obj = value[key];
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
throw new Error(errorMsg('must be an object', key));
}
const at = obj.at;
if (at !== undefined && typeof at !== 'string') {
throw new Error(errorMsg('must be a string', key, 'at'));
}
const extension = obj.extension;
if (extension !== undefined && typeof extension !== 'string') {
throw new Error(errorMsg('must be a string', key, 'extension'));
}
if (extension) {
throw new Error('TODO: implement extension resolution');
}
return { id: key, at, config: obj.config /* validate later */ };
});
}
import {
mergeExtensionParameters,
readAppExtensionParameters,
} from './wiring/parameters';
/** @public */
export function createApp(options: { plugins: BackstagePlugin[] }): {
@@ -92,10 +37,7 @@ export function createApp(options: { plugins: BackstagePlugin[] }): {
} {
const appConfig = ConfigReader.fromConfigs(process.env.APP_CONFIG as any);
// pull in default extension instance from discovered packages
// apply config to adjust default extension instances and add more
const extensionInstanceConfigs = [
...options.plugins.flatMap(plugin => plugin.defaultExtensionInstances),
const builtinExtensionInstanceParams = [
{
id: 'core.router',
at: 'root/default',
@@ -104,40 +46,23 @@ export function createApp(options: { plugins: BackstagePlugin[] }): {
},
];
const appExtensionConfigs = readAppExtensionConfigs(appConfig);
for (const appExtensionConfig of appExtensionConfigs) {
const existingConfig = extensionInstanceConfigs.find(
e => e.id === appExtensionConfig.id,
);
if (existingConfig) {
if (appExtensionConfig.at) {
existingConfig.at = appExtensionConfig.at;
}
if (appExtensionConfig.extension) {
// TODO: do we want to reset config here? it might be completely
// unrelated to the previous one
existingConfig.extension = appExtensionConfig.extension;
}
if (appExtensionConfig.config) {
// TODO: merge config?
existingConfig.config = appExtensionConfig.config;
}
} else if (appExtensionConfig.id) {
const { id, at, extension, config } = appExtensionConfig;
if (!at || !extension) {
throw new Error(`Extension ${appExtensionConfig.id} is incomplete`);
}
extensionInstanceConfigs.push({ id, at, extension, config });
}
}
// pull in default extension instance from discovered packages
// apply config to adjust default extension instances and add more
const extensionInstanceParams = mergeExtensionParameters(
[
...options.plugins.flatMap(plugin => plugin.defaultExtensionInstances),
...builtinExtensionInstanceParams,
],
readAppExtensionParameters(appConfig),
);
// Create attachment map so that we can look attachments up during instance creation
const attachmentMap = new Map<
string,
Map<string, ExtensionInstanceConfig[]>
Map<string, ExtensionInstanceParameters[]>
>();
for (const instanceConfig of extensionInstanceConfigs) {
const [extensionId, pointId = 'default'] = instanceConfig.at.split('/');
for (const instanceParams of extensionInstanceParams) {
const [extensionId, pointId = 'default'] = instanceParams.at.split('/');
let pointMap = attachmentMap.get(extensionId);
if (!pointMap) {
@@ -151,21 +76,21 @@ export function createApp(options: { plugins: BackstagePlugin[] }): {
pointMap.set(pointId, instances);
}
instances.push(instanceConfig);
instances.push(instanceParams);
}
const instances = new Map<string, ExtensionInstance>();
function createInstance(
instanceConfig: ExtensionInstanceConfig,
instanceParams: ExtensionInstanceParameters,
): ExtensionInstance {
const existingInstance = instances.get(instanceConfig.id);
const existingInstance = instances.get(instanceParams.id);
if (existingInstance) {
return existingInstance;
}
const attachments = Object.fromEntries(
Array.from(attachmentMap.get(instanceConfig.id)?.entries() ?? []).map(
Array.from(attachmentMap.get(instanceParams.id)?.entries() ?? []).map(
([inputName, attachmentConfigs]) => [
inputName,
attachmentConfigs.map(createInstance),
@@ -174,16 +99,16 @@ export function createApp(options: { plugins: BackstagePlugin[] }): {
);
return createExtensionInstance({
id: instanceConfig.id,
config: instanceConfig.config,
extension: instanceConfig.extension,
id: instanceParams.id,
config: instanceParams.config,
extension: instanceParams.extension,
attachments,
});
}
const rootConfigs = attachmentMap.get('root')?.get('default') ?? [];
const rootInstances = rootConfigs.map(instanceConfig =>
createInstance(instanceConfig),
const rootInstances = rootConfigs.map(instanceParams =>
createInstance(instanceParams),
);
return {
@@ -0,0 +1,175 @@
/*
* Copyright 2023 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 { ConfigReader } from '@backstage/config';
import { Extension } from '@backstage/frontend-plugin-api';
import {
mergeExtensionParameters,
readAppExtensionParameters,
} from './parameters';
function makeExt(id: string, status: 'disabled' | 'enabled' = 'enabled') {
return {
id,
at: 'foo/bar',
extension: {} as Extension<unknown>,
disabled: status === 'disabled',
};
}
describe('mergeExtensionParameters', () => {
it('should filter out disabled extension instances', () => {
expect(mergeExtensionParameters([makeExt('a', 'disabled')], [])).toEqual(
[],
);
});
it('should pass through extension instances', () => {
expect(mergeExtensionParameters([makeExt('a'), makeExt('b')], [])).toEqual([
makeExt('a'),
makeExt('b'),
]);
});
it('should override extension instances', () => {
expect(
mergeExtensionParameters(
[makeExt('a'), makeExt('b')],
[
{
id: 'b',
extension: { ext: 'other' } as unknown as Extension<unknown>,
},
],
),
).toEqual([makeExt('a'), { ...makeExt('b'), extension: { ext: 'other' } }]);
});
it('should override attachment points', () => {
expect(
mergeExtensionParameters(
[makeExt('a'), makeExt('b')],
[
{
id: 'b',
at: 'derp',
},
],
),
).toEqual([makeExt('a'), { ...makeExt('b'), at: 'derp' }]);
});
it('should fully override configuration', () => {
expect(
mergeExtensionParameters(
[
{ ...makeExt('a'), config: { foo: { bar: 1 } } },
{ ...makeExt('b'), config: { foo: { bar: 2 } } },
],
[
{
id: 'b',
config: { foo: { qux: 3 } },
},
],
),
).toEqual([
{ ...makeExt('a'), config: { foo: { bar: 1 } } },
{ ...makeExt('b'), config: { foo: { qux: 3 } } },
]);
});
it('should place enabled instances in the order that they were enabled', () => {
expect(
mergeExtensionParameters(
[makeExt('a', 'disabled'), makeExt('b', 'disabled')],
[
{
id: 'b',
disabled: false,
},
{
id: 'a',
disabled: false,
},
],
),
).toEqual([makeExt('b'), makeExt('a')]);
});
});
describe('readAppExtensionParameters', () => {
it('should disable extension with shorthand notation', () => {
expect(
readAppExtensionParameters(
new ConfigReader({ app: { extensions: [{ 'core.router': false }] } }),
),
).toEqual([
{
id: 'core.router',
disabled: true,
},
]);
expect(
readAppExtensionParameters(
new ConfigReader({
app: { extensions: [{ 'core.router': { disabled: true } }] },
}),
),
).toEqual([
{
at: undefined,
config: undefined,
disabled: true,
id: 'core.router',
},
]);
});
it('should enable extension with shorthand notation', () => {
expect(
readAppExtensionParameters(
new ConfigReader({ app: { extensions: ['core.router'] } }),
),
).toEqual([
{
id: 'core.router',
},
]);
expect(
readAppExtensionParameters(
new ConfigReader({ app: { extensions: [{ 'core.router': true }] } }),
),
).toEqual([
{
id: 'core.router',
disabled: false,
},
]);
expect(
readAppExtensionParameters(
new ConfigReader({
app: { extensions: [{ 'core.router': { disabled: false } }] },
}),
),
).toEqual([
{
id: 'core.router',
disabled: false,
},
]);
});
});
@@ -0,0 +1,131 @@
/*
* Copyright 2023 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 { Config } from '@backstage/config';
import { ExtensionInstanceParameters } from '@backstage/frontend-plugin-api';
// Since we'll never merge arrays in config the config reader context
// isn't too much of a help. Fall back to manual config reading logic
// as the Config interface makes it quite hard for us otherwise.
/** @internal */
export function readAppExtensionParameters(
rootConfig: Config,
): Partial<ExtensionInstanceParameters>[] {
const arr = rootConfig.getOptional('app.extensions');
if (!Array.isArray(arr)) {
if (arr === undefined) {
return [];
}
// This will throw, and show which part of config had the wrong type
rootConfig.getConfigArray('app.extensions');
return [];
}
return arr.map((value, index) => {
function errorMsg(msg: string, key?: string, prop?: string) {
return `Invalid extension configuration at app.extensions[${index}]${
key ? `[${key}]` : ''
}${prop ? `.${prop}` : ''}, ${msg}`;
}
if (typeof value === 'string') {
return { id: value };
} else if (
typeof value !== 'object' ||
value === null ||
Array.isArray(value)
) {
throw new Error(errorMsg('must be a string or an object'));
}
const keys = Object.keys(value);
if (keys.length !== 1) {
const joinedKeys = `"${keys.join('", "')}"`;
throw new Error(errorMsg(`must have exactly one key, got ${joinedKeys}`));
}
const key = keys[0];
const obj = value[key];
if (typeof obj === 'boolean') {
return { id: key, disabled: !obj };
}
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
throw new Error(errorMsg('must be an object', key));
}
const at = obj.at;
if (at !== undefined && typeof at !== 'string') {
throw new Error(errorMsg('must be a string', key, 'at'));
}
const disabled = obj.disabled;
if (disabled !== undefined && typeof disabled !== 'boolean') {
throw new Error(errorMsg('must be a boolean', key, 'disabled'));
}
const extension = obj.extension;
if (extension !== undefined && typeof extension !== 'string') {
throw new Error(errorMsg('must be a string', key, 'extension'));
}
if (extension) {
throw new Error('TODO: implement extension resolution');
}
return { id: key, at, disabled, config: obj.config /* validate later */ };
});
}
/** @internal */
export function mergeExtensionParameters(
base: ExtensionInstanceParameters[],
overrides: Array<Partial<ExtensionInstanceParameters>>,
): ExtensionInstanceParameters[] {
const extensionInstanceParams = base.slice();
for (const overrideParam of overrides) {
const existingParamIndex = extensionInstanceParams.findIndex(
e => e.id === overrideParam.id,
);
if (existingParamIndex !== -1) {
const existingParam = extensionInstanceParams[existingParamIndex];
if (overrideParam.at) {
existingParam.at = overrideParam.at;
}
if (overrideParam.extension) {
// TODO: do we want to reset config here? it might be completely
// unrelated to the previous one
existingParam.extension = overrideParam.extension;
}
if (overrideParam.config) {
// TODO: merge config?
existingParam.config = overrideParam.config;
}
if (Boolean(existingParam.disabled) !== Boolean(overrideParam.disabled)) {
existingParam.disabled = Boolean(overrideParam.disabled);
if (!existingParam.disabled) {
// bump
extensionInstanceParams.splice(existingParamIndex, 1);
extensionInstanceParams.push(existingParam);
}
}
} else if (overrideParam.id) {
const { id, at, extension, config } = overrideParam;
if (!at || !extension) {
throw new Error(`Extension ${overrideParam.id} is incomplete`);
}
extensionInstanceParams.push({ id, at, extension, config });
}
}
return extensionInstanceParams.filter(param => !param.disabled);
}
+5 -3
View File
@@ -17,7 +17,7 @@ export interface BackstagePlugin {
// (undocumented)
$$type: 'backstage-plugin';
// (undocumented)
defaultExtensionInstances: ExtensionInstanceConfig[];
defaultExtensionInstances: ExtensionInstanceParameters[];
// (undocumented)
id: string;
}
@@ -25,7 +25,7 @@ export interface BackstagePlugin {
// @public (undocumented)
export interface BackstagePluginOptions {
// (undocumented)
defaultExtensionInstances?: ExtensionInstanceConfig[];
defaultExtensionInstances?: ExtensionInstanceParameters[];
// (undocumented)
id: string;
}
@@ -126,12 +126,14 @@ export type ExtensionDataValue<TData extends AnyExtensionDataMap> = {
};
// @public (undocumented)
export interface ExtensionInstanceConfig {
export interface ExtensionInstanceParameters {
// (undocumented)
at: string;
// (undocumented)
config?: unknown;
// (undocumented)
disabled?: boolean;
// (undocumented)
extension: Extension<unknown>;
// (undocumented)
id: string;
+1 -1
View File
@@ -24,7 +24,7 @@ export {
createExtension,
coreExtensionData,
createPlugin,
type ExtensionInstanceParameters as ExtensionInstanceConfig,
type ExtensionInstanceParameters,
type BackstagePlugin,
type Extension,
type AnyExtensionDataMap,
@@ -97,6 +97,7 @@ export interface ExtensionInstanceParameters {
at: string;
extension: Extension<unknown>;
config?: unknown;
disabled?: boolean;
}
/** @public */