Remove the concept of extension instances on the surface
Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com> Co-authored-by: Johan Haals <johan.haals@gmail.com> Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
app:
|
||||
packages: 'all' # ✨
|
||||
extensions:
|
||||
- core.router
|
||||
- graphiql.page:
|
||||
config:
|
||||
path: /
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import React from 'react';
|
||||
|
||||
export const GraphiqlPage = createPageExtension({
|
||||
id: 'graphiql.page',
|
||||
defaultPath: '/graphiql',
|
||||
component: () =>
|
||||
import('@backstage/plugin-graphiql').then(({ Router }) => <Router />),
|
||||
@@ -28,11 +29,5 @@ export const GraphiqlPage = createPageExtension({
|
||||
|
||||
export const graphiqlPlugin = createPlugin({
|
||||
id: 'graphiql',
|
||||
defaultExtensionInstances: [
|
||||
{
|
||||
id: 'graphiql.page',
|
||||
at: 'core.router/routes',
|
||||
extension: GraphiqlPage,
|
||||
},
|
||||
],
|
||||
defaultExtensions: [GraphiqlPage],
|
||||
});
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
import React from 'react';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
ExtensionInstanceParameters,
|
||||
BackstagePlugin,
|
||||
coreExtensionData,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
ExtensionInstance,
|
||||
} from './createExtensionInstance';
|
||||
import {
|
||||
ExtensionInstanceParameters,
|
||||
mergeExtensionParameters,
|
||||
readAppExtensionParameters,
|
||||
} from './wiring/parameters';
|
||||
@@ -37,21 +37,14 @@ export function createApp(options: { plugins: BackstagePlugin[] }): {
|
||||
} {
|
||||
const appConfig = ConfigReader.fromConfigs(process.env.APP_CONFIG as any);
|
||||
|
||||
const builtinExtensionInstanceParams = [
|
||||
{
|
||||
id: 'core.router',
|
||||
at: 'root/default',
|
||||
extension: RouteExtension,
|
||||
config: undefined,
|
||||
},
|
||||
];
|
||||
const builtinExtensions = [RouteExtension];
|
||||
|
||||
// pull in default extension instance from discovered packages
|
||||
// apply config to adjust default extension instances and add more
|
||||
const extensionInstanceParams = mergeExtensionParameters(
|
||||
const extensionParams = mergeExtensionParameters(
|
||||
[
|
||||
...options.plugins.flatMap(plugin => plugin.defaultExtensionInstances),
|
||||
...builtinExtensionInstanceParams,
|
||||
...options.plugins.flatMap(plugin => plugin.defaultExtensions),
|
||||
...builtinExtensions,
|
||||
],
|
||||
readAppExtensionParameters(appConfig),
|
||||
);
|
||||
@@ -64,7 +57,7 @@ export function createApp(options: { plugins: BackstagePlugin[] }): {
|
||||
string,
|
||||
Map<string, ExtensionInstanceParameters[]>
|
||||
>();
|
||||
for (const instanceParams of extensionInstanceParams) {
|
||||
for (const instanceParams of extensionParams) {
|
||||
const [extensionId, pointId = 'default'] = instanceParams.at.split('/');
|
||||
|
||||
let pointMap = attachmentMap.get(extensionId);
|
||||
@@ -87,24 +80,23 @@ export function createApp(options: { plugins: BackstagePlugin[] }): {
|
||||
function createInstance(
|
||||
instanceParams: ExtensionInstanceParameters,
|
||||
): ExtensionInstance {
|
||||
const existingInstance = instances.get(instanceParams.id);
|
||||
const existingInstance = instances.get(instanceParams.extension.id);
|
||||
if (existingInstance) {
|
||||
return existingInstance;
|
||||
}
|
||||
|
||||
const attachments = Object.fromEntries(
|
||||
Array.from(attachmentMap.get(instanceParams.id)?.entries() ?? []).map(
|
||||
([inputName, attachmentConfigs]) => [
|
||||
inputName,
|
||||
attachmentConfigs.map(createInstance),
|
||||
],
|
||||
),
|
||||
Array.from(
|
||||
attachmentMap.get(instanceParams.extension.id)?.entries() ?? [],
|
||||
).map(([inputName, attachmentConfigs]) => [
|
||||
inputName,
|
||||
attachmentConfigs.map(createInstance),
|
||||
]),
|
||||
);
|
||||
|
||||
return createExtensionInstance({
|
||||
id: instanceParams.id,
|
||||
config: instanceParams.config,
|
||||
extension: instanceParams.extension,
|
||||
config: instanceParams.config,
|
||||
attachments,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,12 +26,11 @@ export interface ExtensionInstance {
|
||||
|
||||
/** @internal */
|
||||
export function createExtensionInstance(options: {
|
||||
id: string;
|
||||
extension: Extension<unknown>;
|
||||
config: unknown;
|
||||
attachments: Record<string, ExtensionInstance[]>;
|
||||
}): ExtensionInstance {
|
||||
const { id, extension, config, attachments } = options;
|
||||
const { extension, config, attachments } = options;
|
||||
const extensionData = new Map<string, unknown>();
|
||||
|
||||
let parsedConfig: unknown;
|
||||
@@ -39,7 +38,7 @@ export function createExtensionInstance(options: {
|
||||
parsedConfig = extension.configSchema?.parse(config ?? {});
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Invalid configuration for extension instance '${id}', ${e}`,
|
||||
`Invalid configuration for extension instance '${extension.id}', ${e}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,11 +59,13 @@ export function createExtensionInstance(options: {
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to instantiate extension instance '${id}', ${e}`);
|
||||
throw new Error(
|
||||
`Failed to instantiate extension instance '${extension.id}', ${e}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: options.id,
|
||||
id: options.extension.id,
|
||||
data: extensionData,
|
||||
$$type: 'extension-instance',
|
||||
};
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
import { BrowserRouter, useRoutes } from 'react-router-dom';
|
||||
|
||||
export const RouteExtension = createExtension({
|
||||
id: 'core.router',
|
||||
at: 'root',
|
||||
inputs: {
|
||||
routes: {
|
||||
extensionData: {
|
||||
|
||||
@@ -26,10 +26,9 @@ import {
|
||||
function makeExt(id: string, status: 'disabled' | 'enabled' = 'enabled') {
|
||||
return {
|
||||
id,
|
||||
at: 'foo/bar',
|
||||
extension: {} as Extension<unknown>,
|
||||
at: 'root',
|
||||
disabled: status === 'disabled',
|
||||
};
|
||||
} as Extension<unknown>;
|
||||
}
|
||||
|
||||
describe('mergeExtensionParameters', () => {
|
||||
@@ -40,30 +39,20 @@ describe('mergeExtensionParameters', () => {
|
||||
});
|
||||
|
||||
it('should pass through extension instances', () => {
|
||||
expect(mergeExtensionParameters([makeExt('a'), makeExt('b')], [])).toEqual([
|
||||
makeExt('a'),
|
||||
makeExt('b'),
|
||||
const a = makeExt('a');
|
||||
const b = makeExt('b');
|
||||
expect(mergeExtensionParameters([a, b], [])).toEqual([
|
||||
{ extension: a, at: 'root' },
|
||||
{ extension: b, at: 'root' },
|
||||
]);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const a = makeExt('a');
|
||||
const b = makeExt('b');
|
||||
expect(
|
||||
mergeExtensionParameters(
|
||||
[makeExt('a'), makeExt('b')],
|
||||
[a, b],
|
||||
[
|
||||
{
|
||||
id: 'b',
|
||||
@@ -71,17 +60,27 @@ describe('mergeExtensionParameters', () => {
|
||||
},
|
||||
],
|
||||
),
|
||||
).toEqual([makeExt('a'), { ...makeExt('b'), at: 'derp' }]);
|
||||
).toEqual([
|
||||
{ extension: a, at: 'root' },
|
||||
{ extension: b, at: 'derp' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should fully override configuration', () => {
|
||||
it('should fully override configuration and duplicate', () => {
|
||||
const a = makeExt('a');
|
||||
const b = makeExt('b');
|
||||
expect(
|
||||
mergeExtensionParameters(
|
||||
[a, b],
|
||||
[
|
||||
{ ...makeExt('a'), config: { foo: { bar: 1 } } },
|
||||
{ ...makeExt('b'), config: { foo: { bar: 2 } } },
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 'a',
|
||||
config: { foo: { bar: 1 } },
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
config: { foo: { bar: 2 } },
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
config: { foo: { qux: 3 } },
|
||||
@@ -89,15 +88,17 @@ describe('mergeExtensionParameters', () => {
|
||||
],
|
||||
),
|
||||
).toEqual([
|
||||
{ ...makeExt('a'), config: { foo: { bar: 1 } } },
|
||||
{ ...makeExt('b'), config: { foo: { qux: 3 } } },
|
||||
{ extension: a, at: 'root', config: { foo: { bar: 1 } } },
|
||||
{ extension: b, at: 'root', config: { foo: { qux: 3 } } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should place enabled instances in the order that they were enabled', () => {
|
||||
const a = makeExt('a', 'disabled');
|
||||
const b = makeExt('b', 'disabled');
|
||||
expect(
|
||||
mergeExtensionParameters(
|
||||
[makeExt('a', 'disabled'), makeExt('b', 'disabled')],
|
||||
[a, b],
|
||||
[
|
||||
{
|
||||
id: 'b',
|
||||
@@ -109,7 +110,10 @@ describe('mergeExtensionParameters', () => {
|
||||
},
|
||||
],
|
||||
),
|
||||
).toEqual([makeExt('b'), makeExt('a')]);
|
||||
).toEqual([
|
||||
{ extension: b, at: 'root' },
|
||||
{ extension: a, at: 'root' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,6 +153,7 @@ describe('readAppExtensionParameters', () => {
|
||||
).toEqual([
|
||||
{
|
||||
id: 'core.router',
|
||||
disabled: false,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
@@ -175,26 +180,22 @@ describe('readAppExtensionParameters', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should support extension implementation shorthand', () => {
|
||||
expect(
|
||||
it('should not allow string keys', () => {
|
||||
expect(() =>
|
||||
readAppExtensionParameters(
|
||||
new ConfigReader({
|
||||
app: {
|
||||
extensions: [{ 'core.router': 'example-package#CustomRouter' }],
|
||||
extensions: [{ 'core.router': 'some-string' }],
|
||||
},
|
||||
}),
|
||||
ref => ({ ref } as unknown as Extension<unknown>),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
id: 'core.router',
|
||||
extension: { ref: 'example-package#CustomRouter' },
|
||||
},
|
||||
]);
|
||||
).toThrow(
|
||||
'Invalid extension configuration at app.extensions[0][core.router], value must be a boolean or object',
|
||||
);
|
||||
});
|
||||
|
||||
it('should support attachment shorthand', () => {
|
||||
expect(
|
||||
it('should not allow invalid keys', () => {
|
||||
expect(() =>
|
||||
readAppExtensionParameters(
|
||||
new ConfigReader({
|
||||
app: {
|
||||
@@ -208,70 +209,16 @@ describe('readAppExtensionParameters', () => {
|
||||
],
|
||||
},
|
||||
}),
|
||||
ref => ({ ref } as unknown as Extension<unknown>),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
id: 'generated.1',
|
||||
at: 'core.router/routes',
|
||||
extension: { ref: 'example-package#MyPage' },
|
||||
config: { foo: 'bar' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should support attachment with extension shorthand', () => {
|
||||
expect(
|
||||
readAppExtensionParameters(
|
||||
new ConfigReader({
|
||||
app: {
|
||||
extensions: [{ 'core.router/routes': 'example-package#MyPage' }],
|
||||
},
|
||||
}),
|
||||
ref => ({ ref } as unknown as Extension<unknown>),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
id: 'generated.1',
|
||||
at: 'core.router/routes',
|
||||
extension: { ref: 'example-package#MyPage' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should reject attachment shorthand with explicit attachment', () => {
|
||||
expect(() =>
|
||||
readAppExtensionParameters(
|
||||
new ConfigReader({
|
||||
app: {
|
||||
extensions: [
|
||||
{
|
||||
'core.router/routes': {
|
||||
at: 'other/input',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(
|
||||
`Invalid extension configuration at app.extensions[0][core.router/routes], must not redundantly specify 'at' when the extension input ID form of the key is used (with a slash); the 'at' is already implicitly 'core.router/routes'`,
|
||||
'Invalid extension configuration at app.extensions[0], key must only contain letters, numbers and dots, got core.router/routes',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandShorthandExtensionParameters', () => {
|
||||
const resolveExtensionRef = jest.fn(
|
||||
ref => ({ ref } as unknown as Extension<unknown>),
|
||||
);
|
||||
const generateExtensionId = jest.fn(() => 'generated.1');
|
||||
const run = (value: JsonValue) => {
|
||||
return expandShorthandExtensionParameters(
|
||||
value,
|
||||
1,
|
||||
resolveExtensionRef,
|
||||
generateExtensionId,
|
||||
);
|
||||
return expandShorthandExtensionParameters(value, 1);
|
||||
};
|
||||
|
||||
it('rejects unknown keys', () => {
|
||||
@@ -297,19 +244,20 @@ describe('expandShorthandExtensionParameters', () => {
|
||||
|
||||
it('rejects unknown values', () => {
|
||||
expect(() => run({ a: null })).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][a], value must be a boolean, string, or object"`,
|
||||
`"Invalid extension configuration at app.extensions[1][a], value must be a boolean or object"`,
|
||||
);
|
||||
expect(() => run({ a: 1 })).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][a], value must be a boolean, string, or object"`,
|
||||
`"Invalid extension configuration at app.extensions[1][a], value must be a boolean or object"`,
|
||||
);
|
||||
expect(() => run({ a: [] })).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][a], value must be a boolean, string, or object"`,
|
||||
`"Invalid extension configuration at app.extensions[1][a], value must be a boolean or object"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('supports string key', () => {
|
||||
expect(run('core.router')).toEqual({
|
||||
id: 'core.router',
|
||||
disabled: false,
|
||||
});
|
||||
expect(() => run('')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1], string shorthand cannot be the empty string"`,
|
||||
@@ -328,40 +276,21 @@ describe('expandShorthandExtensionParameters', () => {
|
||||
id: 'core.router',
|
||||
disabled: true,
|
||||
});
|
||||
expect(() =>
|
||||
run({ 'core.router/routes': false }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][core.router/routes], cannot target an extension instance input (key cannot contain slashes; did you mean 'core.router'?)"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('supports string values', () => {
|
||||
expect(run({ 'core.router': 'example-package#MyRouter' })).toEqual({
|
||||
id: 'core.router',
|
||||
extension: { ref: 'example-package#MyRouter' },
|
||||
});
|
||||
expect(run({ 'core.router/routes': 'example-package#MyRouter' })).toEqual({
|
||||
id: 'generated.1',
|
||||
at: 'core.router/routes',
|
||||
extension: { ref: 'example-package#MyRouter' },
|
||||
});
|
||||
it('should not support string values', () => {
|
||||
expect(() =>
|
||||
run({ 'core.router': 'example-package#MyRouter' }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][core.router], value must be a boolean or object"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('supports object id only in the key', () => {
|
||||
expect(() =>
|
||||
run({ 'core.router/routes': { id: 'some.id' } }),
|
||||
run({ 'core.router': { id: 'some.id' } }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][core.router/routes].id, must not specify 'id' when the extension input ID form of the key is used (with a slash); please replace the key 'core.router/routes' with the id instead, and put that key in the 'at' field"`,
|
||||
);
|
||||
expect(() =>
|
||||
run({
|
||||
'core.router/routes': {
|
||||
id: 'example-package#MyRouter',
|
||||
at: 'core.router/routes',
|
||||
},
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][core.router/routes], must not redundantly specify 'at' when the extension input ID form of the key is used (with a slash); the 'at' is already implicitly 'core.router/routes'"`,
|
||||
`"Invalid extension configuration at app.extensions[1][core.router].id, unknown parameter; expected one of 'at', 'disabled', 'config'"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -377,7 +306,7 @@ describe('expandShorthandExtensionParameters', () => {
|
||||
},
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][core.router], must not redundantly specify 'id' when the extension instance ID form of the key is used (without a slash); the 'id' is already implicitly 'core.router'"`,
|
||||
`"Invalid extension configuration at app.extensions[1][core.router].id, unknown parameter; expected one of 'at', 'disabled', 'config'"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -397,21 +326,6 @@ describe('expandShorthandExtensionParameters', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('supports object extension', () => {
|
||||
expect(
|
||||
run({ 'core.router/routes': { extension: 'example-package#MyRouter' } }),
|
||||
).toEqual({
|
||||
id: 'generated.1',
|
||||
at: 'core.router/routes',
|
||||
extension: { ref: 'example-package#MyRouter' },
|
||||
});
|
||||
expect(() =>
|
||||
run({ 'core.router/routes': { extension: 0 } }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][core.router/routes].extension, must be a string"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('supports object config', () => {
|
||||
expect(
|
||||
run({ 'core.router': { config: { disableRedirects: true } } }),
|
||||
@@ -430,7 +344,7 @@ describe('expandShorthandExtensionParameters', () => {
|
||||
expect(() =>
|
||||
run({ 'core.router': { foo: { settings: true } } }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][core.router].foo, unknown parameter; expected one of 'at', 'disabled', 'extension', 'config'"`,
|
||||
`"Invalid extension configuration at app.extensions[1][core.router].foo, unknown parameter; expected one of 'at', 'disabled', 'config'"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,19 +15,17 @@
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
Extension,
|
||||
ExtensionInstanceParameters,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { Extension } from '@backstage/frontend-plugin-api';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import omitBy from 'lodash/omitBy';
|
||||
|
||||
const knownExtensionInstanceParameters = [
|
||||
'at',
|
||||
'disabled',
|
||||
'extension',
|
||||
'config',
|
||||
];
|
||||
export interface ExtensionParameters {
|
||||
id: string;
|
||||
at?: string;
|
||||
disabled?: boolean;
|
||||
config?: unknown;
|
||||
}
|
||||
|
||||
const knownExtensionParameters = ['at', 'disabled', 'config'];
|
||||
|
||||
// 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
|
||||
@@ -35,10 +33,7 @@ const knownExtensionInstanceParameters = [
|
||||
/** @internal */
|
||||
export function readAppExtensionParameters(
|
||||
rootConfig: Config,
|
||||
resolveExtensionRef: (ref: string) => Extension<unknown> = () => {
|
||||
throw new Error('Not Implemented');
|
||||
},
|
||||
): Partial<ExtensionInstanceParameters>[] {
|
||||
): ExtensionParameters[] {
|
||||
const arr = rootConfig.getOptional('app.extensions');
|
||||
if (!Array.isArray(arr)) {
|
||||
if (arr === undefined) {
|
||||
@@ -49,18 +44,8 @@ export function readAppExtensionParameters(
|
||||
return [];
|
||||
}
|
||||
|
||||
const generateExtensionId = (() => {
|
||||
let index = 1;
|
||||
return () => `generated.${index++}`;
|
||||
})();
|
||||
|
||||
return arr.map((arrayEntry, arrayIndex) =>
|
||||
expandShorthandExtensionParameters(
|
||||
arrayEntry,
|
||||
arrayIndex,
|
||||
resolveExtensionRef,
|
||||
generateExtensionId,
|
||||
),
|
||||
expandShorthandExtensionParameters(arrayEntry, arrayIndex),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,9 +53,7 @@ export function readAppExtensionParameters(
|
||||
export function expandShorthandExtensionParameters(
|
||||
arrayEntry: JsonValue,
|
||||
arrayIndex: number,
|
||||
resolveExtensionRef: (ref: string) => Extension<unknown>,
|
||||
generateExtensionId: () => string,
|
||||
): Partial<ExtensionInstanceParameters> {
|
||||
): ExtensionParameters {
|
||||
function errorMsg(msg: string, key?: string, prop?: string) {
|
||||
return `Invalid extension configuration at app.extensions[${arrayIndex}]${
|
||||
key ? `[${key}]` : ''
|
||||
@@ -93,6 +76,7 @@ export function expandShorthandExtensionParameters(
|
||||
}
|
||||
return {
|
||||
id: arrayEntry,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,111 +94,43 @@ export function expandShorthandExtensionParameters(
|
||||
throw new Error(errorMsg(`must have exactly one key, got ${joinedKeys}`));
|
||||
}
|
||||
|
||||
const key = keys[0];
|
||||
const key = String(keys[0]);
|
||||
const value = arrayEntry[key];
|
||||
if (!key.match(/^[\.a-zA-Z0-9]+$/)) {
|
||||
throw new Error(
|
||||
errorMsg(`key must only contain letters, numbers and dots, got ${key}`),
|
||||
);
|
||||
}
|
||||
|
||||
// Example YAML:
|
||||
// - catalog.page.cicd: false
|
||||
if (typeof value === 'boolean') {
|
||||
if (key.includes('/')) {
|
||||
const suggestion = key.split('/')[0];
|
||||
throw new Error(
|
||||
errorMsg(
|
||||
`cannot target an extension instance input (key cannot contain slashes; did you mean '${suggestion}'?)`,
|
||||
key,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: key,
|
||||
disabled: !value,
|
||||
};
|
||||
}
|
||||
|
||||
// Example YAML:
|
||||
// - core.router/routes: '@backstage/plugin-some-plugin#MyPage'
|
||||
// - some-plugin.page: '@internal/frontend-customizations#MyModifiedPage'
|
||||
if (typeof value === 'string') {
|
||||
if (key.includes('/')) {
|
||||
return {
|
||||
id: generateExtensionId(),
|
||||
at: key,
|
||||
extension: resolveExtensionRef(value),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: key,
|
||||
extension: resolveExtensionRef(value),
|
||||
};
|
||||
}
|
||||
|
||||
// The remaining case is the generic object. Example YAML:
|
||||
// - core.router/routes:
|
||||
// extension: '@backstage/core-app-api#Redirect'
|
||||
// config:
|
||||
// path: /
|
||||
// to: /catalog
|
||||
// - tech-radar.page:
|
||||
// at: core.router/routes
|
||||
// extension: '@backstage/plugin-tech-radar#TechRadarPage'
|
||||
// disabled: false
|
||||
// config:
|
||||
// path: /tech-radar
|
||||
// width: 1500
|
||||
// height: 800
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error(
|
||||
errorMsg('value must be a boolean, string, or object', key),
|
||||
);
|
||||
throw new Error(errorMsg('value must be a boolean or object', key));
|
||||
}
|
||||
|
||||
let id = value.id;
|
||||
let at = value.at;
|
||||
const at = value.at;
|
||||
const disabled = value.disabled;
|
||||
const extensionRef = value.extension;
|
||||
const config = value.config;
|
||||
|
||||
if (key.includes('/')) {
|
||||
if (at !== undefined) {
|
||||
throw new Error(
|
||||
errorMsg(
|
||||
`must not redundantly specify 'at' when the extension input ID form of the key is used (with a slash); the 'at' is already implicitly '${key}'`,
|
||||
key,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (id !== undefined) {
|
||||
throw new Error(
|
||||
errorMsg(
|
||||
`must not specify 'id' when the extension input ID form of the key is used (with a slash); please replace the key '${key}' with the id instead, and put that key in the 'at' field`,
|
||||
key,
|
||||
'id',
|
||||
),
|
||||
);
|
||||
}
|
||||
id = generateExtensionId();
|
||||
at = key;
|
||||
} else {
|
||||
if (id !== undefined) {
|
||||
throw new Error(
|
||||
errorMsg(
|
||||
`must not redundantly specify 'id' when the extension instance ID form of the key is used (without a slash); the 'id' is already implicitly '${key}'`,
|
||||
key,
|
||||
),
|
||||
);
|
||||
}
|
||||
id = key;
|
||||
}
|
||||
|
||||
if (id !== undefined && typeof id !== 'string') {
|
||||
throw new Error(errorMsg('must be a string', key, 'id'));
|
||||
} else if (at !== undefined && typeof at !== 'string') {
|
||||
if (at !== undefined && typeof at !== 'string') {
|
||||
throw new Error(errorMsg('must be a string', key, 'at'));
|
||||
} else if (disabled !== undefined && typeof disabled !== 'boolean') {
|
||||
throw new Error(errorMsg('must be a boolean', key, 'disabled'));
|
||||
} else if (extensionRef !== undefined && typeof extensionRef !== 'string') {
|
||||
throw new Error(errorMsg('must be a string', key, 'extension'));
|
||||
} else if (
|
||||
config !== undefined &&
|
||||
(typeof config !== 'object' || config === null || Array.isArray(config))
|
||||
@@ -223,12 +139,12 @@ export function expandShorthandExtensionParameters(
|
||||
}
|
||||
|
||||
const unknownKeys = Object.keys(value).filter(
|
||||
k => !knownExtensionInstanceParameters.includes(k),
|
||||
k => !knownExtensionParameters.includes(k),
|
||||
);
|
||||
if (unknownKeys.length > 0) {
|
||||
throw new Error(
|
||||
errorMsg(
|
||||
`unknown parameter; expected one of '${knownExtensionInstanceParameters.join(
|
||||
`unknown parameter; expected one of '${knownExtensionParameters.join(
|
||||
"', '",
|
||||
)}'`,
|
||||
key,
|
||||
@@ -237,63 +153,67 @@ export function expandShorthandExtensionParameters(
|
||||
);
|
||||
}
|
||||
|
||||
const extension: Extension<unknown> | undefined = extensionRef
|
||||
? resolveExtensionRef(extensionRef)
|
||||
: undefined;
|
||||
return {
|
||||
id: key,
|
||||
at,
|
||||
disabled,
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
return omitBy(
|
||||
{
|
||||
id,
|
||||
at,
|
||||
disabled,
|
||||
extension,
|
||||
config,
|
||||
},
|
||||
v => v === undefined,
|
||||
);
|
||||
export interface ExtensionInstanceParameters {
|
||||
extension: Extension<unknown>;
|
||||
at: string;
|
||||
config?: unknown;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function mergeExtensionParameters(
|
||||
base: ExtensionInstanceParameters[],
|
||||
overrides: Array<Partial<ExtensionInstanceParameters>>,
|
||||
base: Extension<unknown>[],
|
||||
parameters: Array<ExtensionParameters>,
|
||||
): ExtensionInstanceParameters[] {
|
||||
const extensionInstanceParams = base.slice();
|
||||
const overrides = base.map(extension => ({
|
||||
extension,
|
||||
params: {
|
||||
at: extension.at,
|
||||
disabled: extension.disabled,
|
||||
config: undefined as unknown,
|
||||
},
|
||||
}));
|
||||
|
||||
for (const overrideParam of overrides) {
|
||||
const existingParamIndex = extensionInstanceParams.findIndex(
|
||||
e => e.id === overrideParam.id,
|
||||
for (const overrideParam of parameters) {
|
||||
const existingIndex = overrides.findIndex(
|
||||
e => e.extension.id === overrideParam.id,
|
||||
);
|
||||
if (existingParamIndex !== -1) {
|
||||
const existingParam = extensionInstanceParams[existingParamIndex];
|
||||
if (existingIndex !== -1) {
|
||||
const existing = overrides[existingIndex];
|
||||
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;
|
||||
existing.params.at = overrideParam.at;
|
||||
}
|
||||
if (overrideParam.config) {
|
||||
// TODO: merge config?
|
||||
existingParam.config = overrideParam.config;
|
||||
existing.params.config = overrideParam.config;
|
||||
}
|
||||
if (Boolean(existingParam.disabled) !== Boolean(overrideParam.disabled)) {
|
||||
existingParam.disabled = Boolean(overrideParam.disabled);
|
||||
if (!existingParam.disabled) {
|
||||
if (
|
||||
Boolean(existing.params.disabled) !== Boolean(overrideParam.disabled)
|
||||
) {
|
||||
existing.params.disabled = Boolean(overrideParam.disabled);
|
||||
if (!existing.params.disabled) {
|
||||
// bump
|
||||
extensionInstanceParams.splice(existingParamIndex, 1);
|
||||
extensionInstanceParams.push(existingParam);
|
||||
overrides.splice(existingIndex, 1);
|
||||
overrides.push(existing);
|
||||
}
|
||||
}
|
||||
} 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 });
|
||||
} else {
|
||||
throw new Error(`Extension ${overrideParam.id} does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
return extensionInstanceParams.filter(param => !param.disabled);
|
||||
return overrides
|
||||
.filter(override => !override.params.disabled)
|
||||
.map(param => ({
|
||||
extension: param.extension,
|
||||
at: param.params.at,
|
||||
config: param.params.config,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ export function createPageExtension<
|
||||
configSchema: PortableSchema<TConfig>;
|
||||
}
|
||||
) & {
|
||||
id: string;
|
||||
at?: string;
|
||||
disabled?: boolean;
|
||||
inputs?: TInputs;
|
||||
component: (props: {
|
||||
config: TConfig;
|
||||
@@ -60,6 +63,9 @@ export function createPageExtension<
|
||||
) as PortableSchema<TConfig>);
|
||||
|
||||
return createExtension({
|
||||
id: options.id,
|
||||
at: options.at ?? 'core.router/routes',
|
||||
disabled: options.disabled,
|
||||
output: {
|
||||
component: coreExtensionData.reactComponent,
|
||||
path: coreExtensionData.routePath,
|
||||
|
||||
@@ -37,5 +37,4 @@ export {
|
||||
type ExtensionDataBind,
|
||||
type ExtensionDataRef,
|
||||
type ExtensionDataValue,
|
||||
type ExtensionInstanceParameters,
|
||||
} from './types';
|
||||
|
||||
@@ -54,6 +54,9 @@ export interface CreateExtensionOptions<
|
||||
TPoint extends Record<string, { extensionData: AnyExtensionDataMap }>,
|
||||
TConfig,
|
||||
> {
|
||||
id: string;
|
||||
at: string;
|
||||
disabled?: boolean;
|
||||
inputs?: TPoint;
|
||||
output: TData;
|
||||
configSchema?: PortableSchema<TConfig>;
|
||||
@@ -71,7 +74,9 @@ export interface CreateExtensionOptions<
|
||||
/** @public */
|
||||
export interface Extension<TConfig> {
|
||||
$$type: 'extension';
|
||||
// TODO: will extensions have a default "at" as part of their contract, making it optional in the instance config?
|
||||
id: string;
|
||||
at: string;
|
||||
disabled: boolean;
|
||||
inputs: Record<string, { extensionData: AnyExtensionDataMap }>;
|
||||
output: AnyExtensionDataMap;
|
||||
configSchema?: PortableSchema<TConfig>;
|
||||
@@ -88,29 +93,25 @@ export function createExtension<
|
||||
TPoint extends Record<string, { extensionData: AnyExtensionDataMap }>,
|
||||
TConfig = never,
|
||||
>(options: CreateExtensionOptions<TData, TPoint, TConfig>): Extension<TConfig> {
|
||||
return { ...options, $$type: 'extension', inputs: options.inputs ?? {} };
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface ExtensionInstanceParameters {
|
||||
id: string;
|
||||
at: string;
|
||||
extension: Extension<unknown>;
|
||||
config?: unknown;
|
||||
disabled?: boolean;
|
||||
return {
|
||||
...options,
|
||||
disabled: options.disabled ?? false,
|
||||
$$type: 'extension',
|
||||
inputs: options.inputs ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface BackstagePluginOptions {
|
||||
id: string;
|
||||
defaultExtensionInstances?: ExtensionInstanceParameters[];
|
||||
defaultExtensions?: Extension<unknown>[];
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface BackstagePlugin {
|
||||
$$type: 'backstage-plugin';
|
||||
id: string;
|
||||
defaultExtensionInstances: ExtensionInstanceParameters[];
|
||||
defaultExtensions: Extension<unknown>[];
|
||||
}
|
||||
|
||||
/** @public */
|
||||
@@ -118,6 +119,6 @@ export function createPlugin(options: BackstagePluginOptions): BackstagePlugin {
|
||||
return {
|
||||
...options,
|
||||
$$type: 'backstage-plugin',
|
||||
defaultExtensionInstances: options.defaultExtensionInstances ?? [],
|
||||
defaultExtensions: options.defaultExtensions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user