frontend-app-api: initial error collection implementation
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -4,16 +4,33 @@
|
||||
|
||||
```ts
|
||||
import { ApiHolder } from '@backstage/core-plugin-api';
|
||||
import { AppNode } from '@backstage/frontend-plugin-api';
|
||||
import { AppNodeSpec } from '@backstage/frontend-plugin-api';
|
||||
import { AppTree } from '@backstage/frontend-plugin-api';
|
||||
import { ConfigApi } from '@backstage/core-plugin-api';
|
||||
import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api';
|
||||
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendFeature } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendPluginInfo } from '@backstage/frontend-plugin-api';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { RouteRef } from '@backstage/frontend-plugin-api';
|
||||
import { SubRouteRef } from '@backstage/frontend-plugin-api';
|
||||
|
||||
// @public (undocumented)
|
||||
export type AppError = {
|
||||
code: string;
|
||||
message: string;
|
||||
context?: {
|
||||
node?: AppNode;
|
||||
spec?: AppNodeSpec;
|
||||
plugin?: FrontendPlugin;
|
||||
extensionId?: string;
|
||||
inputName?: string;
|
||||
dataRefId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
export type CreateAppRouteBinder = <
|
||||
TExternalRoutes extends {
|
||||
@@ -31,6 +48,7 @@ export type CreateAppRouteBinder = <
|
||||
export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
|
||||
apis: ApiHolder;
|
||||
tree: AppTree;
|
||||
errors?: AppError[];
|
||||
};
|
||||
|
||||
// @public
|
||||
|
||||
@@ -38,6 +38,18 @@ import { Root } from '../extensions/Root';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
|
||||
import { createRouteAliasResolver } from './RouteAliasResolver';
|
||||
import { createErrorCollector } from '../wiring/createErrorCollector';
|
||||
|
||||
const collector = createErrorCollector();
|
||||
|
||||
afterEach(() => {
|
||||
const errors = collector.collectErrors();
|
||||
if (errors) {
|
||||
throw new Error(
|
||||
`Unexpected errors: ${errors.map(e => e.message).join(', ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const ref1 = createRouteRef();
|
||||
const ref2 = createRouteRef();
|
||||
@@ -102,10 +114,12 @@ function routeInfoFromExtensions(
|
||||
],
|
||||
parameters: readAppExtensionsConfig(mockApis.config()),
|
||||
forbidden: new Set(['root']),
|
||||
collector,
|
||||
}),
|
||||
collector,
|
||||
);
|
||||
|
||||
instantiateAppNodeTree(tree.root, TestApiRegistry.from());
|
||||
instantiateAppNodeTree(tree.root, TestApiRegistry.from(), collector);
|
||||
|
||||
return extractRouteInfoFromAppNode(
|
||||
tree.root,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
|
||||
import { createExtensionDataContainer } from '@internal/frontend';
|
||||
import { ErrorCollector } from '../wiring/createErrorCollector';
|
||||
|
||||
type Mutable<T> = {
|
||||
-readonly [P in keyof T]: T[P];
|
||||
@@ -63,12 +64,18 @@ function resolveInputDataContainer(
|
||||
extensionData: Array<ExtensionDataRef>,
|
||||
attachment: AppNode,
|
||||
inputName: string,
|
||||
): { node: AppNode } & ExtensionDataContainer<ExtensionDataRef> {
|
||||
collector: ErrorCollector,
|
||||
): ({ node: AppNode } & ExtensionDataContainer<ExtensionDataRef>) | undefined {
|
||||
const dataMap = new Map<string, unknown>();
|
||||
|
||||
let failed = false;
|
||||
for (const ref of extensionData) {
|
||||
if (dataMap.has(ref.id)) {
|
||||
throw new Error(`Unexpected duplicate input data '${ref.id}'`);
|
||||
collector.report({
|
||||
code: 'EXTENSION_DUPLICATE_INPUT',
|
||||
message: `Unexpected duplicate input data '${ref.id}'`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const value = attachment.instance?.getData(ref);
|
||||
if (value === undefined && !ref.config.optional) {
|
||||
@@ -81,14 +88,20 @@ function resolveInputDataContainer(
|
||||
.map(r => `'${r.id}'`)
|
||||
.join(', ');
|
||||
|
||||
throw new Error(
|
||||
`extension '${attachment.spec.id}' could not be attached because its output data (${provided}) does not match what the input '${inputName}' requires (${expected})`,
|
||||
);
|
||||
collector.report({
|
||||
code: 'EXTENSION_MISSING_INPUT_DATA',
|
||||
message: `extension '${attachment.spec.id}' could not be attached because its output data (${provided}) does not match what the input '${inputName}' requires (${expected})`,
|
||||
});
|
||||
failed = true;
|
||||
}
|
||||
|
||||
dataMap.set(ref.id, value);
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
node: attachment,
|
||||
get(ref) {
|
||||
@@ -198,42 +211,79 @@ function resolveV2Inputs(
|
||||
>;
|
||||
},
|
||||
attachments: ReadonlyMap<string, AppNode[]>,
|
||||
): ResolvedExtensionInputs<{
|
||||
[inputName in string]: ExtensionInput<
|
||||
ExtensionDataRef,
|
||||
{ optional: boolean; singleton: boolean }
|
||||
>;
|
||||
}> {
|
||||
return mapValues(inputMap, (input, inputName) => {
|
||||
collector: ErrorCollector,
|
||||
):
|
||||
| undefined
|
||||
| ResolvedExtensionInputs<{
|
||||
[inputName in string]: ExtensionInput<
|
||||
ExtensionDataRef,
|
||||
{ optional: boolean; singleton: boolean }
|
||||
>;
|
||||
}> {
|
||||
let failed = false;
|
||||
const resolvedInputs = mapValues(inputMap, (input, inputName) => {
|
||||
const attachedNodes = attachments.get(inputName) ?? [];
|
||||
|
||||
if (input.config.singleton) {
|
||||
if (attachedNodes.length > 1) {
|
||||
const attachedNodeIds = attachedNodes.map(e => e.spec.id);
|
||||
throw Error(
|
||||
`expected ${
|
||||
const attachedNodeIds = attachedNodes.map(e => e.spec.id).join("', '");
|
||||
collector.report({
|
||||
code: 'EXTENSION_TOO_MANY_ATTACHMENTS',
|
||||
message: `expected ${
|
||||
input.config.optional ? 'at most' : 'exactly'
|
||||
} one '${inputName}' input but received multiple: '${attachedNodeIds.join(
|
||||
"', '",
|
||||
)}'`,
|
||||
);
|
||||
} one '${inputName}' input but received multiple: '${attachedNodeIds}'`,
|
||||
context: { inputName },
|
||||
});
|
||||
failed = true;
|
||||
return undefined;
|
||||
} else if (attachedNodes.length === 0) {
|
||||
if (input.config.optional) {
|
||||
return undefined;
|
||||
if (!input.config.optional) {
|
||||
collector.report({
|
||||
code: 'EXTENSION_MISSING_REQUIRED_INPUT',
|
||||
message: `input '${inputName}' is required but was not received`,
|
||||
context: { inputName },
|
||||
});
|
||||
failed = true;
|
||||
}
|
||||
throw Error(`input '${inputName}' is required but was not received`);
|
||||
return undefined;
|
||||
}
|
||||
return resolveInputDataContainer(
|
||||
const data = resolveInputDataContainer(
|
||||
input.extensionData,
|
||||
attachedNodes[0],
|
||||
inputName,
|
||||
collector,
|
||||
);
|
||||
if (data === undefined) {
|
||||
failed = true;
|
||||
return undefined;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
return attachedNodes.map(attachment =>
|
||||
resolveInputDataContainer(input.extensionData, attachment, inputName),
|
||||
);
|
||||
}) as ResolvedExtensionInputs<{
|
||||
if (failed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return attachedNodes.map(attachment => {
|
||||
const data = resolveInputDataContainer(
|
||||
input.extensionData,
|
||||
attachment,
|
||||
inputName,
|
||||
collector,
|
||||
);
|
||||
if (data === undefined) {
|
||||
failed = true;
|
||||
return undefined;
|
||||
}
|
||||
return data;
|
||||
});
|
||||
});
|
||||
|
||||
if (failed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return resolvedInputs as ResolvedExtensionInputs<{
|
||||
[inputName in string]: ExtensionInput<
|
||||
ExtensionDataRef,
|
||||
{ optional: boolean; singleton: boolean }
|
||||
@@ -247,8 +297,10 @@ export function createAppNodeInstance(options: {
|
||||
node: AppNode;
|
||||
apis: ApiHolder;
|
||||
attachments: ReadonlyMap<string, AppNode[]>;
|
||||
}): AppNodeInstance {
|
||||
collector: ErrorCollector;
|
||||
}): AppNodeInstance | undefined {
|
||||
const { node, apis, attachments } = options;
|
||||
const collector = options.collector.child({ node });
|
||||
const { id, extension, config } = node.spec;
|
||||
const extensionData = new Map<string, unknown>();
|
||||
const extensionDataRefs = new Set<ExtensionDataRef<unknown>>();
|
||||
@@ -259,9 +311,11 @@ export function createAppNodeInstance(options: {
|
||||
[x: string]: any;
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Invalid configuration for extension '${id}'; caused by ${e}`,
|
||||
);
|
||||
collector.report({
|
||||
code: 'INVALID_CONFIGURATION',
|
||||
message: `Invalid configuration for extension '${id}'; caused by ${e}`,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -293,11 +347,19 @@ export function createAppNodeInstance(options: {
|
||||
extensionDataRefs.add(ref);
|
||||
}
|
||||
} else if (internalExtension.version === 'v2') {
|
||||
const inputs = resolveV2Inputs(
|
||||
internalExtension.inputs,
|
||||
attachments,
|
||||
collector,
|
||||
);
|
||||
if (inputs === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const context = {
|
||||
node,
|
||||
apis,
|
||||
config: parsedConfig,
|
||||
inputs: resolveV2Inputs(internalExtension.inputs, attachments),
|
||||
inputs,
|
||||
};
|
||||
const outputDataValues = options.extensionFactoryMiddleware
|
||||
? createExtensionDataContainer(
|
||||
@@ -320,15 +382,32 @@ export function createAppNodeInstance(options: {
|
||||
typeof outputDataValues !== 'object' ||
|
||||
!outputDataValues?.[Symbol.iterator]
|
||||
) {
|
||||
throw new Error('extension factory did not provide an iterable object');
|
||||
collector.report({
|
||||
code: 'EXTENSION_FACTORY_INVALID_OUTPUT',
|
||||
message: 'extension factory did not provide an iterable object',
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let failed = false;
|
||||
|
||||
const outputDataMap = new Map<string, unknown>();
|
||||
for (const value of outputDataValues) {
|
||||
if (outputDataMap.has(value.id)) {
|
||||
throw new Error(`duplicate extension data output '${value.id}'`);
|
||||
collector.report({
|
||||
code: 'EXTENSION_FACTORY_DUPLICATE_OUTPUT',
|
||||
message: `extension factory output duplicate data '${value.id}'`,
|
||||
context: {
|
||||
dataRefId: value.id,
|
||||
},
|
||||
});
|
||||
failed = true;
|
||||
} else {
|
||||
outputDataMap.set(value.id, value.value);
|
||||
}
|
||||
outputDataMap.set(value.id, value.value);
|
||||
}
|
||||
if (failed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const ref of internalExtension.output) {
|
||||
@@ -336,34 +415,53 @@ export function createAppNodeInstance(options: {
|
||||
outputDataMap.delete(ref.id);
|
||||
if (value === undefined) {
|
||||
if (!ref.config.optional) {
|
||||
throw new Error(
|
||||
`missing required extension data output '${ref.id}'`,
|
||||
);
|
||||
collector.report({
|
||||
code: 'EXTENSION_FACTORY_MISSING_REQUIRED_OUTPUT',
|
||||
message: `missing required extension data output '${ref.id}'`,
|
||||
context: {
|
||||
dataRefId: ref.id,
|
||||
},
|
||||
});
|
||||
failed = true;
|
||||
}
|
||||
} else {
|
||||
extensionData.set(ref.id, value);
|
||||
extensionDataRefs.add(ref);
|
||||
}
|
||||
}
|
||||
if (failed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (outputDataMap.size > 0) {
|
||||
throw new Error(
|
||||
`unexpected output '${Array.from(outputDataMap.keys()).join(
|
||||
"', '",
|
||||
)}'`,
|
||||
);
|
||||
for (const dataRefId of outputDataMap.keys()) {
|
||||
// TODO: Make this a warning
|
||||
collector.report({
|
||||
code: 'EXTENSION_FACTORY_UNEXPECTED_OUTPUT',
|
||||
message: `unexpected output '${dataRefId}'`,
|
||||
context: {
|
||||
dataRefId: dataRefId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`unexpected extension version '${(internalExtension as any).version}'`,
|
||||
);
|
||||
collector.report({
|
||||
code: 'UNEXPECTED_EXTENSION_VERSION',
|
||||
message: `unexpected extension version '${
|
||||
(internalExtension as any).version
|
||||
}'`,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to instantiate extension '${id}'${
|
||||
e.name === 'Error' ? `, ${e.message}` : `; caused by ${e.stack}`
|
||||
collector.report({
|
||||
code: 'FAILED_TO_INSTANTIATE_EXTENSION',
|
||||
message: `Failed to instantiate extension '${id}'${
|
||||
e.name === 'Error' ? `, ${e.message}` : `; caused by ${e}`
|
||||
}`,
|
||||
);
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -383,8 +481,9 @@ export function createAppNodeInstance(options: {
|
||||
export function instantiateAppNodeTree(
|
||||
rootNode: AppNode,
|
||||
apis: ApiHolder,
|
||||
errors: ErrorCollector,
|
||||
extensionFactoryMiddleware?: ExtensionFactoryMiddleware,
|
||||
): void {
|
||||
): boolean {
|
||||
function createInstance(node: AppNode): AppNodeInstance | undefined {
|
||||
if (node.instance) {
|
||||
return node.instance;
|
||||
@@ -413,10 +512,11 @@ export function instantiateAppNodeTree(
|
||||
node,
|
||||
apis,
|
||||
attachments: instantiatedAttachments,
|
||||
collector: errors,
|
||||
});
|
||||
|
||||
return node.instance;
|
||||
}
|
||||
|
||||
createInstance(rootNode);
|
||||
return createInstance(rootNode) !== undefined;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,18 @@ import {
|
||||
ExtensionDefinition,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { resolveAppNodeSpecs } from './resolveAppNodeSpecs';
|
||||
import { createErrorCollector } from '../wiring/createErrorCollector';
|
||||
|
||||
const collector = createErrorCollector();
|
||||
|
||||
afterEach(() => {
|
||||
const errors = collector.collectErrors();
|
||||
if (errors) {
|
||||
throw new Error(
|
||||
`Unexpected errors: ${errors.map(e => e.message).join(', ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function makeExt(
|
||||
id: string,
|
||||
@@ -61,6 +73,7 @@ describe('resolveAppNodeSpecs', () => {
|
||||
features: [],
|
||||
builtinExtensions: [a],
|
||||
parameters: [],
|
||||
collector,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
@@ -83,6 +96,7 @@ describe('resolveAppNodeSpecs', () => {
|
||||
features: [],
|
||||
builtinExtensions: [a, b],
|
||||
parameters: [],
|
||||
collector,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
@@ -122,6 +136,7 @@ describe('resolveAppNodeSpecs', () => {
|
||||
attachTo: { id: 'derp', input: 'default' },
|
||||
},
|
||||
],
|
||||
collector,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
@@ -169,6 +184,7 @@ describe('resolveAppNodeSpecs', () => {
|
||||
config: { foo: { qux: 3 } },
|
||||
},
|
||||
],
|
||||
collector,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
@@ -209,6 +225,7 @@ describe('resolveAppNodeSpecs', () => {
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
collector,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
@@ -249,6 +266,7 @@ describe('resolveAppNodeSpecs', () => {
|
||||
{ id: 'd', disabled: false },
|
||||
{ id: 'c', disabled: false },
|
||||
],
|
||||
collector,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
@@ -341,6 +359,7 @@ describe('resolveAppNodeSpecs', () => {
|
||||
],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
collector,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
@@ -395,6 +414,7 @@ describe('resolveAppNodeSpecs', () => {
|
||||
id,
|
||||
disabled: false,
|
||||
})),
|
||||
collector,
|
||||
});
|
||||
|
||||
expect(result.map(r => r.extension.id)).toEqual([
|
||||
@@ -405,53 +425,83 @@ describe('resolveAppNodeSpecs', () => {
|
||||
});
|
||||
|
||||
it('throws an error when a forbidden extension is overridden by a plugin', () => {
|
||||
expect(() =>
|
||||
resolveAppNodeSpecs({
|
||||
features: [
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
extensions: [makeExtDef('forbidden')],
|
||||
}),
|
||||
],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
forbidden: new Set(['test/forbidden']),
|
||||
}),
|
||||
).toThrow(
|
||||
"It is forbidden to override the following extension(s): 'test/forbidden', which is done by the following plugin(s): 'test'",
|
||||
);
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
extensions: [makeExtDef('forbidden')],
|
||||
});
|
||||
const result = resolveAppNodeSpecs({
|
||||
features: [plugin],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
forbidden: new Set(['test/forbidden']),
|
||||
collector,
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
|
||||
expect(collector.collectErrors()).toEqual([
|
||||
{
|
||||
code: 'EXTENSION_FORBIDDEN',
|
||||
message:
|
||||
"It is forbidden to override the 'test/forbidden' extension, attempted by the 'test' plugin",
|
||||
context: {
|
||||
plugin,
|
||||
extensionId: 'test/forbidden',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws an error when a forbidden extension is overridden by module', () => {
|
||||
expect(() =>
|
||||
resolveAppNodeSpecs({
|
||||
features: [
|
||||
createFrontendPlugin({
|
||||
pluginId: 'forbidden',
|
||||
extensions: [],
|
||||
}),
|
||||
createFrontendModule({
|
||||
pluginId: 'forbidden',
|
||||
extensions: [makeExtDef()],
|
||||
}),
|
||||
],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
forbidden: new Set(['forbidden']),
|
||||
}),
|
||||
).toThrow(
|
||||
"It is forbidden to override the following extension(s): 'forbidden', which is done by a module for the following plugin(s): 'forbidden'",
|
||||
);
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'forbidden',
|
||||
extensions: [],
|
||||
});
|
||||
const result = resolveAppNodeSpecs({
|
||||
features: [
|
||||
plugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'forbidden',
|
||||
extensions: [makeExtDef()],
|
||||
}),
|
||||
],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
forbidden: new Set(['forbidden']),
|
||||
collector,
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
|
||||
expect(collector.collectErrors()).toEqual([
|
||||
{
|
||||
code: 'EXTENSION_FORBIDDEN',
|
||||
message:
|
||||
"It is forbidden to override the 'forbidden' extension, attempted by the 'forbidden' plugin",
|
||||
context: {
|
||||
plugin,
|
||||
extensionId: 'forbidden',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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");
|
||||
const result = resolveAppNodeSpecs({
|
||||
features: [],
|
||||
builtinExtensions: [],
|
||||
parameters: [{ id: 'forbidden', disabled: false }],
|
||||
forbidden: new Set(['forbidden']),
|
||||
collector,
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
|
||||
expect(collector.collectErrors()).toEqual([
|
||||
{
|
||||
code: 'EXTENSION_CONFIG_FORBIDDEN',
|
||||
message: "Configuration of the 'forbidden' extension is forbidden",
|
||||
context: {
|
||||
extensionId: 'forbidden',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
createFrontendPlugin,
|
||||
Extension,
|
||||
FrontendFeature,
|
||||
FrontendPlugin,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionParameters } from './readAppExtensionsConfig';
|
||||
import { AppNodeSpec } from '@backstage/frontend-plugin-api';
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
} from '../../../frontend-plugin-api/src/wiring/createFrontendModule';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
|
||||
import { ErrorCollector } from '../wiring/createErrorCollector';
|
||||
|
||||
/** @internal */
|
||||
export function resolveAppNodeSpecs(options: {
|
||||
@@ -36,61 +38,58 @@ export function resolveAppNodeSpecs(options: {
|
||||
builtinExtensions?: Extension<any, any>[];
|
||||
parameters?: Array<ExtensionParameters>;
|
||||
forbidden?: Set<string>;
|
||||
allowUnknownExtensionConfig?: boolean;
|
||||
collector: ErrorCollector;
|
||||
}): AppNodeSpec[] {
|
||||
const {
|
||||
builtinExtensions = [],
|
||||
parameters = [],
|
||||
forbidden = new Set(),
|
||||
features = [],
|
||||
allowUnknownExtensionConfig = false,
|
||||
collector,
|
||||
} = options;
|
||||
|
||||
const plugins = features.filter(OpaqueFrontendPlugin.isType);
|
||||
const modules = features.filter(isInternalFrontendModule);
|
||||
|
||||
const filterForbidden = (
|
||||
extension: Extension<any, any> & { plugin: FrontendPlugin },
|
||||
) => {
|
||||
if (forbidden.has(extension.id)) {
|
||||
collector.report({
|
||||
code: 'EXTENSION_FORBIDDEN',
|
||||
message: `It is forbidden to override the '${extension.id}' extension, attempted by the '${extension.plugin.id}' plugin`,
|
||||
context: {
|
||||
plugin: extension.plugin,
|
||||
extensionId: extension.id,
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const pluginExtensions = plugins.flatMap(plugin => {
|
||||
return OpaqueFrontendPlugin.toInternal(plugin).extensions.map(
|
||||
extension => ({
|
||||
return OpaqueFrontendPlugin.toInternal(plugin)
|
||||
.extensions.map(extension => ({
|
||||
...extension,
|
||||
plugin,
|
||||
}),
|
||||
);
|
||||
}))
|
||||
.filter(filterForbidden);
|
||||
});
|
||||
const moduleExtensions = modules.flatMap(mod =>
|
||||
toInternalFrontendModule(mod).extensions.flatMap(extension => {
|
||||
// Modules for plugins that are not installed are ignored
|
||||
const plugin = plugins.find(p => p.id === mod.pluginId);
|
||||
if (!plugin) {
|
||||
return [];
|
||||
}
|
||||
toInternalFrontendModule(mod)
|
||||
.extensions.flatMap(extension => {
|
||||
// Modules for plugins that are not installed are ignored
|
||||
const plugin = plugins.find(p => p.id === mod.pluginId);
|
||||
if (!plugin) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ ...extension, plugin }];
|
||||
}),
|
||||
return [{ ...extension, plugin }];
|
||||
})
|
||||
.filter(filterForbidden),
|
||||
);
|
||||
|
||||
// Prevent core override
|
||||
if (pluginExtensions.some(({ id }) => forbidden.has(id))) {
|
||||
const pluginsStr = pluginExtensions
|
||||
.filter(({ id }) => forbidden.has(id))
|
||||
.map(({ plugin }) => `'${plugin.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 (moduleExtensions.some(({ id }) => forbidden.has(id))) {
|
||||
const pluginsStr = moduleExtensions
|
||||
.filter(({ id }) => forbidden.has(id))
|
||||
.map(({ plugin }) => `'${plugin.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 a module for the following plugin(s): ${pluginsStr}`,
|
||||
);
|
||||
}
|
||||
|
||||
const appPlugin =
|
||||
plugins.find(plugin => plugin.id === 'app') ??
|
||||
createFrontendPlugin({
|
||||
@@ -154,52 +153,41 @@ export function resolveAppNodeSpecs(options: {
|
||||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
};
|
||||
}, {});
|
||||
const seendExtensionIds = new Set<string>();
|
||||
const deduplicatedExtensions = configuredExtensions.filter(
|
||||
({ extension, params }) => {
|
||||
if (seendExtensionIds.has(extension.id)) {
|
||||
collector.report({
|
||||
code: 'EXTENSION_DUPLICATED',
|
||||
message: `The extension '${extension.id}' is duplicated`,
|
||||
context: {
|
||||
plugin: params.plugin,
|
||||
extensionId: extension.id,
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
seendExtensionIds.add(extension.id);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
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(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const order = new Map<string, (typeof configuredExtensions)[number]>();
|
||||
const order = new Map<string, (typeof deduplicatedExtensions)[number]>();
|
||||
for (const overrideParam of parameters) {
|
||||
const extensionId = overrideParam.id;
|
||||
|
||||
if (forbidden.has(extensionId)) {
|
||||
throw new Error(
|
||||
`Configuration of the '${extensionId}' extension is forbidden`,
|
||||
);
|
||||
collector.report({
|
||||
code: 'EXTENSION_CONFIG_FORBIDDEN',
|
||||
message: `Configuration of the '${extensionId}' extension is forbidden`,
|
||||
context: {
|
||||
extensionId,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = configuredExtensions.find(
|
||||
const existing = deduplicatedExtensions.find(
|
||||
e => e.extension.id === extensionId,
|
||||
);
|
||||
if (existing) {
|
||||
@@ -216,14 +204,20 @@ export function resolveAppNodeSpecs(options: {
|
||||
existing.params.disabled = Boolean(overrideParam.disabled);
|
||||
}
|
||||
order.set(extensionId, existing);
|
||||
} else if (!allowUnknownExtensionConfig) {
|
||||
throw new Error(`Extension ${extensionId} does not exist`);
|
||||
} else {
|
||||
collector.report({
|
||||
code: 'EXTENSION_CONFIG_UNKNOWN_EXTENSION',
|
||||
message: `Extension ${extensionId} does not exist`,
|
||||
context: {
|
||||
extensionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const orderedExtensions = [
|
||||
...order.values(),
|
||||
...configuredExtensions.filter(e => !order.has(e.extension.id)),
|
||||
...deduplicatedExtensions.filter(e => !order.has(e.extension.id)),
|
||||
];
|
||||
|
||||
return orderedExtensions.map(param => ({
|
||||
|
||||
@@ -24,6 +24,18 @@ import {
|
||||
import { resolveAppTree } from './resolveAppTree';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
|
||||
import { createErrorCollector } from '../wiring/createErrorCollector';
|
||||
|
||||
const collector = createErrorCollector();
|
||||
|
||||
afterEach(() => {
|
||||
const errors = collector.collectErrors();
|
||||
if (errors) {
|
||||
throw new Error(
|
||||
`Unexpected errors: ${errors.map(e => e.message).join(', ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const extension = resolveExtensionDefinition(
|
||||
createExtension({
|
||||
@@ -43,13 +55,13 @@ const baseSpec = {
|
||||
|
||||
describe('buildAppTree', () => {
|
||||
it('should fail to create an empty tree', () => {
|
||||
expect(() => resolveAppTree('app', [])).toThrow(
|
||||
expect(() => resolveAppTree('app', [], collector)).toThrow(
|
||||
"No root node with id 'app' found in app tree",
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a tree with only one node', () => {
|
||||
const tree = resolveAppTree('app', [{ ...baseSpec, id: 'app' }]);
|
||||
const tree = resolveAppTree('app', [{ ...baseSpec, id: 'app' }], collector);
|
||||
expect(tree.root).toEqual({
|
||||
spec: { ...baseSpec, id: 'app' },
|
||||
edges: { attachments: new Map() },
|
||||
@@ -59,15 +71,19 @@ describe('buildAppTree', () => {
|
||||
});
|
||||
|
||||
it('should create a tree', () => {
|
||||
const tree = resolveAppTree('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' },
|
||||
]);
|
||||
const tree = resolveAppTree(
|
||||
'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' },
|
||||
],
|
||||
collector,
|
||||
);
|
||||
|
||||
expect(Array.from(tree.nodes.keys())).toEqual([
|
||||
'a',
|
||||
@@ -122,26 +138,30 @@ describe('buildAppTree', () => {
|
||||
});
|
||||
|
||||
it('should create a tree with clones', () => {
|
||||
const tree = resolveAppTree('a', [
|
||||
{ ...baseSpec, id: 'a' },
|
||||
{ ...baseSpec, id: 'b', attachTo: { id: 'a', input: 'x' } },
|
||||
{
|
||||
...baseSpec,
|
||||
id: 'c',
|
||||
attachTo: [
|
||||
{ id: 'a', input: 'x' },
|
||||
{ id: 'b', input: 'x' },
|
||||
],
|
||||
},
|
||||
{
|
||||
...baseSpec,
|
||||
id: 'd',
|
||||
attachTo: [
|
||||
{ id: 'b', input: 'x' },
|
||||
{ id: 'c', input: 'x' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const tree = resolveAppTree(
|
||||
'a',
|
||||
[
|
||||
{ ...baseSpec, id: 'a' },
|
||||
{ ...baseSpec, id: 'b', attachTo: { id: 'a', input: 'x' } },
|
||||
{
|
||||
...baseSpec,
|
||||
id: 'c',
|
||||
attachTo: [
|
||||
{ id: 'a', input: 'x' },
|
||||
{ id: 'b', input: 'x' },
|
||||
],
|
||||
},
|
||||
{
|
||||
...baseSpec,
|
||||
id: 'd',
|
||||
attachTo: [
|
||||
{ id: 'b', input: 'x' },
|
||||
{ id: 'c', input: 'x' },
|
||||
],
|
||||
},
|
||||
],
|
||||
collector,
|
||||
);
|
||||
|
||||
expect(Array.from(tree.nodes.keys())).toEqual(['a', 'b', 'c', 'd']);
|
||||
|
||||
@@ -172,15 +192,19 @@ describe('buildAppTree', () => {
|
||||
});
|
||||
|
||||
it('should create a tree out of order', () => {
|
||||
const tree = resolveAppTree('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' },
|
||||
]);
|
||||
const tree = resolveAppTree(
|
||||
'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' },
|
||||
],
|
||||
collector,
|
||||
);
|
||||
|
||||
expect(Array.from(tree.nodes.keys())).toEqual([
|
||||
'bx2',
|
||||
@@ -214,13 +238,23 @@ describe('buildAppTree', () => {
|
||||
`);
|
||||
});
|
||||
|
||||
it('throws an error when duplicated extensions are detected', () => {
|
||||
expect(() =>
|
||||
resolveAppTree('app', [
|
||||
it('emits an error when duplicated extensions are detected', () => {
|
||||
const tree = resolveAppTree(
|
||||
'a',
|
||||
[
|
||||
{ ...baseSpec, id: 'a' },
|
||||
{ ...baseSpec, id: 'a' },
|
||||
]),
|
||||
).toThrow("Unexpected duplicate extension id 'a'");
|
||||
],
|
||||
collector,
|
||||
);
|
||||
expect(Array.from(tree.nodes.keys())).toEqual(['a']);
|
||||
expect(collector.collectErrors()).toEqual([
|
||||
{
|
||||
code: 'DUPLICATE_EXTENSION_ID',
|
||||
message: "Unexpected duplicate extension id 'a'",
|
||||
context: { spec: { ...baseSpec, id: 'a' } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe('redirects', () => {
|
||||
@@ -253,12 +287,28 @@ describe('buildAppTree', () => {
|
||||
}),
|
||||
) as Extension<unknown, unknown>;
|
||||
|
||||
expect(() =>
|
||||
resolveAppTree('a', [
|
||||
const tree = resolveAppTree(
|
||||
'a',
|
||||
[
|
||||
{ ...baseSpec, id: 'a', extension: e1 },
|
||||
{ ...baseSpec, id: 'b', extension: e2 },
|
||||
]),
|
||||
).toThrow("Duplicate redirect target for input 'test' in extension 'b'");
|
||||
],
|
||||
collector,
|
||||
);
|
||||
|
||||
expect(Array.from(tree.nodes.keys())).toEqual(['a', 'b']);
|
||||
|
||||
expect(collector.collectErrors()).toEqual([
|
||||
{
|
||||
code: 'DUPLICATE_REDIRECT_TARGET',
|
||||
message:
|
||||
"Duplicate redirect target for input 'test' in extension 'b'",
|
||||
context: {
|
||||
spec: { ...baseSpec, id: 'b', extension: e2 },
|
||||
inputName: 'test',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should set the correct attachment point for a redirect', () => {
|
||||
@@ -285,22 +335,26 @@ describe('buildAppTree', () => {
|
||||
}),
|
||||
) as Extension<unknown, unknown>;
|
||||
|
||||
const tree = resolveAppTree('a', [
|
||||
{
|
||||
attachTo: e1.attachTo,
|
||||
id: 'a',
|
||||
extension: e1,
|
||||
disabled: false,
|
||||
plugin: baseSpec.plugin,
|
||||
},
|
||||
{
|
||||
attachTo: e2.attachTo,
|
||||
id: 'b',
|
||||
extension: e2,
|
||||
disabled: false,
|
||||
plugin: baseSpec.plugin,
|
||||
},
|
||||
]);
|
||||
const tree = resolveAppTree(
|
||||
'a',
|
||||
[
|
||||
{
|
||||
attachTo: e1.attachTo,
|
||||
id: 'a',
|
||||
extension: e1,
|
||||
disabled: false,
|
||||
plugin: baseSpec.plugin,
|
||||
},
|
||||
{
|
||||
attachTo: e2.attachTo,
|
||||
id: 'b',
|
||||
extension: e2,
|
||||
disabled: false,
|
||||
plugin: baseSpec.plugin,
|
||||
},
|
||||
],
|
||||
collector,
|
||||
);
|
||||
|
||||
expect(tree.root).toMatchInlineSnapshot(`
|
||||
{
|
||||
@@ -365,29 +419,33 @@ describe('buildAppTree', () => {
|
||||
}),
|
||||
) as Extension<unknown, unknown>;
|
||||
|
||||
const tree = resolveAppTree('test-2', [
|
||||
{
|
||||
attachTo: e1.attachTo,
|
||||
id: e1.id,
|
||||
extension: e1,
|
||||
disabled: false,
|
||||
plugin: baseSpec.plugin,
|
||||
},
|
||||
{
|
||||
attachTo: e2.attachTo,
|
||||
id: e2.id,
|
||||
extension: e2,
|
||||
disabled: false,
|
||||
plugin: baseSpec.plugin,
|
||||
},
|
||||
{
|
||||
attachTo: e3.attachTo,
|
||||
id: e3.id,
|
||||
extension: e3,
|
||||
disabled: false,
|
||||
plugin: baseSpec.plugin,
|
||||
},
|
||||
]);
|
||||
const tree = resolveAppTree(
|
||||
'test-2',
|
||||
[
|
||||
{
|
||||
attachTo: e1.attachTo,
|
||||
id: e1.id,
|
||||
extension: e1,
|
||||
disabled: false,
|
||||
plugin: baseSpec.plugin,
|
||||
},
|
||||
{
|
||||
attachTo: e2.attachTo,
|
||||
id: e2.id,
|
||||
extension: e2,
|
||||
disabled: false,
|
||||
plugin: baseSpec.plugin,
|
||||
},
|
||||
{
|
||||
attachTo: e3.attachTo,
|
||||
id: e3.id,
|
||||
extension: e3,
|
||||
disabled: false,
|
||||
plugin: baseSpec.plugin,
|
||||
},
|
||||
],
|
||||
collector,
|
||||
);
|
||||
|
||||
expect(tree.nodes.get('test-3')?.edges.attachedTo?.node).toBe(
|
||||
tree.nodes.get('test-2'),
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
|
||||
import { ErrorCollector } from '../wiring/createErrorCollector';
|
||||
|
||||
function indent(str: string) {
|
||||
return str.replace(/^/gm, ' ');
|
||||
@@ -114,6 +115,7 @@ const isValidAttachmentPoint = (
|
||||
export function resolveAppTree(
|
||||
rootNodeId: string,
|
||||
specs: AppNodeSpec[],
|
||||
errorCollector: ErrorCollector,
|
||||
): AppTree {
|
||||
const nodes = new Map<string, SerializableAppNode>();
|
||||
|
||||
@@ -122,7 +124,14 @@ export function resolveAppTree(
|
||||
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}'`);
|
||||
errorCollector.report({
|
||||
code: 'DUPLICATE_EXTENSION_ID',
|
||||
message: `Unexpected duplicate extension id '${spec.id}'`,
|
||||
context: {
|
||||
spec,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const node = new SerializableAppNode(spec);
|
||||
@@ -134,9 +143,15 @@ export function resolveAppTree(
|
||||
for (const replace of input.replaces) {
|
||||
const key = makeRedirectKey(replace);
|
||||
if (redirectTargetsByKey.has(key)) {
|
||||
throw new Error(
|
||||
`Duplicate redirect target for input '${inputName}' in extension '${spec.id}'`,
|
||||
);
|
||||
errorCollector.report({
|
||||
code: 'DUPLICATE_REDIRECT_TARGET',
|
||||
message: `Duplicate redirect target for input '${inputName}' in extension '${spec.id}'`,
|
||||
context: {
|
||||
spec,
|
||||
inputName,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
redirectTargetsByKey.set(key, { id: spec.id, input: inputName });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2025 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 {
|
||||
AppNode,
|
||||
AppNodeSpec,
|
||||
FrontendPlugin,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
/** @public */
|
||||
export type AppError = {
|
||||
code: string;
|
||||
message: string;
|
||||
context?: {
|
||||
node?: AppNode;
|
||||
spec?: AppNodeSpec;
|
||||
plugin?: FrontendPlugin;
|
||||
extensionId?: string;
|
||||
inputName?: string;
|
||||
dataRefId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
export interface ErrorCollector {
|
||||
report(report: AppError): void;
|
||||
child(context?: AppError['context']): ErrorCollector;
|
||||
collectErrors(): AppError[] | undefined;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function createErrorCollector(context?: AppError['context']) {
|
||||
const errors: AppError[] = [];
|
||||
const children: ErrorCollector[] = [];
|
||||
return {
|
||||
report(report: AppError) {
|
||||
errors.push({ ...report, context: { ...context, ...report.context } });
|
||||
},
|
||||
collectErrors() {
|
||||
const allErrors = [
|
||||
...errors,
|
||||
...children.flatMap(child => child.collectErrors() ?? []),
|
||||
];
|
||||
errors.length = 0;
|
||||
if (allErrors.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return allErrors;
|
||||
},
|
||||
child(childContext: AppError['context']) {
|
||||
const child = createErrorCollector(childContext);
|
||||
children.push(child);
|
||||
return child;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -83,6 +83,11 @@ import {
|
||||
FrontendPluginInfoResolver,
|
||||
} from './createPluginInfoAttacher';
|
||||
import { createRouteAliasResolver } from '../routing/RouteAliasResolver';
|
||||
import {
|
||||
AppError,
|
||||
createErrorCollector,
|
||||
ErrorCollector,
|
||||
} from './createErrorCollector';
|
||||
|
||||
function deduplicateFeatures(
|
||||
allFeatures: FrontendFeature[],
|
||||
@@ -284,12 +289,15 @@ export type CreateSpecializedAppOptions = {
|
||||
export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
|
||||
apis: ApiHolder;
|
||||
tree: AppTree;
|
||||
errors?: AppError[];
|
||||
} {
|
||||
const config = options?.config ?? new ConfigReader({}, 'empty-config');
|
||||
const features = deduplicateFeatures(options?.features ?? []).map(
|
||||
createPluginInfoAttacher(config, options?.advanced?.pluginInfoResolver),
|
||||
);
|
||||
|
||||
const collector = createErrorCollector();
|
||||
|
||||
const tree = resolveAppTree(
|
||||
'root',
|
||||
resolveAppNodeSpecs({
|
||||
@@ -299,12 +307,12 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
|
||||
],
|
||||
parameters: readAppExtensionsConfig(config),
|
||||
forbidden: new Set(['root']),
|
||||
allowUnknownExtensionConfig:
|
||||
options?.advanced?.allowUnknownExtensionConfig,
|
||||
collector,
|
||||
}),
|
||||
collector,
|
||||
);
|
||||
|
||||
const factories = createApiFactories({ tree });
|
||||
const factories = createApiFactories({ tree, collector });
|
||||
const appBasePath = getBasePath(config);
|
||||
const appTreeApi = new AppTreeApiProxy(tree, appBasePath);
|
||||
|
||||
@@ -353,6 +361,7 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
|
||||
instantiateAppNodeTree(
|
||||
tree.root,
|
||||
apis,
|
||||
collector,
|
||||
mergeExtensionFactoryMiddleware(
|
||||
options?.advanced?.extensionFactoryMiddleware,
|
||||
),
|
||||
@@ -366,22 +375,32 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
|
||||
routeResolutionApi.initialize(routeInfo, routeRefsById.routes);
|
||||
appTreeApi.initialize(routeInfo);
|
||||
|
||||
return { apis, tree };
|
||||
return { apis, tree, errors: collector.collectErrors() };
|
||||
}
|
||||
|
||||
function createApiFactories(options: { tree: AppTree }): AnyApiFactory[] {
|
||||
function createApiFactories(options: {
|
||||
tree: AppTree;
|
||||
collector: ErrorCollector;
|
||||
}): AnyApiFactory[] {
|
||||
const emptyApiHolder = ApiRegistry.from([]);
|
||||
const factories = new Array<AnyApiFactory>();
|
||||
|
||||
for (const apiNode of options.tree.root.edges.attachments.get('apis') ?? []) {
|
||||
instantiateAppNodeTree(apiNode, emptyApiHolder);
|
||||
const apiFactory = apiNode.instance?.getData(ApiBlueprint.dataRefs.factory);
|
||||
if (!apiFactory) {
|
||||
throw new Error(
|
||||
`No API factory found in for extension ${apiNode.spec.id}`,
|
||||
);
|
||||
if (!instantiateAppNodeTree(apiNode, emptyApiHolder, options.collector)) {
|
||||
continue;
|
||||
}
|
||||
const apiFactory = apiNode.instance?.getData(ApiBlueprint.dataRefs.factory);
|
||||
if (apiFactory) {
|
||||
factories.push(apiFactory);
|
||||
} else {
|
||||
options.collector.report({
|
||||
code: 'NO_API_FACTORY',
|
||||
message: `API extension '${apiNode.spec.id}' did not output an API factory`,
|
||||
context: {
|
||||
node: apiNode,
|
||||
},
|
||||
});
|
||||
}
|
||||
factories.push(apiFactory);
|
||||
}
|
||||
|
||||
return factories;
|
||||
|
||||
@@ -19,3 +19,4 @@ export {
|
||||
type CreateSpecializedAppOptions,
|
||||
} from './createSpecializedApp';
|
||||
export { type FrontendPluginInfoResolver } from './createPluginInfoAttacher';
|
||||
export { type AppError } from './createErrorCollector';
|
||||
|
||||
Reference in New Issue
Block a user