Merge pull request #20667 from backstage/rugvip/app-graph
frontend-app-api: refactor app extension management into graph
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-app-api': patch
|
||||
---
|
||||
|
||||
Refactor internal extension instance system into an app graph.
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
|
||||
export const Core = createExtension({
|
||||
id: 'core',
|
||||
attachTo: { id: 'root', input: 'default' },
|
||||
attachTo: { id: 'root', input: 'default' }, // ignored
|
||||
inputs: {
|
||||
apis: createExtensionInput({
|
||||
api: coreExtensionData.apiFactory,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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 {
|
||||
createExtension,
|
||||
createExtensionOverrides,
|
||||
createPlugin,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { MockConfigApi } from '@backstage/test-utils';
|
||||
import { createAppGraph } from './createAppGraph';
|
||||
|
||||
const extBase = {
|
||||
id: 'test',
|
||||
attachTo: { id: 'core', input: 'root' },
|
||||
output: {},
|
||||
factory() {},
|
||||
};
|
||||
|
||||
describe('createAppGraph', () => {
|
||||
it('throws an error when a core extension is parametrized', () => {
|
||||
const config = new MockConfigApi({
|
||||
app: {
|
||||
extensions: [
|
||||
{
|
||||
core: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const features = [
|
||||
createPlugin({
|
||||
id: 'plugin',
|
||||
extensions: [],
|
||||
}),
|
||||
];
|
||||
expect(() =>
|
||||
createAppGraph({ features, config, builtinExtensions: [] }),
|
||||
).toThrow("Configuration of the 'core' extension is forbidden");
|
||||
});
|
||||
|
||||
it('throws an error when a core extension is overridden', () => {
|
||||
const config = new MockConfigApi({});
|
||||
const features = [
|
||||
createPlugin({
|
||||
id: 'plugin',
|
||||
extensions: [
|
||||
createExtension({
|
||||
id: 'core',
|
||||
attachTo: { id: 'core.routes', input: 'route' },
|
||||
inputs: {},
|
||||
output: {},
|
||||
factory() {},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
expect(() =>
|
||||
createAppGraph({ features, config, builtinExtensions: [] }),
|
||||
).toThrow(
|
||||
"It is forbidden to override the following extension(s): 'core', which is done by the following plugin(s): 'plugin'",
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when duplicated extensions are detected', () => {
|
||||
const config = new MockConfigApi({});
|
||||
|
||||
const ExtensionA = createExtension({ ...extBase, id: 'A' });
|
||||
|
||||
const ExtensionB = createExtension({ ...extBase, id: 'B' });
|
||||
|
||||
const PluginA = createPlugin({
|
||||
id: 'A',
|
||||
extensions: [ExtensionA, ExtensionA],
|
||||
});
|
||||
|
||||
const PluginB = createPlugin({
|
||||
id: 'B',
|
||||
extensions: [ExtensionA, ExtensionB, ExtensionB],
|
||||
});
|
||||
|
||||
const features = [PluginA, PluginB];
|
||||
|
||||
expect(() =>
|
||||
createAppGraph({ features, config, builtinExtensions: [] }),
|
||||
).toThrow(
|
||||
"The following extensions are duplicated: The extension 'A' was provided 2 time(s) by the plugin 'A' and 1 time(s) by the plugin 'B', The extension 'B' was provided 2 time(s) by the plugin 'B'",
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when duplicated extension overrides are detected', () => {
|
||||
expect(() =>
|
||||
createAppGraph({
|
||||
features: [
|
||||
createExtensionOverrides({
|
||||
extensions: [
|
||||
createExtension({ ...extBase, id: 'a' }),
|
||||
createExtension({ ...extBase, id: 'a' }),
|
||||
createExtension({ ...extBase, id: 'b' }),
|
||||
],
|
||||
}),
|
||||
createExtensionOverrides({
|
||||
extensions: [createExtension({ ...extBase, id: 'b' })],
|
||||
}),
|
||||
],
|
||||
config: new MockConfigApi({}),
|
||||
builtinExtensions: [],
|
||||
}),
|
||||
).toThrow('The following extensions had duplicate overrides: a, b');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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 {
|
||||
BackstagePlugin,
|
||||
Extension,
|
||||
ExtensionOverrides,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { readAppExtensionsConfig } from './readAppExtensionsConfig';
|
||||
import { resolveAppGraph } from './resolveAppGraph';
|
||||
import { resolveAppNodeSpecs } from './resolveAppNodeSpecs';
|
||||
import { AppGraph } from './types';
|
||||
import { Config } from '@backstage/config';
|
||||
import { instantiateAppNodeTree } from './instantiateAppNodeTree';
|
||||
|
||||
/** @internal */
|
||||
export interface CreateAppGraphOptions {
|
||||
features: (BackstagePlugin | ExtensionOverrides)[];
|
||||
builtinExtensions: Extension<unknown>[];
|
||||
config: Config;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function createAppGraph(options: CreateAppGraphOptions): AppGraph {
|
||||
const appGraph = resolveAppGraph(
|
||||
'core',
|
||||
resolveAppNodeSpecs({
|
||||
features: options.features,
|
||||
builtinExtensions: options.builtinExtensions,
|
||||
parameters: readAppExtensionsConfig(options.config),
|
||||
forbidden: new Set(['core']),
|
||||
}),
|
||||
);
|
||||
instantiateAppNodeTree(appGraph.root);
|
||||
return appGraph;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export type {
|
||||
AppNode,
|
||||
AppNodeEdges,
|
||||
AppNodeInstance,
|
||||
AppNodeSpec,
|
||||
} from './types';
|
||||
export { createAppGraph } from './createAppGraph';
|
||||
@@ -0,0 +1,554 @@
|
||||
/*
|
||||
* 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 {
|
||||
Extension,
|
||||
createExtension,
|
||||
createExtensionDataRef,
|
||||
createExtensionInput,
|
||||
createSchemaFromZod,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
createAppNodeInstance,
|
||||
instantiateAppNodeTree,
|
||||
} from './instantiateAppNodeTree';
|
||||
import { AppNodeInstance, AppNodeSpec } from './types';
|
||||
import { resolveAppGraph } from './resolveAppGraph';
|
||||
|
||||
const testDataRef = createExtensionDataRef<string>('test');
|
||||
const otherDataRef = createExtensionDataRef<number>('other');
|
||||
const inputMirrorDataRef = createExtensionDataRef<unknown>('mirror');
|
||||
|
||||
const simpleExtension = createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
output: {
|
||||
test: testDataRef,
|
||||
other: otherDataRef.optional(),
|
||||
},
|
||||
configSchema: createSchemaFromZod(z =>
|
||||
z.object({
|
||||
output: z.string().default('test'),
|
||||
other: z.number().optional(),
|
||||
}),
|
||||
),
|
||||
factory({ bind, config }) {
|
||||
bind({ test: config.output, other: config.other });
|
||||
},
|
||||
});
|
||||
|
||||
function makeSpec<TConfig>(
|
||||
extension: Extension<TConfig>,
|
||||
config?: TConfig,
|
||||
): AppNodeSpec {
|
||||
return {
|
||||
id: extension.id,
|
||||
attachTo: extension.attachTo,
|
||||
disabled: extension.disabled,
|
||||
extension,
|
||||
config,
|
||||
source: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function makeInstanceWithId<TConfig>(
|
||||
extension: Extension<TConfig>,
|
||||
config?: TConfig,
|
||||
): { id: string; instance: AppNodeInstance } {
|
||||
return {
|
||||
id: extension.id,
|
||||
instance: createAppNodeInstance({
|
||||
spec: makeSpec(extension, config),
|
||||
attachments: new Map(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('instantiateAppNodeTree', () => {
|
||||
it('should instantiate a single node', () => {
|
||||
const graph = resolveAppGraph('root-node', [
|
||||
{ ...makeSpec(simpleExtension), id: 'root-node' },
|
||||
]);
|
||||
expect(graph.root.instance).not.toBeDefined();
|
||||
instantiateAppNodeTree(graph.root);
|
||||
expect(graph.root.instance).toBeDefined();
|
||||
expect(graph.root.instance?.getData(testDataRef)).toBe('test');
|
||||
|
||||
// Multiple calls should have no effect
|
||||
instantiateAppNodeTree(graph.root);
|
||||
expect(graph.root.instance).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not instantiate disabled nodes', () => {
|
||||
const graph = resolveAppGraph('root-node', [
|
||||
{ ...makeSpec(simpleExtension), id: 'root-node', disabled: true },
|
||||
]);
|
||||
expect(graph.root.instance).not.toBeDefined();
|
||||
instantiateAppNodeTree(graph.root);
|
||||
expect(graph.root.instance).not.toBeDefined();
|
||||
});
|
||||
|
||||
it('should instantiate a node with attachments', () => {
|
||||
const graph = resolveAppGraph('root-node', [
|
||||
{
|
||||
...makeSpec(
|
||||
createExtension({
|
||||
id: 'root-node',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
inputs: {
|
||||
test: createExtensionInput({ test: testDataRef }),
|
||||
},
|
||||
output: {
|
||||
inputMirror: inputMirrorDataRef,
|
||||
},
|
||||
factory({ bind, inputs }) {
|
||||
bind({ inputMirror: inputs });
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
...makeSpec(simpleExtension),
|
||||
id: 'child-node',
|
||||
attachTo: { id: 'root-node', input: 'test' },
|
||||
},
|
||||
]);
|
||||
|
||||
const childNode = graph.nodes.get('child-node');
|
||||
expect(childNode).toBeDefined();
|
||||
|
||||
expect(graph.root.instance).not.toBeDefined();
|
||||
expect(childNode?.instance).not.toBeDefined();
|
||||
instantiateAppNodeTree(graph.root);
|
||||
expect(graph.root.instance).toBeDefined();
|
||||
expect(childNode?.instance).toBeDefined();
|
||||
expect(graph.root.instance?.getData(inputMirrorDataRef)).toEqual({
|
||||
test: [{ test: 'test' }],
|
||||
});
|
||||
|
||||
// Multiple calls should have no effect
|
||||
instantiateAppNodeTree(graph.root);
|
||||
expect(graph.root.instance).toBeDefined();
|
||||
expect(childNode?.instance).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not instantiate disabled attachments', () => {
|
||||
const graph = resolveAppGraph('root-node', [
|
||||
{
|
||||
...makeSpec(
|
||||
createExtension({
|
||||
id: 'root-node',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
inputs: {
|
||||
test: createExtensionInput({ test: testDataRef }),
|
||||
},
|
||||
output: {
|
||||
inputMirror: inputMirrorDataRef,
|
||||
},
|
||||
factory({ bind, inputs }) {
|
||||
bind({ inputMirror: inputs });
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
...makeSpec(simpleExtension),
|
||||
id: 'child-node',
|
||||
attachTo: { id: 'root-node', input: 'test' },
|
||||
disabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const childNode = graph.nodes.get('child-node');
|
||||
expect(childNode).toBeDefined();
|
||||
|
||||
expect(graph.root.instance).not.toBeDefined();
|
||||
expect(childNode?.instance).not.toBeDefined();
|
||||
instantiateAppNodeTree(graph.root);
|
||||
expect(graph.root.instance).toBeDefined();
|
||||
expect(childNode?.instance).not.toBeDefined();
|
||||
expect(graph.root.instance?.getData(inputMirrorDataRef)).toEqual({
|
||||
test: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAppNodeInstance', () => {
|
||||
it('should create a simple extension instance', () => {
|
||||
const attachments = new Map();
|
||||
const instance = createAppNodeInstance({
|
||||
spec: makeSpec(simpleExtension),
|
||||
attachments,
|
||||
});
|
||||
|
||||
expect(Array.from(instance.getDataRefs())).toEqual([
|
||||
testDataRef,
|
||||
otherDataRef.optional(),
|
||||
]);
|
||||
expect(instance.getData(testDataRef)).toEqual('test');
|
||||
});
|
||||
|
||||
it('should create an extension with different kind of inputs', () => {
|
||||
const attachments = new Map([
|
||||
[
|
||||
'optionalSingletonPresent',
|
||||
[
|
||||
makeInstanceWithId(simpleExtension, {
|
||||
output: 'optionalSingletonPresent',
|
||||
}),
|
||||
],
|
||||
],
|
||||
[
|
||||
'singleton',
|
||||
[
|
||||
makeInstanceWithId(simpleExtension, {
|
||||
output: 'singleton',
|
||||
other: 2,
|
||||
}),
|
||||
],
|
||||
],
|
||||
[
|
||||
'many',
|
||||
[
|
||||
makeInstanceWithId(simpleExtension, { output: 'many1' }),
|
||||
makeInstanceWithId(simpleExtension, { output: 'many2', other: 3 }),
|
||||
],
|
||||
],
|
||||
]);
|
||||
const instance = createAppNodeInstance({
|
||||
attachments,
|
||||
spec: makeSpec(
|
||||
createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
inputs: {
|
||||
optionalSingletonPresent: createExtensionInput(
|
||||
{
|
||||
test: testDataRef,
|
||||
other: otherDataRef.optional(),
|
||||
},
|
||||
{ singleton: true, optional: true },
|
||||
),
|
||||
optionalSingletonMissing: createExtensionInput(
|
||||
{
|
||||
test: testDataRef,
|
||||
other: otherDataRef.optional(),
|
||||
},
|
||||
{ singleton: true, optional: true },
|
||||
),
|
||||
singleton: createExtensionInput(
|
||||
{
|
||||
test: testDataRef,
|
||||
other: otherDataRef.optional(),
|
||||
},
|
||||
{ singleton: true },
|
||||
),
|
||||
many: createExtensionInput({
|
||||
test: testDataRef,
|
||||
other: otherDataRef.optional(),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
inputMirror: inputMirrorDataRef,
|
||||
},
|
||||
factory({ bind, inputs }) {
|
||||
bind({ inputMirror: inputs });
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
expect(Array.from(instance.getDataRefs())).toEqual([inputMirrorDataRef]);
|
||||
expect(instance.getData(inputMirrorDataRef)).toEqual({
|
||||
optionalSingletonPresent: { test: 'optionalSingletonPresent' },
|
||||
singleton: { test: 'singleton', other: 2 },
|
||||
many: [{ test: 'many1' }, { test: 'many2', other: 3 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should refuse to create an extension with invalid config', () => {
|
||||
expect(() =>
|
||||
createAppNodeInstance({
|
||||
spec: {
|
||||
...makeSpec(simpleExtension),
|
||||
config: { other: 'not-a-number' },
|
||||
},
|
||||
attachments: new Map(),
|
||||
}),
|
||||
).toThrow(
|
||||
"Invalid configuration for extension 'core.test'; caused by Error: Expected number, received string at 'other'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should forward extension factory errors', () => {
|
||||
expect(() =>
|
||||
createAppNodeInstance({
|
||||
spec: makeSpec(
|
||||
createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
output: {},
|
||||
factory() {
|
||||
const error = new Error('NOPE');
|
||||
error.name = 'NopeError';
|
||||
throw error;
|
||||
},
|
||||
}),
|
||||
),
|
||||
attachments: new Map(),
|
||||
}),
|
||||
).toThrow(
|
||||
"Failed to instantiate extension 'core.test'; caused by NopeError: NOPE",
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse to create an instance with duplicate output', () => {
|
||||
expect(() =>
|
||||
createAppNodeInstance({
|
||||
spec: makeSpec(
|
||||
createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
output: {
|
||||
test1: testDataRef,
|
||||
test2: testDataRef,
|
||||
},
|
||||
factory({ bind }) {
|
||||
bind({ test1: 'test', test2: 'test2' });
|
||||
},
|
||||
}),
|
||||
),
|
||||
attachments: new Map(),
|
||||
}),
|
||||
).toThrow(
|
||||
"Failed to instantiate extension 'core.test', duplicate extension data 'test' received via output 'test2'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse to create an instance with disconnected output data', () => {
|
||||
expect(() =>
|
||||
createAppNodeInstance({
|
||||
spec: makeSpec(
|
||||
createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
output: {
|
||||
test: testDataRef,
|
||||
},
|
||||
factory({ bind }) {
|
||||
bind({ nonexistent: 'test' } as any);
|
||||
},
|
||||
}),
|
||||
),
|
||||
attachments: new Map(),
|
||||
}),
|
||||
).toThrow(
|
||||
"Failed to instantiate extension 'core.test', unknown output provided via 'nonexistent'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse to create an instance with missing required input', () => {
|
||||
expect(() =>
|
||||
createAppNodeInstance({
|
||||
spec: makeSpec(
|
||||
createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
inputs: {
|
||||
singleton: createExtensionInput(
|
||||
{
|
||||
test: testDataRef,
|
||||
},
|
||||
{ singleton: true },
|
||||
),
|
||||
},
|
||||
output: {},
|
||||
factory() {},
|
||||
}),
|
||||
),
|
||||
attachments: new Map(),
|
||||
}),
|
||||
).toThrow(
|
||||
"Failed to instantiate extension 'core.test', input 'singleton' is required but was not received",
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse to create an instance with undeclared inputs', () => {
|
||||
expect(() =>
|
||||
createAppNodeInstance({
|
||||
attachments: new Map([
|
||||
[
|
||||
'declared',
|
||||
[
|
||||
makeInstanceWithId(simpleExtension, {
|
||||
output: 'many1',
|
||||
}),
|
||||
],
|
||||
],
|
||||
[
|
||||
'undeclared',
|
||||
[
|
||||
makeInstanceWithId(simpleExtension, {
|
||||
output: 'many1',
|
||||
}),
|
||||
],
|
||||
],
|
||||
]),
|
||||
spec: makeSpec(
|
||||
createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
inputs: {
|
||||
declared: createExtensionInput({
|
||||
test: testDataRef,
|
||||
}),
|
||||
},
|
||||
output: {},
|
||||
factory() {},
|
||||
}),
|
||||
),
|
||||
}),
|
||||
).toThrow(
|
||||
"Failed to instantiate extension 'core.test', received undeclared input 'undeclared' from extension 'core.test'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse to create an instance with multiple undeclared inputs', () => {
|
||||
expect(() =>
|
||||
createAppNodeInstance({
|
||||
attachments: new Map([
|
||||
[
|
||||
'undeclared1',
|
||||
[makeInstanceWithId(simpleExtension, { output: 'many1' })],
|
||||
],
|
||||
[
|
||||
'undeclared2',
|
||||
[
|
||||
makeInstanceWithId(simpleExtension, { output: 'many1' }),
|
||||
makeInstanceWithId(simpleExtension, { output: 'many1' }),
|
||||
],
|
||||
],
|
||||
]),
|
||||
spec: makeSpec(
|
||||
createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
output: {},
|
||||
factory() {},
|
||||
}),
|
||||
),
|
||||
}),
|
||||
).toThrow(
|
||||
"Failed to instantiate extension 'core.test', received undeclared inputs 'undeclared1' from extension 'core.test' and 'undeclared2' from extensions 'core.test', 'core.test'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse to create an instance with multiple inputs for required singleton', () => {
|
||||
expect(() =>
|
||||
createAppNodeInstance({
|
||||
attachments: new Map([
|
||||
[
|
||||
'singleton',
|
||||
[
|
||||
makeInstanceWithId(simpleExtension, { output: 'many1' }),
|
||||
makeInstanceWithId(simpleExtension, { output: 'many2' }),
|
||||
],
|
||||
],
|
||||
]),
|
||||
spec: makeSpec(
|
||||
createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
inputs: {
|
||||
singleton: createExtensionInput(
|
||||
{
|
||||
test: testDataRef,
|
||||
},
|
||||
{ singleton: true },
|
||||
),
|
||||
},
|
||||
output: {},
|
||||
factory() {},
|
||||
}),
|
||||
),
|
||||
}),
|
||||
).toThrow(
|
||||
"Failed to instantiate extension 'core.test', expected exactly one 'singleton' input but received multiple: 'core.test', 'core.test'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse to create an instance with multiple inputs for optional singleton', () => {
|
||||
expect(() =>
|
||||
createAppNodeInstance({
|
||||
attachments: new Map([
|
||||
[
|
||||
'singleton',
|
||||
[
|
||||
makeInstanceWithId(simpleExtension, { output: 'many1' }),
|
||||
makeInstanceWithId(simpleExtension, { output: 'many2' }),
|
||||
],
|
||||
],
|
||||
]),
|
||||
spec: makeSpec(
|
||||
createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
inputs: {
|
||||
singleton: createExtensionInput(
|
||||
{
|
||||
test: testDataRef,
|
||||
},
|
||||
{ singleton: true, optional: true },
|
||||
),
|
||||
},
|
||||
output: {},
|
||||
factory() {},
|
||||
}),
|
||||
),
|
||||
}),
|
||||
).toThrow(
|
||||
"Failed to instantiate extension 'core.test', expected at most one 'singleton' input but received multiple: 'core.test', 'core.test'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse to create an instance with multiple inputs that did not provide required data', () => {
|
||||
expect(() =>
|
||||
createAppNodeInstance({
|
||||
attachments: new Map([
|
||||
['singleton', [makeInstanceWithId(simpleExtension, undefined)]],
|
||||
]),
|
||||
spec: makeSpec(
|
||||
createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
inputs: {
|
||||
singleton: createExtensionInput(
|
||||
{
|
||||
other: otherDataRef,
|
||||
},
|
||||
{ singleton: true },
|
||||
),
|
||||
},
|
||||
output: {},
|
||||
factory() {},
|
||||
}),
|
||||
),
|
||||
}),
|
||||
).toThrow(
|
||||
"Failed to instantiate extension 'core.test', input 'singleton' did not receive required extension data 'other' from extension 'core.test'",
|
||||
);
|
||||
});
|
||||
});
|
||||
+75
-110
@@ -17,36 +17,22 @@
|
||||
import {
|
||||
AnyExtensionDataMap,
|
||||
AnyExtensionInputMap,
|
||||
BackstagePlugin,
|
||||
Extension,
|
||||
ExtensionDataRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import mapValues from 'lodash/mapValues';
|
||||
import { AppNode, AppNodeInstance, AppNodeSpec } from './types';
|
||||
|
||||
/** @internal */
|
||||
export interface ExtensionInstance {
|
||||
readonly $$type: '@backstage/ExtensionInstance';
|
||||
|
||||
readonly id: string;
|
||||
/**
|
||||
* Get concrete value for the given extension data reference. Returns undefined if no value is available.
|
||||
*/
|
||||
getData<T>(ref: ExtensionDataRef<T>): T | undefined;
|
||||
/**
|
||||
* Maps input names to the actual instances given to them.
|
||||
*/
|
||||
readonly attachments: Map<string, ExtensionInstance[]>;
|
||||
|
||||
readonly source?: BackstagePlugin;
|
||||
}
|
||||
type Mutable<T> = {
|
||||
-readonly [P in keyof T]: T[P];
|
||||
};
|
||||
|
||||
function resolveInputData(
|
||||
dataMap: AnyExtensionDataMap,
|
||||
attachment: ExtensionInstance,
|
||||
attachment: { id: string; instance: AppNodeInstance },
|
||||
inputName: string,
|
||||
) {
|
||||
return mapValues(dataMap, ref => {
|
||||
const value = attachment.getData(ref);
|
||||
const value = attachment.instance.getData(ref);
|
||||
if (value === undefined && !ref.config.optional) {
|
||||
throw new Error(
|
||||
`input '${inputName}' did not receive required extension data '${ref.id}' from extension '${attachment.id}'`,
|
||||
@@ -58,7 +44,7 @@ function resolveInputData(
|
||||
|
||||
function resolveInputs(
|
||||
inputMap: AnyExtensionInputMap,
|
||||
attachments: Map<string, ExtensionInstance[]>,
|
||||
attachments: ReadonlyMap<string, { id: string; instance: AppNodeInstance }[]>,
|
||||
) {
|
||||
const undeclaredAttachments = Array.from(attachments.entries()).filter(
|
||||
([inputName]) => inputMap[inputName] === undefined,
|
||||
@@ -80,113 +66,49 @@ function resolveInputs(
|
||||
}
|
||||
|
||||
return mapValues(inputMap, (input, inputName) => {
|
||||
const attachedInstances = attachments.get(inputName) ?? [];
|
||||
const attachedNodes = attachments.get(inputName) ?? [];
|
||||
|
||||
if (input.config.singleton) {
|
||||
if (attachedInstances.length > 1) {
|
||||
if (attachedNodes.length > 1) {
|
||||
const attachedNodeIds = attachedNodes.map(e => e.id);
|
||||
throw Error(
|
||||
`expected ${
|
||||
input.config.optional ? 'at most' : 'exactly'
|
||||
} one '${inputName}' input but received multiple: '${attachedInstances
|
||||
.map(e => e.id)
|
||||
.join("', '")}'`,
|
||||
} one '${inputName}' input but received multiple: '${attachedNodeIds.join(
|
||||
"', '",
|
||||
)}'`,
|
||||
);
|
||||
} else if (attachedInstances.length === 0) {
|
||||
} else if (attachedNodes.length === 0) {
|
||||
if (input.config.optional) {
|
||||
return undefined;
|
||||
}
|
||||
throw Error(`input '${inputName}' is required but was not received`);
|
||||
}
|
||||
return resolveInputData(
|
||||
input.extensionData,
|
||||
attachedInstances[0],
|
||||
inputName,
|
||||
);
|
||||
return resolveInputData(input.extensionData, attachedNodes[0], inputName);
|
||||
}
|
||||
|
||||
return attachedInstances.map(attachment =>
|
||||
return attachedNodes.map(attachment =>
|
||||
resolveInputData(input.extensionData, attachment, inputName),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function indent(str: string) {
|
||||
return str.replace(/^/gm, ' ');
|
||||
}
|
||||
|
||||
class ExtensionInstanceImpl implements ExtensionInstance {
|
||||
readonly $$type = '@backstage/ExtensionInstance';
|
||||
|
||||
readonly id: string;
|
||||
readonly #extensionData: Map<string, unknown>;
|
||||
readonly attachments: Map<string, ExtensionInstance[]>;
|
||||
readonly source?: BackstagePlugin;
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
extensionData: Map<string, unknown>,
|
||||
attachments: Map<string, ExtensionInstance[]>,
|
||||
source: BackstagePlugin | undefined,
|
||||
) {
|
||||
this.id = id;
|
||||
this.#extensionData = extensionData;
|
||||
this.attachments = attachments;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
getData<T>(ref: ExtensionDataRef<T>): T | undefined {
|
||||
return this.#extensionData.get(ref.id) as T | undefined;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
output:
|
||||
this.#extensionData.size > 0
|
||||
? [...this.#extensionData.keys()]
|
||||
: undefined,
|
||||
attachments:
|
||||
this.attachments.size > 0
|
||||
? Object.fromEntries(this.attachments)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
toString() {
|
||||
const out =
|
||||
this.#extensionData.size > 0
|
||||
? ` out=[${[...this.#extensionData.keys()].join(', ')}]`
|
||||
: '';
|
||||
|
||||
if (this.attachments.size === 0) {
|
||||
return `<${this.id}${out} />`;
|
||||
}
|
||||
|
||||
return [
|
||||
`<${this.id}${out}>`,
|
||||
...[...this.attachments.entries()].map(([k, v]) =>
|
||||
indent([`${k} [`, ...v.map(e => indent(e.toString())), `]`].join('\n')),
|
||||
),
|
||||
`</${this.id}>`,
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function createExtensionInstance(options: {
|
||||
extension: Extension<unknown>;
|
||||
config: unknown;
|
||||
source?: BackstagePlugin;
|
||||
attachments: Map<string, ExtensionInstance[]>;
|
||||
}): ExtensionInstance {
|
||||
const { extension, config, source, attachments } = options;
|
||||
export function createAppNodeInstance(options: {
|
||||
spec: AppNodeSpec;
|
||||
attachments: ReadonlyMap<string, { id: string; instance: AppNodeInstance }[]>;
|
||||
}): AppNodeInstance {
|
||||
const { spec, attachments } = options;
|
||||
const { id, extension, config, source } = spec;
|
||||
const extensionData = new Map<string, unknown>();
|
||||
const extensionDataRefs = new Set<ExtensionDataRef<unknown>>();
|
||||
|
||||
let parsedConfig: unknown;
|
||||
try {
|
||||
parsedConfig = extension.configSchema?.parse(config ?? {});
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Invalid configuration for extension '${extension.id}'; caused by ${e}`,
|
||||
`Invalid configuration for extension '${id}'; caused by ${e}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -206,22 +128,65 @@ export function createExtensionInstance(options: {
|
||||
);
|
||||
}
|
||||
extensionData.set(ref.id, output);
|
||||
extensionDataRefs.add(ref);
|
||||
}
|
||||
},
|
||||
inputs: resolveInputs(extension.inputs, attachments),
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to instantiate extension '${extension.id}'${
|
||||
`Failed to instantiate extension '${id}'${
|
||||
e.name === 'Error' ? `, ${e.message}` : `; caused by ${e}`
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
return new ExtensionInstanceImpl(
|
||||
options.extension.id,
|
||||
extensionData,
|
||||
attachments,
|
||||
source,
|
||||
);
|
||||
return {
|
||||
getDataRefs() {
|
||||
return extensionDataRefs.values();
|
||||
},
|
||||
getData<T>(ref: ExtensionDataRef<T>): T | undefined {
|
||||
return extensionData.get(ref.id) as T | undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting at the provided node, instantiate all reachable nodes in the graph that have not been disabled.
|
||||
* @internal
|
||||
*/
|
||||
export function instantiateAppNodeTree(rootNode: AppNode): void {
|
||||
function createInstance(node: AppNode): AppNodeInstance | undefined {
|
||||
if (node.instance) {
|
||||
return node.instance;
|
||||
}
|
||||
if (node.spec.disabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const instantiatedAttachments = new Map<
|
||||
string,
|
||||
{ id: string; instance: AppNodeInstance }[]
|
||||
>();
|
||||
|
||||
for (const [input, children] of node.edges.attachments) {
|
||||
const instantiatedChildren = children.flatMap(child => {
|
||||
const childInstance = createInstance(child);
|
||||
if (!childInstance) {
|
||||
return [];
|
||||
}
|
||||
return [{ id: child.spec.id, instance: childInstance }];
|
||||
});
|
||||
instantiatedAttachments.set(input, instantiatedChildren);
|
||||
}
|
||||
|
||||
(node as Mutable<AppNode>).instance = createAppNodeInstance({
|
||||
spec: node.spec,
|
||||
attachments: instantiatedAttachments,
|
||||
});
|
||||
|
||||
return node.instance;
|
||||
}
|
||||
|
||||
createInstance(rootNode);
|
||||
}
|
||||
+10
-198
@@ -15,204 +15,16 @@
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
createExtensionOverrides,
|
||||
createPlugin,
|
||||
Extension,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import {
|
||||
expandShorthandExtensionParameters,
|
||||
mergeExtensionParameters,
|
||||
readAppExtensionParameters,
|
||||
} from './parameters';
|
||||
readAppExtensionsConfig,
|
||||
} from './readAppExtensionsConfig';
|
||||
|
||||
function makeExt(
|
||||
id: string,
|
||||
status: 'disabled' | 'enabled' = 'enabled',
|
||||
attachId: string = 'root',
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
attachTo: { id: attachId, input: 'default' },
|
||||
disabled: status === 'disabled',
|
||||
} as Extension<unknown>;
|
||||
}
|
||||
|
||||
describe('mergeExtensionParameters', () => {
|
||||
it('should filter out disabled extension instances', () => {
|
||||
expect(
|
||||
mergeExtensionParameters({
|
||||
features: [],
|
||||
builtinExtensions: [makeExt('a', 'disabled')],
|
||||
parameters: [],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('should pass through extension instances', () => {
|
||||
const a = makeExt('a');
|
||||
const b = makeExt('b');
|
||||
expect(
|
||||
mergeExtensionParameters({
|
||||
features: [],
|
||||
builtinExtensions: [a, b],
|
||||
parameters: [],
|
||||
}),
|
||||
).toEqual([
|
||||
{ extension: a, attachTo: { id: 'root', input: 'default' } },
|
||||
{ extension: b, attachTo: { id: 'root', input: 'default' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should override attachment points', () => {
|
||||
const a = makeExt('a');
|
||||
const b = makeExt('b');
|
||||
const pluginA = createPlugin({ id: 'test', extensions: [a] });
|
||||
expect(
|
||||
mergeExtensionParameters({
|
||||
features: [pluginA],
|
||||
builtinExtensions: [b],
|
||||
parameters: [
|
||||
{
|
||||
id: 'b',
|
||||
attachTo: { id: 'derp', input: 'default' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
extension: a,
|
||||
attachTo: { id: 'root', input: 'default' },
|
||||
source: pluginA,
|
||||
},
|
||||
{ extension: b, attachTo: { id: 'derp', input: 'default' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should fully override configuration and duplicate', () => {
|
||||
const a = makeExt('a');
|
||||
const b = makeExt('b');
|
||||
const plugin = createPlugin({ id: 'test', extensions: [a, b] });
|
||||
expect(
|
||||
mergeExtensionParameters({
|
||||
features: [plugin],
|
||||
builtinExtensions: [],
|
||||
parameters: [
|
||||
{
|
||||
id: 'a',
|
||||
config: { foo: { bar: 1 } },
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
config: { foo: { bar: 2 } },
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
config: { foo: { qux: 3 } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
extension: a,
|
||||
attachTo: { id: 'root', input: 'default' },
|
||||
source: plugin,
|
||||
config: { foo: { bar: 1 } },
|
||||
},
|
||||
{
|
||||
extension: b,
|
||||
attachTo: { id: 'root', input: 'default' },
|
||||
source: plugin,
|
||||
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({
|
||||
features: [createPlugin({ id: 'empty', extensions: [] })],
|
||||
builtinExtensions: [a, b],
|
||||
parameters: [
|
||||
{
|
||||
id: 'b',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{ extension: b, attachTo: { id: 'root', input: 'default' } },
|
||||
{ extension: a, attachTo: { id: 'root', input: 'default' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should apply extension overrides', () => {
|
||||
const a = makeExt('a');
|
||||
const b = makeExt('b');
|
||||
const plugin = createPlugin({ id: 'test', extensions: [a, b] });
|
||||
const aOverride = makeExt('a', 'enabled', 'other');
|
||||
const bOverride = makeExt('b', 'disabled', 'other');
|
||||
const cOverride = makeExt('c');
|
||||
|
||||
const result = mergeExtensionParameters({
|
||||
features: [
|
||||
plugin,
|
||||
createExtensionOverrides({
|
||||
extensions: [aOverride, bOverride, cOverride],
|
||||
}),
|
||||
],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
});
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].extension).toBe(aOverride);
|
||||
expect(result[0].attachTo).toEqual({ id: 'other', input: 'default' });
|
||||
expect(result[0].config).toEqual(undefined);
|
||||
expect(result[0].source).toBe(plugin);
|
||||
|
||||
expect(result[1]).toEqual({
|
||||
extension: cOverride,
|
||||
attachTo: { id: 'root', input: 'default' },
|
||||
config: undefined,
|
||||
source: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use order from configuration when rather than overrides', () => {
|
||||
const a = makeExt('a', 'disabled');
|
||||
const b = makeExt('b', 'disabled');
|
||||
const c = makeExt('c', 'disabled');
|
||||
const aOverride = makeExt('c', 'disabled');
|
||||
const bOverride = makeExt('b', 'disabled');
|
||||
const cOverride = makeExt('a', 'disabled');
|
||||
|
||||
const result = mergeExtensionParameters({
|
||||
features: [
|
||||
createPlugin({ id: 'test', extensions: [a, b, c] }),
|
||||
createExtensionOverrides({
|
||||
extensions: [cOverride, bOverride, aOverride],
|
||||
}),
|
||||
],
|
||||
builtinExtensions: [],
|
||||
parameters: ['b', 'c', 'a'].map(id => ({ id, disabled: false })),
|
||||
});
|
||||
|
||||
expect(result.map(r => r.extension.id)).toEqual(['b', 'c', 'a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readAppExtensionParameters', () => {
|
||||
describe('readAppExtensionsConfig', () => {
|
||||
it('should disable extension with shorthand notation', () => {
|
||||
expect(
|
||||
readAppExtensionParameters(
|
||||
readAppExtensionsConfig(
|
||||
new ConfigReader({ app: { extensions: [{ 'core.router': false }] } }),
|
||||
),
|
||||
).toEqual([
|
||||
@@ -222,7 +34,7 @@ describe('readAppExtensionParameters', () => {
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
readAppExtensionParameters(
|
||||
readAppExtensionsConfig(
|
||||
new ConfigReader({
|
||||
app: { extensions: [{ 'core.router': { disabled: true } }] },
|
||||
}),
|
||||
@@ -239,7 +51,7 @@ describe('readAppExtensionParameters', () => {
|
||||
|
||||
it('should enable extension with shorthand notation', () => {
|
||||
expect(
|
||||
readAppExtensionParameters(
|
||||
readAppExtensionsConfig(
|
||||
new ConfigReader({ app: { extensions: ['core.router'] } }),
|
||||
),
|
||||
).toEqual([
|
||||
@@ -249,7 +61,7 @@ describe('readAppExtensionParameters', () => {
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
readAppExtensionParameters(
|
||||
readAppExtensionsConfig(
|
||||
new ConfigReader({ app: { extensions: [{ 'core.router': true }] } }),
|
||||
),
|
||||
).toEqual([
|
||||
@@ -259,7 +71,7 @@ describe('readAppExtensionParameters', () => {
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
readAppExtensionParameters(
|
||||
readAppExtensionsConfig(
|
||||
new ConfigReader({
|
||||
app: { extensions: [{ 'core.router': { disabled: false } }] },
|
||||
}),
|
||||
@@ -274,7 +86,7 @@ describe('readAppExtensionParameters', () => {
|
||||
|
||||
it('should not allow string keys', () => {
|
||||
expect(() =>
|
||||
readAppExtensionParameters(
|
||||
readAppExtensionsConfig(
|
||||
new ConfigReader({
|
||||
app: {
|
||||
extensions: [{ 'core.router': 'some-string' }],
|
||||
@@ -288,7 +100,7 @@ describe('readAppExtensionParameters', () => {
|
||||
|
||||
it('should not allow invalid keys', () => {
|
||||
expect(() =>
|
||||
readAppExtensionParameters(
|
||||
readAppExtensionsConfig(
|
||||
new ConfigReader({
|
||||
app: {
|
||||
extensions: [
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* 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 { JsonValue } from '@backstage/types';
|
||||
|
||||
export interface ExtensionParameters {
|
||||
id: string;
|
||||
attachTo?: { id: string; input: string };
|
||||
disabled?: boolean;
|
||||
config?: unknown;
|
||||
}
|
||||
|
||||
const knownExtensionParameters = ['attachTo', '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
|
||||
// as the Config interface makes it quite hard for us otherwise.
|
||||
/** @internal */
|
||||
export function readAppExtensionsConfig(
|
||||
rootConfig: Config,
|
||||
): ExtensionParameters[] {
|
||||
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((arrayEntry, arrayIndex) =>
|
||||
expandShorthandExtensionParameters(arrayEntry, arrayIndex),
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function expandShorthandExtensionParameters(
|
||||
arrayEntry: JsonValue,
|
||||
arrayIndex: number,
|
||||
): ExtensionParameters {
|
||||
function errorMsg(msg: string, key?: string, prop?: string) {
|
||||
return `Invalid extension configuration at app.extensions[${arrayIndex}]${
|
||||
key ? `[${key}]` : ''
|
||||
}${prop ? `.${prop}` : ''}, ${msg}`;
|
||||
}
|
||||
|
||||
// NOTE(freben): This check is intentionally not complete and doesn't check
|
||||
// whether letters and digits are used, etc. It's not up to the config reading
|
||||
// logic to decide what constitutes a valid extension ID; that should be
|
||||
// decided by the logic that loads and instantiates the extensions. This check
|
||||
// is just here to catch real mistakes or truly conceptually wrong input.
|
||||
function assertValidId(id: string) {
|
||||
if (!id || id !== id.trim()) {
|
||||
throw new Error(
|
||||
errorMsg('extension ID must not be empty or contain whitespace'),
|
||||
);
|
||||
}
|
||||
|
||||
if (id.includes('/')) {
|
||||
let message = `extension ID must not contain slashes; got '${id}'`;
|
||||
const good = id.split('/')[0];
|
||||
if (good) {
|
||||
message += `, did you mean '${good}'?`;
|
||||
}
|
||||
throw new Error(errorMsg(message));
|
||||
}
|
||||
}
|
||||
|
||||
// Example YAML:
|
||||
// - entity.card.about
|
||||
if (typeof arrayEntry === 'string') {
|
||||
assertValidId(arrayEntry);
|
||||
return {
|
||||
id: arrayEntry,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
// All remaining cases are single-key objects
|
||||
if (
|
||||
typeof arrayEntry !== 'object' ||
|
||||
arrayEntry === null ||
|
||||
Array.isArray(arrayEntry)
|
||||
) {
|
||||
throw new Error(errorMsg('must be a string or an object'));
|
||||
}
|
||||
const keys = Object.keys(arrayEntry);
|
||||
if (keys.length !== 1) {
|
||||
const joinedKeys = keys.length ? `'${keys.join("', '")}'` : 'none';
|
||||
throw new Error(errorMsg(`must have exactly one key, got ${joinedKeys}`));
|
||||
}
|
||||
|
||||
const id = String(keys[0]);
|
||||
const value = arrayEntry[id];
|
||||
assertValidId(id);
|
||||
|
||||
// This example covers a potentially common mistake in the syntax
|
||||
// Example YAML:
|
||||
// - entity.card.about:
|
||||
if (value === null) {
|
||||
return {
|
||||
id,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Example YAML:
|
||||
// - catalog.page.cicd: false
|
||||
if (typeof value === 'boolean') {
|
||||
return {
|
||||
id,
|
||||
disabled: !value,
|
||||
};
|
||||
}
|
||||
|
||||
// The remaining case is the generic object. Example YAML:
|
||||
// - tech-radar.page:
|
||||
// at: core.router/routes
|
||||
// disabled: false
|
||||
// config:
|
||||
// path: /tech-radar
|
||||
// width: 1500
|
||||
// height: 800
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
// We don't mention null here - we don't want people to explicitly enter
|
||||
// - entity.card.about: null
|
||||
throw new Error(errorMsg('value must be a boolean or object', id));
|
||||
}
|
||||
|
||||
const attachTo = value.attachTo as { id: string; input: string } | undefined;
|
||||
const disabled = value.disabled;
|
||||
const config = value.config;
|
||||
|
||||
if (attachTo !== undefined) {
|
||||
if (
|
||||
attachTo === null ||
|
||||
typeof attachTo !== 'object' ||
|
||||
Array.isArray(attachTo)
|
||||
) {
|
||||
throw new Error(errorMsg('must be an object', id, 'attachTo'));
|
||||
}
|
||||
if (typeof attachTo.id !== 'string' || attachTo.id === '') {
|
||||
throw new Error(
|
||||
errorMsg('must be a non-empty string', id, 'attachTo.id'),
|
||||
);
|
||||
}
|
||||
if (typeof attachTo.input !== 'string' || attachTo.input === '') {
|
||||
throw new Error(
|
||||
errorMsg('must be a non-empty string', id, 'attachTo.input'),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (disabled !== undefined && typeof disabled !== 'boolean') {
|
||||
throw new Error(errorMsg('must be a boolean', id, 'disabled'));
|
||||
}
|
||||
if (
|
||||
config !== undefined &&
|
||||
(typeof config !== 'object' || config === null || Array.isArray(config))
|
||||
) {
|
||||
throw new Error(errorMsg('must be an object', id, 'config'));
|
||||
}
|
||||
|
||||
const unknownKeys = Object.keys(value).filter(
|
||||
k => !knownExtensionParameters.includes(k),
|
||||
);
|
||||
if (unknownKeys.length > 0) {
|
||||
throw new Error(
|
||||
errorMsg(
|
||||
`unknown parameter; expected one of '${knownExtensionParameters.join(
|
||||
"', '",
|
||||
)}'`,
|
||||
id,
|
||||
unknownKeys.join(', '),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
attachTo,
|
||||
disabled,
|
||||
config,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* 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 { createExtension } from '@backstage/frontend-plugin-api';
|
||||
import { resolveAppGraph } from './resolveAppGraph';
|
||||
|
||||
const extBaseConfig = {
|
||||
id: 'test',
|
||||
attachTo: { id: 'nonexistent', input: 'nonexistent' },
|
||||
output: {},
|
||||
factory() {},
|
||||
};
|
||||
|
||||
const extension = createExtension(extBaseConfig);
|
||||
|
||||
const baseSpec = {
|
||||
extension,
|
||||
attachTo: { id: 'nonexistent', input: 'nonexistent' },
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
describe('buildAppGraph', () => {
|
||||
it('should fail to create an empty graph', () => {
|
||||
expect(() => resolveAppGraph('core', [])).toThrow(
|
||||
"No root node with id 'core' found in app graph",
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a graph with only one node', () => {
|
||||
const graph = resolveAppGraph('core', [{ ...baseSpec, id: 'core' }]);
|
||||
expect(graph.root).toEqual({
|
||||
spec: { ...baseSpec, id: 'core' },
|
||||
edges: { attachments: new Map() },
|
||||
});
|
||||
expect(Array.from(graph.orphans)).toEqual([]);
|
||||
expect(Array.from(graph.nodes.keys())).toEqual(['core']);
|
||||
});
|
||||
|
||||
it('should create a graph', () => {
|
||||
const graph = resolveAppGraph('b', [
|
||||
{ ...baseSpec, id: 'a' },
|
||||
{ ...baseSpec, id: 'b' },
|
||||
{ ...baseSpec, id: 'c' },
|
||||
{ ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx1' },
|
||||
{ ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx2' },
|
||||
{ ...baseSpec, attachTo: { id: 'b', input: 'y' }, id: 'by1' },
|
||||
{ ...baseSpec, attachTo: { id: 'd', input: 'x' }, id: 'dx1' },
|
||||
]);
|
||||
|
||||
expect(Array.from(graph.nodes.keys())).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'bx1',
|
||||
'bx2',
|
||||
'by1',
|
||||
'dx1',
|
||||
]);
|
||||
|
||||
expect(JSON.parse(JSON.stringify(graph.root))).toMatchInlineSnapshot(`
|
||||
{
|
||||
"attachments": {
|
||||
"x": [
|
||||
{
|
||||
"id": "bx1",
|
||||
},
|
||||
{
|
||||
"id": "bx2",
|
||||
},
|
||||
],
|
||||
"y": [
|
||||
{
|
||||
"id": "by1",
|
||||
},
|
||||
],
|
||||
},
|
||||
"id": "b",
|
||||
}
|
||||
`);
|
||||
expect(String(graph.root)).toMatchInlineSnapshot(`
|
||||
"<b>
|
||||
x [
|
||||
<bx1 />
|
||||
<bx2 />
|
||||
]
|
||||
y [
|
||||
<by1 />
|
||||
]
|
||||
</b>"
|
||||
`);
|
||||
|
||||
const orphans = Array.from(graph.orphans).map(String);
|
||||
expect(orphans).toMatchInlineSnapshot(`
|
||||
[
|
||||
"<a />",
|
||||
"<c />",
|
||||
"<dx1 />",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should create a graph out of order', () => {
|
||||
const graph = resolveAppGraph('b', [
|
||||
{ ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx2' },
|
||||
{ ...baseSpec, id: 'a' },
|
||||
{ ...baseSpec, attachTo: { id: 'b', input: 'y' }, id: 'by1' },
|
||||
{ ...baseSpec, id: 'b' },
|
||||
{ ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx1' },
|
||||
{ ...baseSpec, id: 'c' },
|
||||
{ ...baseSpec, attachTo: { id: 'd', input: 'x' }, id: 'dx1' },
|
||||
]);
|
||||
|
||||
expect(Array.from(graph.nodes.keys())).toEqual([
|
||||
'bx2',
|
||||
'a',
|
||||
'by1',
|
||||
'b',
|
||||
'bx1',
|
||||
'c',
|
||||
'dx1',
|
||||
]);
|
||||
|
||||
expect(String(graph.root)).toMatchInlineSnapshot(`
|
||||
"<b>
|
||||
x [
|
||||
<bx2 />
|
||||
<bx1 />
|
||||
]
|
||||
y [
|
||||
<by1 />
|
||||
]
|
||||
</b>"
|
||||
`);
|
||||
|
||||
const orphans = Array.from(graph.orphans).map(String);
|
||||
expect(orphans).toMatchInlineSnapshot(`
|
||||
[
|
||||
"<a />",
|
||||
"<c />",
|
||||
"<dx1 />",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('throws an error when duplicated extensions are detected', () => {
|
||||
expect(() =>
|
||||
resolveAppGraph('core', [
|
||||
{ ...baseSpec, id: 'a' },
|
||||
{ ...baseSpec, id: 'a' },
|
||||
]),
|
||||
).toThrow("Unexpected duplicate extension id 'a'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* 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 { AppGraph, AppNode, AppNodeInstance, AppNodeSpec } from './types';
|
||||
|
||||
function indent(str: string) {
|
||||
return str.replace(/^/gm, ' ');
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
class SerializableAppNode implements AppNode {
|
||||
public readonly spec: AppNodeSpec;
|
||||
public readonly edges = {
|
||||
attachedTo: undefined as { node: AppNode; input: string } | undefined,
|
||||
attachments: new Map<string, SerializableAppNode[]>(),
|
||||
};
|
||||
public readonly instance?: AppNodeInstance;
|
||||
|
||||
constructor(spec: AppNodeSpec) {
|
||||
this.spec = spec;
|
||||
}
|
||||
|
||||
setParent(parent: SerializableAppNode) {
|
||||
const input = this.spec.attachTo.input;
|
||||
|
||||
this.edges.attachedTo = { node: parent, input };
|
||||
|
||||
const parentInputEdges = parent.edges.attachments.get(input);
|
||||
if (parentInputEdges) {
|
||||
parentInputEdges.push(this);
|
||||
} else {
|
||||
parent.edges.attachments.set(input, [this]);
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const dataRefs = this.instance && [...this.instance.getDataRefs()];
|
||||
return {
|
||||
id: this.spec.id,
|
||||
output:
|
||||
dataRefs && dataRefs.length > 0
|
||||
? dataRefs.map(ref => ref.id)
|
||||
: undefined,
|
||||
attachments:
|
||||
this.edges.attachments.size > 0
|
||||
? Object.fromEntries(this.edges.attachments)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
const dataRefs = this.instance && [...this.instance.getDataRefs()];
|
||||
const out =
|
||||
dataRefs && dataRefs.length > 0
|
||||
? ` out=[${[...dataRefs.keys()].join(', ')}]`
|
||||
: '';
|
||||
|
||||
if (this.edges.attachments.size === 0) {
|
||||
return `<${this.spec.id}${out} />`;
|
||||
}
|
||||
|
||||
return [
|
||||
`<${this.spec.id}${out}>`,
|
||||
...[...this.edges.attachments.entries()].map(([k, v]) =>
|
||||
indent([`${k} [`, ...v.map(e => indent(e.toString())), `]`].join('\n')),
|
||||
),
|
||||
`</${this.spec.id}>`,
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the app graph by iterating through all node specs and constructing the app
|
||||
* tree with all attachments in the same order as they appear in the input specs array.
|
||||
* @internal
|
||||
*/
|
||||
export function resolveAppGraph(
|
||||
rootNodeId: string,
|
||||
specs: AppNodeSpec[],
|
||||
): AppGraph {
|
||||
const nodes = new Map<string, SerializableAppNode>();
|
||||
|
||||
// A node with the provided rootNodeId must be found in the graph, and it must not be attached to anything
|
||||
let rootNode: AppNode | undefined = undefined;
|
||||
|
||||
// While iterating through the inputs specs we keep track of all nodes that were created
|
||||
// before their parent, and attach them later when the parent is created.
|
||||
// As we find the parents and attach the children, we remove them from this map. This means
|
||||
// that after iterating through all input specs, this will be a map for each root node.
|
||||
const orphansByParent = new Map<
|
||||
string /* parentId */,
|
||||
SerializableAppNode[]
|
||||
>();
|
||||
|
||||
for (const spec of specs) {
|
||||
// The main check with a more helpful error message happens in resolveAppNodeSpecs
|
||||
if (nodes.has(spec.id)) {
|
||||
throw new Error(`Unexpected duplicate extension id '${spec.id}'`);
|
||||
}
|
||||
|
||||
const node = new SerializableAppNode(spec);
|
||||
nodes.set(spec.id, node);
|
||||
|
||||
// TODO: For now we simply ignore the attachTo spec of the root node, but it'd be cleaner if we could avoid defining it
|
||||
if (spec.id === rootNodeId) {
|
||||
rootNode = node;
|
||||
} else {
|
||||
const parent = nodes.get(spec.attachTo.id);
|
||||
if (parent) {
|
||||
node.setParent(parent);
|
||||
} else {
|
||||
const orphanNodesForParent = orphansByParent.get(spec.attachTo.id);
|
||||
if (orphanNodesForParent) {
|
||||
orphanNodesForParent.push(node);
|
||||
} else {
|
||||
orphansByParent.set(spec.attachTo.id, [node]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const orphanedChildren = orphansByParent.get(spec.id);
|
||||
if (orphanedChildren) {
|
||||
orphansByParent.delete(spec.id);
|
||||
for (const orphan of orphanedChildren) {
|
||||
orphan.setParent(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!rootNode) {
|
||||
throw new Error(`No root node with id '${rootNodeId}' found in app graph`);
|
||||
}
|
||||
|
||||
return {
|
||||
root: rootNode,
|
||||
nodes,
|
||||
orphans: Array.from(orphansByParent.values()).flat(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* 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 {
|
||||
createExtensionOverrides,
|
||||
createPlugin,
|
||||
Extension,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { resolveAppNodeSpecs } from './resolveAppNodeSpecs';
|
||||
|
||||
function makeExt(
|
||||
id: string,
|
||||
status: 'disabled' | 'enabled' = 'enabled',
|
||||
attachId: string = 'root',
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
attachTo: { id: attachId, input: 'default' },
|
||||
disabled: status === 'disabled',
|
||||
} as Extension<unknown>;
|
||||
}
|
||||
|
||||
describe('resolveAppNodeSpecs', () => {
|
||||
it('should filter out disabled extension instances', () => {
|
||||
expect(
|
||||
resolveAppNodeSpecs({
|
||||
features: [],
|
||||
builtinExtensions: [makeExt('a', 'disabled')],
|
||||
parameters: [],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('should pass through extension instances', () => {
|
||||
const a = makeExt('a');
|
||||
const b = makeExt('b');
|
||||
expect(
|
||||
resolveAppNodeSpecs({
|
||||
features: [],
|
||||
builtinExtensions: [a, b],
|
||||
parameters: [],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: 'a',
|
||||
extension: a,
|
||||
attachTo: { id: 'root', input: 'default' },
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
extension: b,
|
||||
attachTo: { id: 'root', input: 'default' },
|
||||
disabled: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should override attachment points', () => {
|
||||
const a = makeExt('a');
|
||||
const b = makeExt('b');
|
||||
const pluginA = createPlugin({ id: 'test', extensions: [a] });
|
||||
expect(
|
||||
resolveAppNodeSpecs({
|
||||
features: [pluginA],
|
||||
builtinExtensions: [b],
|
||||
parameters: [
|
||||
{
|
||||
id: 'b',
|
||||
attachTo: { id: 'derp', input: 'default' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: 'a',
|
||||
extension: a,
|
||||
attachTo: { id: 'root', input: 'default' },
|
||||
source: pluginA,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
extension: b,
|
||||
attachTo: { id: 'derp', input: 'default' },
|
||||
disabled: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should fully override configuration and duplicate', () => {
|
||||
const a = makeExt('a');
|
||||
const b = makeExt('b');
|
||||
const plugin = createPlugin({ id: 'test', extensions: [a, b] });
|
||||
expect(
|
||||
resolveAppNodeSpecs({
|
||||
features: [plugin],
|
||||
builtinExtensions: [],
|
||||
parameters: [
|
||||
{
|
||||
id: 'a',
|
||||
config: { foo: { bar: 1 } },
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
config: { foo: { bar: 2 } },
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
config: { foo: { qux: 3 } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: 'a',
|
||||
extension: a,
|
||||
attachTo: { id: 'root', input: 'default' },
|
||||
source: plugin,
|
||||
config: { foo: { bar: 1 } },
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
extension: b,
|
||||
attachTo: { id: 'root', input: 'default' },
|
||||
source: plugin,
|
||||
config: { foo: { qux: 3 } },
|
||||
disabled: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should place enabled instances in the order that they were enabled', () => {
|
||||
const a = makeExt('a', 'disabled');
|
||||
const b = makeExt('b', 'disabled');
|
||||
expect(
|
||||
resolveAppNodeSpecs({
|
||||
features: [createPlugin({ id: 'empty', extensions: [] })],
|
||||
builtinExtensions: [a, b],
|
||||
parameters: [
|
||||
{
|
||||
id: 'b',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: 'b',
|
||||
extension: b,
|
||||
attachTo: { id: 'root', input: 'default' },
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
extension: a,
|
||||
attachTo: { id: 'root', input: 'default' },
|
||||
disabled: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should apply extension overrides', () => {
|
||||
const a = makeExt('a');
|
||||
const b = makeExt('b');
|
||||
const plugin = createPlugin({ id: 'test', extensions: [a, b] });
|
||||
const aOverride = makeExt('a', 'enabled', 'other');
|
||||
const bOverride = makeExt('b', 'disabled', 'other');
|
||||
const cOverride = makeExt('c');
|
||||
|
||||
const result = resolveAppNodeSpecs({
|
||||
features: [
|
||||
plugin,
|
||||
createExtensionOverrides({
|
||||
extensions: [aOverride, bOverride, cOverride],
|
||||
}),
|
||||
],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
});
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].extension).toBe(aOverride);
|
||||
expect(result[0].attachTo).toEqual({ id: 'other', input: 'default' });
|
||||
expect(result[0].config).toEqual(undefined);
|
||||
expect(result[0].source).toBe(plugin);
|
||||
|
||||
expect(result[1]).toEqual({
|
||||
id: 'c',
|
||||
extension: cOverride,
|
||||
attachTo: { id: 'root', input: 'default' },
|
||||
config: undefined,
|
||||
source: undefined,
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use order from configuration when rather than overrides', () => {
|
||||
const a = makeExt('a', 'disabled');
|
||||
const b = makeExt('b', 'disabled');
|
||||
const c = makeExt('c', 'disabled');
|
||||
const aOverride = makeExt('c', 'disabled');
|
||||
const bOverride = makeExt('b', 'disabled');
|
||||
const cOverride = makeExt('a', 'disabled');
|
||||
|
||||
const result = resolveAppNodeSpecs({
|
||||
features: [
|
||||
createPlugin({ id: 'test', extensions: [a, b, c] }),
|
||||
createExtensionOverrides({
|
||||
extensions: [cOverride, bOverride, aOverride],
|
||||
}),
|
||||
],
|
||||
builtinExtensions: [],
|
||||
parameters: ['b', 'c', 'a'].map(id => ({ id, disabled: false })),
|
||||
});
|
||||
|
||||
expect(result.map(r => r.extension.id)).toEqual(['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
it('throws an error when a forbidden extension is overridden by a plugin', () => {
|
||||
expect(() =>
|
||||
resolveAppNodeSpecs({
|
||||
features: [
|
||||
createPlugin({ id: 'test', extensions: [makeExt('forbidden')] }),
|
||||
],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
forbidden: new Set(['forbidden']),
|
||||
}),
|
||||
).toThrow(
|
||||
"It is forbidden to override the following extension(s): 'forbidden', which is done by the following plugin(s): 'test'",
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when a forbidden extension is overridden by overrides', () => {
|
||||
expect(() =>
|
||||
resolveAppNodeSpecs({
|
||||
features: [
|
||||
createExtensionOverrides({ extensions: [makeExt('forbidden')] }),
|
||||
],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
forbidden: new Set(['forbidden']),
|
||||
}),
|
||||
).toThrow(
|
||||
"It is forbidden to override the following extension(s): 'forbidden', which is done by one or more extension overrides",
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when a forbidden extension is parametrized', () => {
|
||||
expect(() =>
|
||||
resolveAppNodeSpecs({
|
||||
features: [],
|
||||
builtinExtensions: [],
|
||||
parameters: [{ id: 'forbidden', disabled: false }],
|
||||
forbidden: new Set(['forbidden']),
|
||||
}),
|
||||
).toThrow("Configuration of the 'forbidden' extension is forbidden");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* 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 {
|
||||
BackstagePlugin,
|
||||
Extension,
|
||||
ExtensionOverrides,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides';
|
||||
import { ExtensionParameters } from './readAppExtensionsConfig';
|
||||
import { AppNodeSpec } from './types';
|
||||
|
||||
/** @internal */
|
||||
export function resolveAppNodeSpecs(options: {
|
||||
features: (BackstagePlugin | ExtensionOverrides)[];
|
||||
builtinExtensions: Extension<unknown>[];
|
||||
parameters: Array<ExtensionParameters>;
|
||||
forbidden?: Set<string>;
|
||||
}): AppNodeSpec[] {
|
||||
const { builtinExtensions, parameters, forbidden = new Set() } = options;
|
||||
|
||||
const plugins = options.features.filter(
|
||||
(f): f is BackstagePlugin => f.$$type === '@backstage/BackstagePlugin',
|
||||
);
|
||||
const overrides = options.features.filter(
|
||||
(f): f is ExtensionOverrides =>
|
||||
f.$$type === '@backstage/ExtensionOverrides',
|
||||
);
|
||||
|
||||
const pluginExtensions = plugins.flatMap(source => {
|
||||
return source.extensions.map(extension => ({ ...extension, source }));
|
||||
});
|
||||
const overrideExtensions = overrides.flatMap(
|
||||
override => toInternalExtensionOverrides(override).extensions,
|
||||
);
|
||||
|
||||
// Prevent core override
|
||||
if (pluginExtensions.some(({ id }) => forbidden.has(id))) {
|
||||
const pluginsStr = pluginExtensions
|
||||
.filter(({ id }) => forbidden.has(id))
|
||||
.map(({ source }) => `'${source.id}'`)
|
||||
.join(', ');
|
||||
const forbiddenStr = [...forbidden].map(id => `'${id}'`).join(', ');
|
||||
throw new Error(
|
||||
`It is forbidden to override the following extension(s): ${forbiddenStr}, which is done by the following plugin(s): ${pluginsStr}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (overrideExtensions.some(({ id }) => forbidden.has(id))) {
|
||||
const forbiddenStr = [...forbidden].map(id => `'${id}'`).join(', ');
|
||||
throw new Error(
|
||||
`It is forbidden to override the following extension(s): ${forbiddenStr}, which is done by one or more extension overrides`,
|
||||
);
|
||||
}
|
||||
const overrideExtensionIds = overrideExtensions.map(({ id }) => id);
|
||||
if (overrideExtensionIds.length !== new Set(overrideExtensionIds).size) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const id of overrideExtensionIds) {
|
||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
}
|
||||
const duplicated = Array.from(counts.entries())
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([id]) => id);
|
||||
throw new Error(
|
||||
`The following extensions had duplicate overrides: ${duplicated.join(
|
||||
', ',
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const configuredExtensions = [
|
||||
...pluginExtensions.map(({ source, ...extension }) => ({
|
||||
extension,
|
||||
params: {
|
||||
source,
|
||||
attachTo: extension.attachTo,
|
||||
disabled: extension.disabled,
|
||||
config: undefined as unknown,
|
||||
},
|
||||
})),
|
||||
...builtinExtensions.map(extension => ({
|
||||
extension,
|
||||
params: {
|
||||
source: undefined,
|
||||
attachTo: extension.attachTo,
|
||||
disabled: extension.disabled,
|
||||
config: undefined as unknown,
|
||||
},
|
||||
})),
|
||||
];
|
||||
|
||||
// Install all extension overrides
|
||||
for (const extension of overrideExtensions) {
|
||||
// Check if our override is overriding an extension that already exists
|
||||
const index = configuredExtensions.findIndex(
|
||||
e => e.extension.id === extension.id,
|
||||
);
|
||||
if (index !== -1) {
|
||||
// Only implementation, attachment point and default disabled status are overridden, the source is kept
|
||||
configuredExtensions[index].extension = extension;
|
||||
configuredExtensions[index].params.attachTo = extension.attachTo;
|
||||
configuredExtensions[index].params.disabled = extension.disabled;
|
||||
} else {
|
||||
// Add the extension as a new one when not overriding an existing one
|
||||
configuredExtensions.push({
|
||||
extension,
|
||||
params: {
|
||||
source: undefined,
|
||||
attachTo: extension.attachTo,
|
||||
disabled: extension.disabled,
|
||||
config: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const duplicatedExtensionIds = new Set<string>();
|
||||
const duplicatedExtensionData = configuredExtensions.reduce<
|
||||
Record<string, Record<string, number>>
|
||||
>((data, { extension, params }) => {
|
||||
const extensionId = extension.id;
|
||||
const extensionData = data?.[extensionId];
|
||||
if (extensionData) duplicatedExtensionIds.add(extensionId);
|
||||
const pluginId = params.source?.id ?? 'internal';
|
||||
const pluginCount = extensionData?.[pluginId] ?? 0;
|
||||
return {
|
||||
...data,
|
||||
[extensionId]: { ...extensionData, [pluginId]: pluginCount + 1 },
|
||||
};
|
||||
}, {});
|
||||
|
||||
if (duplicatedExtensionIds.size > 0) {
|
||||
throw new Error(
|
||||
`The following extensions are duplicated: ${Array.from(
|
||||
duplicatedExtensionIds,
|
||||
)
|
||||
.map(
|
||||
extensionId =>
|
||||
`The extension '${extensionId}' was provided ${Object.keys(
|
||||
duplicatedExtensionData[extensionId],
|
||||
)
|
||||
.map(
|
||||
pluginId =>
|
||||
`${duplicatedExtensionData[extensionId][pluginId]} time(s) by the plugin '${pluginId}'`,
|
||||
)
|
||||
.join(' and ')}`,
|
||||
)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const overrideParam of parameters) {
|
||||
const extensionId = overrideParam.id;
|
||||
|
||||
if (forbidden.has(extensionId)) {
|
||||
throw new Error(
|
||||
`Configuration of the '${extensionId}' extension is forbidden`,
|
||||
);
|
||||
}
|
||||
|
||||
const existingIndex = configuredExtensions.findIndex(
|
||||
e => e.extension.id === extensionId,
|
||||
);
|
||||
if (existingIndex !== -1) {
|
||||
const existing = configuredExtensions[existingIndex];
|
||||
if (overrideParam.attachTo) {
|
||||
existing.params.attachTo = overrideParam.attachTo;
|
||||
}
|
||||
if (overrideParam.config) {
|
||||
// TODO: merge config?
|
||||
existing.params.config = overrideParam.config;
|
||||
}
|
||||
if (
|
||||
Boolean(existing.params.disabled) !== Boolean(overrideParam.disabled)
|
||||
) {
|
||||
existing.params.disabled = Boolean(overrideParam.disabled);
|
||||
if (!existing.params.disabled) {
|
||||
// bump
|
||||
configuredExtensions.splice(existingIndex, 1);
|
||||
configuredExtensions.push(existing);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Extension ${extensionId} does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
return configuredExtensions
|
||||
.filter(override => !override.params.disabled)
|
||||
.map(param => ({
|
||||
id: param.extension.id,
|
||||
attachTo: param.params.attachTo,
|
||||
extension: param.extension,
|
||||
disabled: param.params.disabled,
|
||||
source: param.params.source,
|
||||
config: param.params.config,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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 {
|
||||
BackstagePlugin,
|
||||
Extension,
|
||||
ExtensionDataRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
/*
|
||||
NOTE: These types are marked as @internal for now, but the intention is for this to be a public API in the future.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The specification for this node in the app graph.
|
||||
*
|
||||
* @internal
|
||||
* @remarks
|
||||
*
|
||||
* The specifications for a collection of app nodes is all the information needed
|
||||
* to build the graph and instantiate the nodes.
|
||||
*/
|
||||
export interface AppNodeSpec {
|
||||
readonly id: string;
|
||||
readonly attachTo: { id: string; input: string };
|
||||
readonly extension: Extension<unknown>;
|
||||
readonly disabled: boolean;
|
||||
readonly config?: unknown;
|
||||
readonly source?: BackstagePlugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* The connections from this node to other nodes.
|
||||
*
|
||||
* @internal
|
||||
* @remarks
|
||||
*
|
||||
* The app node edges are resolved based on the app node specs, regardless of whether
|
||||
* adjacent nodes are disabled or not. If no parent attachment is present or
|
||||
*/
|
||||
export interface AppNodeEdges {
|
||||
readonly attachedTo?: { node: AppNode; input: string };
|
||||
readonly attachments: ReadonlyMap<string, AppNode[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The instance of this node in the app graph.
|
||||
*
|
||||
* @internal
|
||||
* @remarks
|
||||
*
|
||||
* The app node instance is created when the `factory` function of an extension is called.
|
||||
* Instances will only be present for nodes in the app that are connected to the root
|
||||
* node and not disabled
|
||||
*/
|
||||
export interface AppNodeInstance {
|
||||
/** Returns a sequence of all extension data refs that were output by this instance */
|
||||
getDataRefs(): Iterable<ExtensionDataRef<unknown>>;
|
||||
/** Get the output data for a single extension data ref */
|
||||
getData<T>(ref: ExtensionDataRef<T>): T | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface AppNode {
|
||||
/** The specification for how this node should be instantiated */
|
||||
readonly spec: AppNodeSpec;
|
||||
/** The edges from this node to other nodes in the app graph */
|
||||
readonly edges: AppNodeEdges;
|
||||
/** The instance of this node, if it was instantiated */
|
||||
readonly instance?: AppNodeInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* The app graph containing all nodes of the app.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface AppGraph {
|
||||
/** The root node of the app */
|
||||
root: AppNode;
|
||||
/** A map of all nodes in the app by ID, including orphaned or disabled nodes */
|
||||
nodes: ReadonlyMap<string /* id */, AppNode>;
|
||||
/** A sequence of all nodes with a parent that is not reachable from the app root node */
|
||||
orphans: Iterable<AppNode>;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { RouteResolver } from './RouteResolver';
|
||||
import { MATCH_ALL_ROUTE } from './extractRouteInfoFromInstanceTree';
|
||||
import { MATCH_ALL_ROUTE } from './extractRouteInfoFromAppNode';
|
||||
|
||||
const rest = {
|
||||
element: null,
|
||||
|
||||
+9
-4
@@ -16,7 +16,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { extractRouteInfoFromInstanceTree } from './extractRouteInfoFromInstanceTree';
|
||||
import { extractRouteInfoFromAppNode } from './extractRouteInfoFromAppNode';
|
||||
import {
|
||||
AnyRouteRefParams,
|
||||
Extension,
|
||||
@@ -27,8 +27,12 @@ import {
|
||||
createPlugin,
|
||||
createRouteRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { createInstances } from '../wiring/createApp';
|
||||
import { MockConfigApi } from '@backstage/test-utils';
|
||||
import { createAppGraph } from '../graph';
|
||||
import { Core } from '../extensions/Core';
|
||||
import { CoreRoutes } from '../extensions/CoreRoutes';
|
||||
import { CoreNav } from '../extensions/CoreNav';
|
||||
import { CoreLayout } from '../extensions/CoreLayout';
|
||||
|
||||
const ref1 = createRouteRef();
|
||||
const ref2 = createRouteRef();
|
||||
@@ -73,12 +77,13 @@ function routeInfoFromExtensions(extensions: Extension<unknown>[]) {
|
||||
id: 'test',
|
||||
extensions,
|
||||
});
|
||||
const { coreInstance } = createInstances({
|
||||
const graph = createAppGraph({
|
||||
config: new MockConfigApi({}),
|
||||
builtinExtensions: [Core, CoreRoutes, CoreNav, CoreLayout],
|
||||
features: [plugin],
|
||||
});
|
||||
|
||||
return extractRouteInfoFromInstanceTree(coreInstance);
|
||||
return extractRouteInfoFromAppNode(graph.root);
|
||||
}
|
||||
|
||||
function sortedEntries<T>(map: Map<RouteRef, T>): [RouteRef, T][] {
|
||||
+10
-10
@@ -15,10 +15,10 @@
|
||||
*/
|
||||
|
||||
import { RouteRef, coreExtensionData } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionInstance } from '../wiring/createExtensionInstance';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toLegacyPlugin } from '../wiring/createApp';
|
||||
import { BackstageRouteObject } from './types';
|
||||
import { AppNode } from '../graph';
|
||||
|
||||
// We always add a child that matches all subroutes but without any route refs. This makes
|
||||
// sure that we're always able to match each route no matter how deep the navigation goes.
|
||||
@@ -41,7 +41,7 @@ export function joinPaths(...paths: string[]): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function extractRouteInfoFromInstanceTree(core: ExtensionInstance): {
|
||||
export function extractRouteInfoFromAppNode(node: AppNode): {
|
||||
routePaths: Map<RouteRef, string>;
|
||||
routeParents: Map<RouteRef, RouteRef | undefined>;
|
||||
routeObjects: BackstageRouteObject[];
|
||||
@@ -56,17 +56,17 @@ export function extractRouteInfoFromInstanceTree(core: ExtensionInstance): {
|
||||
const routeObjects = new Array<BackstageRouteObject>();
|
||||
|
||||
function visit(
|
||||
current: ExtensionInstance,
|
||||
current: AppNode,
|
||||
collectedPath?: string,
|
||||
foundRefForCollectedPath: boolean = false,
|
||||
parentRef?: RouteRef,
|
||||
candidateParentRef?: RouteRef,
|
||||
parentObj?: BackstageRouteObject,
|
||||
) {
|
||||
const routePath = current
|
||||
.getData(coreExtensionData.routePath)
|
||||
const routePath = current.instance
|
||||
?.getData(coreExtensionData.routePath)
|
||||
?.replace(/^\//, '');
|
||||
const routeRef = current.getData(coreExtensionData.routeRef);
|
||||
const routeRef = current.instance?.getData(coreExtensionData.routeRef);
|
||||
const parentChildren = parentObj?.children ?? routeObjects;
|
||||
let currentObj = parentObj;
|
||||
|
||||
@@ -124,12 +124,12 @@ export function extractRouteInfoFromInstanceTree(core: ExtensionInstance): {
|
||||
|
||||
routeParents.set(routeRef, newParentRef);
|
||||
currentObj?.routeRefs.add(routeRef);
|
||||
if (current.source) {
|
||||
currentObj?.plugins.add(toLegacyPlugin(current.source));
|
||||
if (current.spec.source) {
|
||||
currentObj?.plugins.add(toLegacyPlugin(current.spec.source));
|
||||
}
|
||||
}
|
||||
|
||||
for (const children of current.attachments.values()) {
|
||||
for (const children of current.edges.attachments.values()) {
|
||||
for (const child of children) {
|
||||
visit(
|
||||
child,
|
||||
@@ -143,7 +143,7 @@ export function extractRouteInfoFromInstanceTree(core: ExtensionInstance): {
|
||||
}
|
||||
}
|
||||
|
||||
visit(core);
|
||||
visit(node);
|
||||
|
||||
return { routePaths, routeParents, routeObjects };
|
||||
}
|
||||
@@ -15,123 +15,15 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
createExtension,
|
||||
createExtensionOverrides,
|
||||
createPageExtension,
|
||||
createPlugin,
|
||||
createRouteRef,
|
||||
createThemeExtension,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { createApp, createInstances } from './createApp';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { createApp } from './createApp';
|
||||
import { MockConfigApi, renderWithEffects } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
|
||||
const extBaseConfig = {
|
||||
id: 'test',
|
||||
attachTo: { id: 'root', input: 'default' },
|
||||
output: {},
|
||||
factory() {},
|
||||
};
|
||||
|
||||
describe('createInstances', () => {
|
||||
it('throws an error when a core extension is parametrized', () => {
|
||||
const config = new MockConfigApi({
|
||||
app: {
|
||||
extensions: [
|
||||
{
|
||||
core: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const features = [
|
||||
createPlugin({
|
||||
id: 'plugin',
|
||||
extensions: [],
|
||||
}),
|
||||
];
|
||||
expect(() => createInstances({ config, features })).toThrow(
|
||||
"A 'core' extension configuration was detected, but the core extension is not configurable",
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when a core extension is overridden', () => {
|
||||
const config = new MockConfigApi({});
|
||||
const features = [
|
||||
createPlugin({
|
||||
id: 'plugin',
|
||||
extensions: [
|
||||
createExtension({
|
||||
id: 'core',
|
||||
attachTo: { id: 'core.routes', input: 'route' },
|
||||
inputs: {},
|
||||
output: {},
|
||||
factory() {},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
expect(() => createInstances({ config, features })).toThrow(
|
||||
"The following plugin(s) are overriding the 'core' extension which is forbidden: plugin",
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when duplicated extensions are detected', () => {
|
||||
const config = new MockConfigApi({});
|
||||
|
||||
const ExtensionA = createPageExtension({
|
||||
id: 'A',
|
||||
defaultPath: '/',
|
||||
routeRef: createRouteRef(),
|
||||
loader: async () => <div>Extension A</div>,
|
||||
});
|
||||
|
||||
const ExtensionB = createPageExtension({
|
||||
id: 'B',
|
||||
defaultPath: '/',
|
||||
routeRef: createRouteRef(),
|
||||
loader: async () => <div>Extension B</div>,
|
||||
});
|
||||
|
||||
const PluginA = createPlugin({
|
||||
id: 'A',
|
||||
extensions: [ExtensionA, ExtensionA],
|
||||
});
|
||||
|
||||
const PluginB = createPlugin({
|
||||
id: 'B',
|
||||
extensions: [ExtensionA, ExtensionB, ExtensionB],
|
||||
});
|
||||
|
||||
const features = [PluginA, PluginB];
|
||||
|
||||
expect(() => createInstances({ config, features })).toThrow(
|
||||
"The following extensions are duplicated: The extension 'A' was provided 2 time(s) by the plugin 'A' and 1 time(s) by the plugin 'B', The extension 'B' was provided 2 time(s) by the plugin 'B'",
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when duplicated extension overrides are detected', () => {
|
||||
expect(() =>
|
||||
createInstances({
|
||||
config: new MockConfigApi({}),
|
||||
features: [
|
||||
createExtensionOverrides({
|
||||
extensions: [
|
||||
createExtension({ ...extBaseConfig, id: 'a' }),
|
||||
createExtension({ ...extBaseConfig, id: 'a' }),
|
||||
createExtension({ ...extBaseConfig, id: 'b' }),
|
||||
],
|
||||
}),
|
||||
createExtensionOverrides({
|
||||
extensions: [createExtension({ ...extBaseConfig, id: 'b' })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).toThrow('The following extensions had duplicate overrides: a, b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createApp', () => {
|
||||
it('should allow themes to be installed', async () => {
|
||||
const app = createApp({
|
||||
@@ -161,90 +53,6 @@ describe('createApp', () => {
|
||||
await expect(screen.findByText('Derp')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should log an app', () => {
|
||||
const { coreInstance } = createInstances({
|
||||
config: new MockConfigApi({}),
|
||||
features: [],
|
||||
});
|
||||
|
||||
expect(String(coreInstance)).toMatchInlineSnapshot(`
|
||||
"<core out=[core.reactElement]>
|
||||
root [
|
||||
<core.layout out=[core.reactElement]>
|
||||
content [
|
||||
<core.routes out=[core.reactElement] />
|
||||
]
|
||||
nav [
|
||||
<core.nav out=[core.reactElement] />
|
||||
]
|
||||
</core.layout>
|
||||
]
|
||||
themes [
|
||||
<themes.light out=[core.theme] />
|
||||
<themes.dark out=[core.theme] />
|
||||
]
|
||||
</core>"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should serialize an app as JSON', () => {
|
||||
const { coreInstance } = createInstances({
|
||||
config: new MockConfigApi({}),
|
||||
features: [],
|
||||
});
|
||||
|
||||
expect(JSON.parse(JSON.stringify(coreInstance))).toMatchInlineSnapshot(`
|
||||
{
|
||||
"attachments": {
|
||||
"root": [
|
||||
{
|
||||
"attachments": {
|
||||
"content": [
|
||||
{
|
||||
"id": "core.routes",
|
||||
"output": [
|
||||
"core.reactElement",
|
||||
],
|
||||
},
|
||||
],
|
||||
"nav": [
|
||||
{
|
||||
"id": "core.nav",
|
||||
"output": [
|
||||
"core.reactElement",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"id": "core.layout",
|
||||
"output": [
|
||||
"core.reactElement",
|
||||
],
|
||||
},
|
||||
],
|
||||
"themes": [
|
||||
{
|
||||
"id": "themes.light",
|
||||
"output": [
|
||||
"core.theme",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "themes.dark",
|
||||
"output": [
|
||||
"core.theme",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"id": "core",
|
||||
"output": [
|
||||
"core.reactElement",
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should deduplicate features keeping the last received one', async () => {
|
||||
const duplicatedFeatureId = 'test';
|
||||
const app = createApp({
|
||||
|
||||
@@ -28,15 +28,6 @@ import { Core } from '../extensions/Core';
|
||||
import { CoreRoutes } from '../extensions/CoreRoutes';
|
||||
import { CoreLayout } from '../extensions/CoreLayout';
|
||||
import { CoreNav } from '../extensions/CoreNav';
|
||||
import {
|
||||
createExtensionInstance,
|
||||
ExtensionInstance,
|
||||
} from './createExtensionInstance';
|
||||
import {
|
||||
ExtensionInstanceParameters,
|
||||
mergeExtensionParameters,
|
||||
readAppExtensionParameters,
|
||||
} from './parameters';
|
||||
import {
|
||||
AnyApiFactory,
|
||||
ApiHolder,
|
||||
@@ -86,7 +77,7 @@ import {
|
||||
import { BrowserRouter, Route } from 'react-router-dom';
|
||||
import { SidebarItem } from '@backstage/core-components';
|
||||
import { DarkTheme, LightTheme } from '../extensions/themes';
|
||||
import { extractRouteInfoFromInstanceTree } from '../routing/extractRouteInfoFromInstanceTree';
|
||||
import { extractRouteInfoFromAppNode } from '../routing/extractRouteInfoFromAppNode';
|
||||
import { getOrCreateGlobalSingleton } from '@backstage/version-bridge';
|
||||
import {
|
||||
appLanguageApiRef,
|
||||
@@ -96,6 +87,16 @@ import { AppRouteBinder } from '../routing';
|
||||
import { RoutingProvider } from '../routing/RoutingProvider';
|
||||
import { resolveRouteBindings } from '../routing/resolveRouteBindings';
|
||||
import { collectRouteIds } from '../routing/collectRouteIds';
|
||||
import { AppNode, createAppGraph } from '../graph';
|
||||
|
||||
const builtinExtensions = [
|
||||
Core,
|
||||
CoreRoutes,
|
||||
CoreNav,
|
||||
CoreLayout,
|
||||
LightTheme,
|
||||
DarkTheme,
|
||||
];
|
||||
|
||||
/** @public */
|
||||
export interface ExtensionTreeNode {
|
||||
@@ -116,20 +117,38 @@ export function createExtensionTree(options: {
|
||||
config: Config;
|
||||
}): ExtensionTree {
|
||||
const features = getAvailableFeatures(options.config);
|
||||
const { instances } = createInstances({
|
||||
const graph = createAppGraph({
|
||||
features,
|
||||
builtinExtensions,
|
||||
config: options.config,
|
||||
});
|
||||
|
||||
function convertNode(node?: AppNode): ExtensionTreeNode | undefined {
|
||||
return (
|
||||
node && {
|
||||
id: node.spec.id,
|
||||
getData<T>(ref: ExtensionDataRef<T>): T | undefined {
|
||||
return node.instance?.getData(ref);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
getExtension(id: string): ExtensionTreeNode | undefined {
|
||||
return instances.get(id);
|
||||
return convertNode(graph.nodes.get(id));
|
||||
},
|
||||
getExtensionAttachments(
|
||||
id: string,
|
||||
inputName: string,
|
||||
): ExtensionTreeNode[] {
|
||||
return instances.get(id)?.attachments.get(inputName) ?? [];
|
||||
return (
|
||||
graph.nodes
|
||||
.get(id)
|
||||
?.edges.attachments.get(inputName)
|
||||
?.map(convertNode)
|
||||
.filter((node): node is ExtensionTreeNode => Boolean(node)) ?? []
|
||||
);
|
||||
},
|
||||
getRootRoutes(): JSX.Element[] {
|
||||
return this.getExtensionAttachments('core.routes', 'routes').map(node => {
|
||||
@@ -179,94 +198,6 @@ export function createExtensionTree(options: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function createInstances(options: {
|
||||
features: (BackstagePlugin | ExtensionOverrides)[];
|
||||
config: Config;
|
||||
}) {
|
||||
const builtinExtensions = [
|
||||
Core,
|
||||
CoreRoutes,
|
||||
CoreNav,
|
||||
CoreLayout,
|
||||
LightTheme,
|
||||
DarkTheme,
|
||||
];
|
||||
|
||||
// pull in default extension instance from discovered packages
|
||||
// apply config to adjust default extension instances and add more
|
||||
const extensionParams = mergeExtensionParameters({
|
||||
features: options.features,
|
||||
builtinExtensions,
|
||||
parameters: readAppExtensionParameters(options.config),
|
||||
});
|
||||
|
||||
// TODO: validate the config of all extension instances
|
||||
// We do it at this point to ensure that merging (if any) of config has already happened
|
||||
|
||||
// Create attachment map so that we can look attachments up during instance creation
|
||||
const attachmentMap = new Map<
|
||||
string,
|
||||
Map<string, ExtensionInstanceParameters[]>
|
||||
>();
|
||||
for (const instanceParams of extensionParams) {
|
||||
const extensionId = instanceParams.attachTo.id;
|
||||
const pointId = instanceParams.attachTo.input;
|
||||
let pointMap = attachmentMap.get(extensionId);
|
||||
if (!pointMap) {
|
||||
pointMap = new Map();
|
||||
attachmentMap.set(extensionId, pointMap);
|
||||
}
|
||||
|
||||
let instances = pointMap.get(pointId);
|
||||
if (!instances) {
|
||||
instances = [];
|
||||
pointMap.set(pointId, instances);
|
||||
}
|
||||
|
||||
instances.push(instanceParams);
|
||||
}
|
||||
|
||||
const instances = new Map<string, ExtensionInstance>();
|
||||
|
||||
function createInstance(
|
||||
instanceParams: ExtensionInstanceParameters,
|
||||
): ExtensionInstance {
|
||||
const extensionId = instanceParams.extension.id;
|
||||
const existingInstance = instances.get(extensionId);
|
||||
if (existingInstance) {
|
||||
return existingInstance;
|
||||
}
|
||||
|
||||
const attachments = new Map(
|
||||
Array.from(attachmentMap.get(extensionId)?.entries() ?? []).map(
|
||||
([inputName, attachmentConfigs]) => {
|
||||
return [inputName, attachmentConfigs.map(createInstance)];
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const newInstance = createExtensionInstance({
|
||||
extension: instanceParams.extension,
|
||||
source: instanceParams.source,
|
||||
config: instanceParams.config,
|
||||
attachments,
|
||||
});
|
||||
|
||||
instances.set(extensionId, newInstance);
|
||||
|
||||
return newInstance;
|
||||
}
|
||||
|
||||
const coreInstance = createInstance(
|
||||
extensionParams.find(p => p.extension.id === 'core')!,
|
||||
);
|
||||
|
||||
return { coreInstance, instances };
|
||||
}
|
||||
|
||||
function deduplicateFeatures(
|
||||
allFeatures: (BackstagePlugin | ExtensionOverrides)[],
|
||||
): (BackstagePlugin | ExtensionOverrides)[] {
|
||||
@@ -316,8 +247,9 @@ export function createApp(options: {
|
||||
...(options.features ?? []),
|
||||
]);
|
||||
|
||||
const { coreInstance } = createInstances({
|
||||
const appGraph = createAppGraph({
|
||||
features: allFeatures,
|
||||
builtinExtensions,
|
||||
config,
|
||||
});
|
||||
|
||||
@@ -330,11 +262,11 @@ export function createApp(options: {
|
||||
const routeIds = collectRouteIds(allFeatures);
|
||||
|
||||
const App = () => (
|
||||
<ApiProvider apis={createApiHolder(coreInstance, config)}>
|
||||
<ApiProvider apis={createApiHolder(appGraph.root, config)}>
|
||||
<AppContextProvider appContext={appContext}>
|
||||
<AppThemeProvider>
|
||||
<RoutingProvider
|
||||
{...extractRouteInfoFromInstanceTree(coreInstance)}
|
||||
{...extractRouteInfoFromAppNode(appGraph.root)}
|
||||
routeBindings={resolveRouteBindings(
|
||||
options.bindRoutes,
|
||||
config,
|
||||
@@ -343,7 +275,9 @@ export function createApp(options: {
|
||||
>
|
||||
{/* TODO: set base path using the logic from AppRouter */}
|
||||
<BrowserRouter>
|
||||
{coreInstance.getData(coreExtensionData.reactElement)}
|
||||
{appGraph.root.instance!.getData(
|
||||
coreExtensionData.reactElement,
|
||||
)}
|
||||
</BrowserRouter>
|
||||
</RoutingProvider>
|
||||
</AppThemeProvider>
|
||||
@@ -424,22 +358,19 @@ function createLegacyAppContext(plugins: BackstagePlugin[]): AppContext {
|
||||
};
|
||||
}
|
||||
|
||||
function createApiHolder(
|
||||
coreExtension: ExtensionInstance,
|
||||
configApi: ConfigApi,
|
||||
): ApiHolder {
|
||||
function createApiHolder(core: AppNode, configApi: ConfigApi): ApiHolder {
|
||||
const factoryRegistry = new ApiFactoryRegistry();
|
||||
|
||||
const pluginApis =
|
||||
coreExtension.attachments
|
||||
core.edges.attachments
|
||||
.get('apis')
|
||||
?.map(e => e.getData(coreExtensionData.apiFactory))
|
||||
?.map(e => e.instance?.getData(coreExtensionData.apiFactory))
|
||||
.filter((x): x is AnyApiFactory => !!x) ?? [];
|
||||
|
||||
const themeExtensions =
|
||||
coreExtension.attachments
|
||||
core.edges.attachments
|
||||
.get('themes')
|
||||
?.map(e => e.getData(coreExtensionData.theme))
|
||||
?.map(e => e.instance?.getData(coreExtensionData.theme))
|
||||
.filter((x): x is AppTheme => !!x) ?? [];
|
||||
|
||||
for (const factory of [...defaultApis, ...pluginApis]) {
|
||||
|
||||
@@ -1,456 +0,0 @@
|
||||
/*
|
||||
* 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 {
|
||||
createExtension,
|
||||
createExtensionDataRef,
|
||||
createExtensionInput,
|
||||
createSchemaFromZod,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { createExtensionInstance } from './createExtensionInstance';
|
||||
|
||||
const testDataRef = createExtensionDataRef<string>('test');
|
||||
const otherDataRef = createExtensionDataRef<number>('other');
|
||||
const inputMirrorDataRef = createExtensionDataRef<unknown>('mirror');
|
||||
|
||||
const simpleExtension = createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
output: {
|
||||
test: testDataRef,
|
||||
other: otherDataRef.optional(),
|
||||
},
|
||||
configSchema: createSchemaFromZod(z =>
|
||||
z.object({
|
||||
output: z.string().default('test'),
|
||||
other: z.number().optional(),
|
||||
}),
|
||||
),
|
||||
factory({ bind, config }) {
|
||||
bind({ test: config.output, other: config.other });
|
||||
},
|
||||
});
|
||||
|
||||
describe('createExtensionInstance', () => {
|
||||
it('should create a simple extension instance', () => {
|
||||
const attachments = new Map();
|
||||
const instance = createExtensionInstance({
|
||||
attachments,
|
||||
config: undefined,
|
||||
extension: simpleExtension,
|
||||
});
|
||||
|
||||
expect(instance.id).toBe('core.test');
|
||||
expect(instance.attachments).toBe(attachments);
|
||||
expect(instance.getData(testDataRef)).toEqual('test');
|
||||
});
|
||||
|
||||
it('should create an extension with different kind of inputs', () => {
|
||||
const attachments = new Map([
|
||||
[
|
||||
'optionalSingletonPresent',
|
||||
[
|
||||
createExtensionInstance({
|
||||
attachments: new Map(),
|
||||
config: { output: 'optionalSingletonPresent' },
|
||||
extension: simpleExtension,
|
||||
}),
|
||||
],
|
||||
],
|
||||
[
|
||||
'singleton',
|
||||
[
|
||||
createExtensionInstance({
|
||||
attachments: new Map(),
|
||||
config: { output: 'singleton', other: 2 },
|
||||
extension: simpleExtension,
|
||||
}),
|
||||
],
|
||||
],
|
||||
[
|
||||
'many',
|
||||
[
|
||||
createExtensionInstance({
|
||||
attachments: new Map(),
|
||||
config: { output: 'many1' },
|
||||
extension: simpleExtension,
|
||||
}),
|
||||
createExtensionInstance({
|
||||
attachments: new Map(),
|
||||
config: { output: 'many2', other: 3 },
|
||||
extension: simpleExtension,
|
||||
}),
|
||||
],
|
||||
],
|
||||
]);
|
||||
const instance = createExtensionInstance({
|
||||
attachments,
|
||||
config: undefined,
|
||||
extension: createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
inputs: {
|
||||
optionalSingletonPresent: createExtensionInput(
|
||||
{
|
||||
test: testDataRef,
|
||||
other: otherDataRef.optional(),
|
||||
},
|
||||
{ singleton: true, optional: true },
|
||||
),
|
||||
optionalSingletonMissing: createExtensionInput(
|
||||
{
|
||||
test: testDataRef,
|
||||
other: otherDataRef.optional(),
|
||||
},
|
||||
{ singleton: true, optional: true },
|
||||
),
|
||||
singleton: createExtensionInput(
|
||||
{
|
||||
test: testDataRef,
|
||||
other: otherDataRef.optional(),
|
||||
},
|
||||
{ singleton: true },
|
||||
),
|
||||
many: createExtensionInput({
|
||||
test: testDataRef,
|
||||
other: otherDataRef.optional(),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
inputMirror: inputMirrorDataRef,
|
||||
},
|
||||
factory({ bind, inputs }) {
|
||||
bind({ inputMirror: inputs });
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(instance.id).toBe('core.test');
|
||||
expect(instance.attachments).toBe(attachments);
|
||||
expect(instance.getData(inputMirrorDataRef)).toEqual({
|
||||
optionalSingletonPresent: { test: 'optionalSingletonPresent' },
|
||||
singleton: { test: 'singleton', other: 2 },
|
||||
many: [{ test: 'many1' }, { test: 'many2', other: 3 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should refuse to create an extension with invalid config', () => {
|
||||
expect(() =>
|
||||
createExtensionInstance({
|
||||
attachments: new Map(),
|
||||
config: { other: 'not-a-number' },
|
||||
extension: simpleExtension,
|
||||
}),
|
||||
).toThrow(
|
||||
"Invalid configuration for extension 'core.test'; caused by Error: Expected number, received string at 'other'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should forward extension factory errors', () => {
|
||||
expect(() =>
|
||||
createExtensionInstance({
|
||||
attachments: new Map(),
|
||||
config: { other: 'not-a-number' },
|
||||
extension: createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
output: {},
|
||||
factory() {
|
||||
const error = new Error('NOPE');
|
||||
error.name = 'NopeError';
|
||||
throw error;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).toThrow(
|
||||
"Failed to instantiate extension 'core.test'; caused by NopeError: NOPE",
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse to create an instance with duplicate output', () => {
|
||||
const attachments = new Map();
|
||||
expect(() =>
|
||||
createExtensionInstance({
|
||||
attachments,
|
||||
config: undefined,
|
||||
extension: createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
output: {
|
||||
test1: testDataRef,
|
||||
test2: testDataRef,
|
||||
},
|
||||
factory({ bind }) {
|
||||
bind({ test1: 'test', test2: 'test2' });
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).toThrow(
|
||||
"Failed to instantiate extension 'core.test', duplicate extension data 'test' received via output 'test2'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse to create an instance with disconnected output data', () => {
|
||||
const attachments = new Map();
|
||||
expect(() =>
|
||||
createExtensionInstance({
|
||||
attachments,
|
||||
config: undefined,
|
||||
extension: createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
output: {
|
||||
test: testDataRef,
|
||||
},
|
||||
factory({ bind }) {
|
||||
bind({ nonexistent: 'test' } as any);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).toThrow(
|
||||
"Failed to instantiate extension 'core.test', unknown output provided via 'nonexistent'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse to create an instance with missing required input', () => {
|
||||
expect(() =>
|
||||
createExtensionInstance({
|
||||
attachments: new Map(),
|
||||
config: undefined,
|
||||
extension: createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
inputs: {
|
||||
singleton: createExtensionInput(
|
||||
{
|
||||
test: testDataRef,
|
||||
},
|
||||
{ singleton: true },
|
||||
),
|
||||
},
|
||||
output: {},
|
||||
factory() {},
|
||||
}),
|
||||
}),
|
||||
).toThrow(
|
||||
"Failed to instantiate extension 'core.test', input 'singleton' is required but was not received",
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse to create an instance with undeclared inputs', () => {
|
||||
expect(() =>
|
||||
createExtensionInstance({
|
||||
attachments: new Map([
|
||||
[
|
||||
'declared',
|
||||
[
|
||||
createExtensionInstance({
|
||||
attachments: new Map(),
|
||||
config: { output: 'many1' },
|
||||
extension: simpleExtension,
|
||||
}),
|
||||
],
|
||||
],
|
||||
[
|
||||
'undeclared',
|
||||
[
|
||||
createExtensionInstance({
|
||||
attachments: new Map(),
|
||||
config: { output: 'many1' },
|
||||
extension: simpleExtension,
|
||||
}),
|
||||
],
|
||||
],
|
||||
]),
|
||||
config: undefined,
|
||||
extension: createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
inputs: {
|
||||
declared: createExtensionInput({
|
||||
test: testDataRef,
|
||||
}),
|
||||
},
|
||||
output: {},
|
||||
factory() {},
|
||||
}),
|
||||
}),
|
||||
).toThrow(
|
||||
"Failed to instantiate extension 'core.test', received undeclared input 'undeclared' from extension 'core.test'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse to create an instance with multiple undeclared inputs', () => {
|
||||
expect(() =>
|
||||
createExtensionInstance({
|
||||
attachments: new Map([
|
||||
[
|
||||
'undeclared1',
|
||||
[
|
||||
createExtensionInstance({
|
||||
attachments: new Map(),
|
||||
config: { output: 'many1' },
|
||||
extension: simpleExtension,
|
||||
}),
|
||||
],
|
||||
],
|
||||
[
|
||||
'undeclared2',
|
||||
[
|
||||
createExtensionInstance({
|
||||
attachments: new Map(),
|
||||
config: { output: 'many1' },
|
||||
extension: simpleExtension,
|
||||
}),
|
||||
createExtensionInstance({
|
||||
attachments: new Map(),
|
||||
config: { output: 'many1' },
|
||||
extension: simpleExtension,
|
||||
}),
|
||||
],
|
||||
],
|
||||
]),
|
||||
config: undefined,
|
||||
extension: createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
output: {},
|
||||
factory() {},
|
||||
}),
|
||||
}),
|
||||
).toThrow(
|
||||
"Failed to instantiate extension 'core.test', received undeclared inputs 'undeclared1' from extension 'core.test' and 'undeclared2' from extensions 'core.test', 'core.test'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse to create an instance with multiple inputs for required singleton', () => {
|
||||
expect(() =>
|
||||
createExtensionInstance({
|
||||
attachments: new Map([
|
||||
[
|
||||
'singleton',
|
||||
[
|
||||
createExtensionInstance({
|
||||
attachments: new Map(),
|
||||
config: { output: 'many1' },
|
||||
extension: simpleExtension,
|
||||
}),
|
||||
createExtensionInstance({
|
||||
attachments: new Map(),
|
||||
config: { output: 'many2' },
|
||||
extension: simpleExtension,
|
||||
}),
|
||||
],
|
||||
],
|
||||
]),
|
||||
config: undefined,
|
||||
extension: createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
inputs: {
|
||||
singleton: createExtensionInput(
|
||||
{
|
||||
test: testDataRef,
|
||||
},
|
||||
{ singleton: true },
|
||||
),
|
||||
},
|
||||
output: {},
|
||||
factory() {},
|
||||
}),
|
||||
}),
|
||||
).toThrow(
|
||||
"Failed to instantiate extension 'core.test', expected exactly one 'singleton' input but received multiple: 'core.test', 'core.test'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse to create an instance with multiple inputs for optional singleton', () => {
|
||||
expect(() =>
|
||||
createExtensionInstance({
|
||||
attachments: new Map([
|
||||
[
|
||||
'singleton',
|
||||
[
|
||||
createExtensionInstance({
|
||||
attachments: new Map(),
|
||||
config: { output: 'many1' },
|
||||
extension: simpleExtension,
|
||||
}),
|
||||
createExtensionInstance({
|
||||
attachments: new Map(),
|
||||
config: { output: 'many2' },
|
||||
extension: simpleExtension,
|
||||
}),
|
||||
],
|
||||
],
|
||||
]),
|
||||
config: undefined,
|
||||
extension: createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
inputs: {
|
||||
singleton: createExtensionInput(
|
||||
{
|
||||
test: testDataRef,
|
||||
},
|
||||
{ singleton: true, optional: true },
|
||||
),
|
||||
},
|
||||
output: {},
|
||||
factory() {},
|
||||
}),
|
||||
}),
|
||||
).toThrow(
|
||||
"Failed to instantiate extension 'core.test', expected at most one 'singleton' input but received multiple: 'core.test', 'core.test'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse to create an instance with multiple inputs that did not provide required data', () => {
|
||||
expect(() =>
|
||||
createExtensionInstance({
|
||||
attachments: new Map([
|
||||
[
|
||||
'singleton',
|
||||
[
|
||||
createExtensionInstance({
|
||||
attachments: new Map(),
|
||||
config: undefined,
|
||||
extension: simpleExtension,
|
||||
}),
|
||||
],
|
||||
],
|
||||
]),
|
||||
config: undefined,
|
||||
extension: createExtension({
|
||||
id: 'core.test',
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
inputs: {
|
||||
singleton: createExtensionInput(
|
||||
{
|
||||
other: otherDataRef,
|
||||
},
|
||||
{ singleton: true },
|
||||
),
|
||||
},
|
||||
output: {},
|
||||
factory() {},
|
||||
}),
|
||||
}),
|
||||
).toThrow(
|
||||
"Failed to instantiate extension 'core.test', input 'singleton' did not receive required extension data 'other' from extension 'core.test'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,397 +0,0 @@
|
||||
/*
|
||||
* 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 {
|
||||
BackstagePlugin,
|
||||
Extension,
|
||||
ExtensionOverrides,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
|
||||
export interface ExtensionParameters {
|
||||
id: string;
|
||||
attachTo?: { id: string; input: string };
|
||||
disabled?: boolean;
|
||||
config?: unknown;
|
||||
}
|
||||
|
||||
const knownExtensionParameters = ['attachTo', '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
|
||||
// as the Config interface makes it quite hard for us otherwise.
|
||||
/** @internal */
|
||||
export function readAppExtensionParameters(
|
||||
rootConfig: Config,
|
||||
): ExtensionParameters[] {
|
||||
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((arrayEntry, arrayIndex) =>
|
||||
expandShorthandExtensionParameters(arrayEntry, arrayIndex),
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function expandShorthandExtensionParameters(
|
||||
arrayEntry: JsonValue,
|
||||
arrayIndex: number,
|
||||
): ExtensionParameters {
|
||||
function errorMsg(msg: string, key?: string, prop?: string) {
|
||||
return `Invalid extension configuration at app.extensions[${arrayIndex}]${
|
||||
key ? `[${key}]` : ''
|
||||
}${prop ? `.${prop}` : ''}, ${msg}`;
|
||||
}
|
||||
|
||||
// NOTE(freben): This check is intentionally not complete and doesn't check
|
||||
// whether letters and digits are used, etc. It's not up to the config reading
|
||||
// logic to decide what constitutes a valid extension ID; that should be
|
||||
// decided by the logic that loads and instantiates the extensions. This check
|
||||
// is just here to catch real mistakes or truly conceptually wrong input.
|
||||
function assertValidId(id: string) {
|
||||
if (!id || id !== id.trim()) {
|
||||
throw new Error(
|
||||
errorMsg('extension ID must not be empty or contain whitespace'),
|
||||
);
|
||||
}
|
||||
|
||||
if (id.includes('/')) {
|
||||
let message = `extension ID must not contain slashes; got '${id}'`;
|
||||
const good = id.split('/')[0];
|
||||
if (good) {
|
||||
message += `, did you mean '${good}'?`;
|
||||
}
|
||||
throw new Error(errorMsg(message));
|
||||
}
|
||||
}
|
||||
|
||||
// Example YAML:
|
||||
// - entity.card.about
|
||||
if (typeof arrayEntry === 'string') {
|
||||
assertValidId(arrayEntry);
|
||||
return {
|
||||
id: arrayEntry,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
// All remaining cases are single-key objects
|
||||
if (
|
||||
typeof arrayEntry !== 'object' ||
|
||||
arrayEntry === null ||
|
||||
Array.isArray(arrayEntry)
|
||||
) {
|
||||
throw new Error(errorMsg('must be a string or an object'));
|
||||
}
|
||||
const keys = Object.keys(arrayEntry);
|
||||
if (keys.length !== 1) {
|
||||
const joinedKeys = keys.length ? `'${keys.join("', '")}'` : 'none';
|
||||
throw new Error(errorMsg(`must have exactly one key, got ${joinedKeys}`));
|
||||
}
|
||||
|
||||
const id = String(keys[0]);
|
||||
const value = arrayEntry[id];
|
||||
assertValidId(id);
|
||||
|
||||
// This example covers a potentially common mistake in the syntax
|
||||
// Example YAML:
|
||||
// - entity.card.about:
|
||||
if (value === null) {
|
||||
return {
|
||||
id,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Example YAML:
|
||||
// - catalog.page.cicd: false
|
||||
if (typeof value === 'boolean') {
|
||||
return {
|
||||
id,
|
||||
disabled: !value,
|
||||
};
|
||||
}
|
||||
|
||||
// The remaining case is the generic object. Example YAML:
|
||||
// - tech-radar.page:
|
||||
// at: core.router/routes
|
||||
// disabled: false
|
||||
// config:
|
||||
// path: /tech-radar
|
||||
// width: 1500
|
||||
// height: 800
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
// We don't mention null here - we don't want people to explicitly enter
|
||||
// - entity.card.about: null
|
||||
throw new Error(errorMsg('value must be a boolean or object', id));
|
||||
}
|
||||
|
||||
const attachTo = value.attachTo as { id: string; input: string } | undefined;
|
||||
const disabled = value.disabled;
|
||||
const config = value.config;
|
||||
|
||||
if (attachTo !== undefined) {
|
||||
if (
|
||||
attachTo === null ||
|
||||
typeof attachTo !== 'object' ||
|
||||
Array.isArray(attachTo)
|
||||
) {
|
||||
throw new Error(errorMsg('must be an object', id, 'attachTo'));
|
||||
}
|
||||
if (typeof attachTo.id !== 'string' || attachTo.id === '') {
|
||||
throw new Error(
|
||||
errorMsg('must be a non-empty string', id, 'attachTo.id'),
|
||||
);
|
||||
}
|
||||
if (typeof attachTo.input !== 'string' || attachTo.input === '') {
|
||||
throw new Error(
|
||||
errorMsg('must be a non-empty string', id, 'attachTo.input'),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (disabled !== undefined && typeof disabled !== 'boolean') {
|
||||
throw new Error(errorMsg('must be a boolean', id, 'disabled'));
|
||||
}
|
||||
if (
|
||||
config !== undefined &&
|
||||
(typeof config !== 'object' || config === null || Array.isArray(config))
|
||||
) {
|
||||
throw new Error(errorMsg('must be an object', id, 'config'));
|
||||
}
|
||||
|
||||
const unknownKeys = Object.keys(value).filter(
|
||||
k => !knownExtensionParameters.includes(k),
|
||||
);
|
||||
if (unknownKeys.length > 0) {
|
||||
throw new Error(
|
||||
errorMsg(
|
||||
`unknown parameter; expected one of '${knownExtensionParameters.join(
|
||||
"', '",
|
||||
)}'`,
|
||||
id,
|
||||
unknownKeys.join(', '),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
attachTo,
|
||||
disabled,
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExtensionInstanceParameters {
|
||||
extension: Extension<unknown>;
|
||||
source?: BackstagePlugin;
|
||||
attachTo: { id: string; input: string };
|
||||
config?: unknown;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function mergeExtensionParameters(options: {
|
||||
features: (BackstagePlugin | ExtensionOverrides)[];
|
||||
builtinExtensions: Extension<unknown>[];
|
||||
parameters: Array<ExtensionParameters>;
|
||||
}): ExtensionInstanceParameters[] {
|
||||
const { builtinExtensions, parameters } = options;
|
||||
|
||||
const plugins = options.features.filter(
|
||||
(f): f is BackstagePlugin => f.$$type === '@backstage/BackstagePlugin',
|
||||
);
|
||||
const overrides = options.features.filter(
|
||||
(f): f is ExtensionOverrides =>
|
||||
f.$$type === '@backstage/ExtensionOverrides',
|
||||
);
|
||||
|
||||
const pluginExtensions = plugins.flatMap(source => {
|
||||
return source.extensions.map(extension => ({ ...extension, source }));
|
||||
});
|
||||
const overrideExtensions = overrides.flatMap(
|
||||
override => toInternalExtensionOverrides(override).extensions,
|
||||
);
|
||||
|
||||
// Prevent core override
|
||||
if (pluginExtensions.some(({ id }) => id === 'core')) {
|
||||
const pluginIds = pluginExtensions
|
||||
.filter(({ id }) => id === 'core')
|
||||
.map(({ source }) => source.id);
|
||||
throw new Error(
|
||||
`The following plugin(s) are overriding the 'core' extension which is forbidden: ${pluginIds.join(
|
||||
',',
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (overrideExtensions.some(({ id }) => id === 'root')) {
|
||||
throw new Error(
|
||||
`An extension override is overriding the 'root' extension which is forbidden`,
|
||||
);
|
||||
}
|
||||
const overrideExtensionIds = overrideExtensions.map(({ id }) => id);
|
||||
if (overrideExtensionIds.length !== new Set(overrideExtensionIds).size) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const id of overrideExtensionIds) {
|
||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
}
|
||||
const duplicated = Array.from(counts.entries())
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([id]) => id);
|
||||
throw new Error(
|
||||
`The following extensions had duplicate overrides: ${duplicated.join(
|
||||
', ',
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const configuredExtensions = [
|
||||
...pluginExtensions.map(({ source, ...extension }) => ({
|
||||
extension,
|
||||
params: {
|
||||
source,
|
||||
attachTo: extension.attachTo,
|
||||
disabled: extension.disabled,
|
||||
config: undefined as unknown,
|
||||
},
|
||||
})),
|
||||
...builtinExtensions.map(extension => ({
|
||||
extension,
|
||||
params: {
|
||||
source: undefined,
|
||||
attachTo: extension.attachTo,
|
||||
disabled: extension.disabled,
|
||||
config: undefined as unknown,
|
||||
},
|
||||
})),
|
||||
];
|
||||
|
||||
// Install all extension overrides
|
||||
for (const extension of overrideExtensions) {
|
||||
// Check if our override is overriding an extension that already exists
|
||||
const index = configuredExtensions.findIndex(
|
||||
e => e.extension.id === extension.id,
|
||||
);
|
||||
if (index !== -1) {
|
||||
// Only implementation, attachment point and default disabled status are overridden, the source is kept
|
||||
configuredExtensions[index].extension = extension;
|
||||
configuredExtensions[index].params.attachTo = extension.attachTo;
|
||||
configuredExtensions[index].params.disabled = extension.disabled;
|
||||
} else {
|
||||
// Add the extension as a new one when not overriding an existing one
|
||||
configuredExtensions.push({
|
||||
extension,
|
||||
params: {
|
||||
source: undefined,
|
||||
attachTo: extension.attachTo,
|
||||
disabled: extension.disabled,
|
||||
config: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const duplicatedExtensionIds = new Set<string>();
|
||||
const duplicatedExtensionData = configuredExtensions.reduce<
|
||||
Record<string, Record<string, number>>
|
||||
>((data, { extension, params }) => {
|
||||
const extensionId = extension.id;
|
||||
const extensionData = data?.[extensionId];
|
||||
if (extensionData) duplicatedExtensionIds.add(extensionId);
|
||||
const pluginId = params.source?.id ?? 'internal';
|
||||
const pluginCount = extensionData?.[pluginId] ?? 0;
|
||||
return {
|
||||
...data,
|
||||
[extensionId]: { ...extensionData, [pluginId]: pluginCount + 1 },
|
||||
};
|
||||
}, {});
|
||||
|
||||
if (duplicatedExtensionIds.size > 0) {
|
||||
throw new Error(
|
||||
`The following extensions are duplicated: ${Array.from(
|
||||
duplicatedExtensionIds,
|
||||
)
|
||||
.map(
|
||||
extensionId =>
|
||||
`The extension '${extensionId}' was provided ${Object.keys(
|
||||
duplicatedExtensionData[extensionId],
|
||||
)
|
||||
.map(
|
||||
pluginId =>
|
||||
`${duplicatedExtensionData[extensionId][pluginId]} time(s) by the plugin '${pluginId}'`,
|
||||
)
|
||||
.join(' and ')}`,
|
||||
)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const overrideParam of parameters) {
|
||||
const extensionId = overrideParam.id;
|
||||
|
||||
// Prevent core parametrization
|
||||
if (extensionId === 'core') {
|
||||
throw new Error(
|
||||
"A 'core' extension configuration was detected, but the core extension is not configurable",
|
||||
);
|
||||
}
|
||||
|
||||
const existingIndex = configuredExtensions.findIndex(
|
||||
e => e.extension.id === extensionId,
|
||||
);
|
||||
if (existingIndex !== -1) {
|
||||
const existing = configuredExtensions[existingIndex];
|
||||
if (overrideParam.attachTo) {
|
||||
existing.params.attachTo = overrideParam.attachTo;
|
||||
}
|
||||
if (overrideParam.config) {
|
||||
// TODO: merge config?
|
||||
existing.params.config = overrideParam.config;
|
||||
}
|
||||
if (
|
||||
Boolean(existing.params.disabled) !== Boolean(overrideParam.disabled)
|
||||
) {
|
||||
existing.params.disabled = Boolean(overrideParam.disabled);
|
||||
if (!existing.params.disabled) {
|
||||
// bump
|
||||
configuredExtensions.splice(existingIndex, 1);
|
||||
configuredExtensions.push(existing);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Extension ${extensionId} does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
return configuredExtensions
|
||||
.filter(override => !override.params.disabled)
|
||||
.map(param => ({
|
||||
extension: param.extension,
|
||||
attachTo: param.params.attachTo,
|
||||
source: param.params.source,
|
||||
config: param.params.config,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user