Merge pull request #31082 from backstage/rugvip/errors
frontend-app-api: new error handling for app startup
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-test-utils': patch
|
||||
---
|
||||
|
||||
Internal update to use and throw app errors.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-app-api': minor
|
||||
---
|
||||
|
||||
The `createSpecializedApp` no longer throws when encountering many common errors when starting up the app. It will instead return them through the `errors` property so that they can be handled more gracefully in the app.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-defaults': patch
|
||||
---
|
||||
|
||||
The default app now leverages the new error reporting functionality from `@backstage/frontend-app-api`. If there are critical errors during startup, an error screen that shows a summary of all errors will now be shown, rather than leaving the screen blank. Other errors will be logged as warnings in the console.
|
||||
@@ -4,16 +4,128 @@
|
||||
|
||||
```ts
|
||||
import { ApiHolder } from '@backstage/core-plugin-api';
|
||||
import { AppNode } 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 =
|
||||
keyof AppErrorTypes extends infer ICode extends keyof AppErrorTypes
|
||||
? ICode extends any
|
||||
? {
|
||||
code: ICode;
|
||||
message: string;
|
||||
context: AppErrorTypes[ICode]['context'];
|
||||
}
|
||||
: never
|
||||
: never;
|
||||
|
||||
// @public (undocumented)
|
||||
export type AppErrorTypes = {
|
||||
EXTENSION_IGNORED: {
|
||||
context: {
|
||||
plugin: FrontendPlugin;
|
||||
extensionId: string;
|
||||
};
|
||||
};
|
||||
INVALID_EXTENSION_CONFIG_KEY: {
|
||||
context: {
|
||||
extensionId: string;
|
||||
};
|
||||
};
|
||||
EXTENSION_INPUT_REDIRECT_CONFLICT: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
inputName: string;
|
||||
};
|
||||
};
|
||||
EXTENSION_INPUT_DATA_IGNORED: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
inputName: string;
|
||||
};
|
||||
};
|
||||
EXTENSION_INPUT_DATA_MISSING: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
inputName: string;
|
||||
};
|
||||
};
|
||||
EXTENSION_ATTACHMENT_CONFLICT: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
inputName: string;
|
||||
};
|
||||
};
|
||||
EXTENSION_ATTACHMENT_MISSING: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
inputName: string;
|
||||
};
|
||||
};
|
||||
EXTENSION_CONFIGURATION_INVALID: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
};
|
||||
};
|
||||
EXTENSION_INVALID: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
};
|
||||
};
|
||||
EXTENSION_OUTPUT_CONFLICT: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
dataRefId: string;
|
||||
};
|
||||
};
|
||||
EXTENSION_OUTPUT_MISSING: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
dataRefId: string;
|
||||
};
|
||||
};
|
||||
EXTENSION_OUTPUT_IGNORED: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
dataRefId: string;
|
||||
};
|
||||
};
|
||||
EXTENSION_FACTORY_ERROR: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
};
|
||||
};
|
||||
API_EXTENSION_INVALID: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
};
|
||||
};
|
||||
ROUTE_DUPLICATE: {
|
||||
context: {
|
||||
routeId: string;
|
||||
};
|
||||
};
|
||||
ROUTE_BINDING_INVALID_VALUE: {
|
||||
context: {
|
||||
routeId: string;
|
||||
};
|
||||
};
|
||||
ROUTE_NOT_FOUND: {
|
||||
context: {
|
||||
routeId: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
export type CreateAppRouteBinder = <
|
||||
TExternalRoutes extends {
|
||||
@@ -31,6 +143,7 @@ export type CreateAppRouteBinder = <
|
||||
export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
|
||||
apis: ApiHolder;
|
||||
tree: AppTree;
|
||||
errors?: AppError[];
|
||||
};
|
||||
|
||||
// @public
|
||||
|
||||
@@ -20,6 +20,18 @@ import {
|
||||
createFrontendPlugin,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { collectRouteIds } from './collectRouteIds';
|
||||
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(', ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
describe('collectRouteIds', () => {
|
||||
it('should assign IDs to routes', () => {
|
||||
@@ -33,13 +45,16 @@ describe('collectRouteIds', () => {
|
||||
/^ExternalRouteRef\{created at '.*collectRouteIds\.test\.ts.*'\}$/,
|
||||
);
|
||||
|
||||
const collected = collectRouteIds([
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
routes: { ref },
|
||||
externalRoutes: { extRef },
|
||||
}),
|
||||
]);
|
||||
const collected = collectRouteIds(
|
||||
[
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
routes: { ref },
|
||||
externalRoutes: { extRef },
|
||||
}),
|
||||
],
|
||||
collector,
|
||||
);
|
||||
expect(Object.fromEntries(collected.routes)).toEqual({
|
||||
'test.ref': ref,
|
||||
});
|
||||
@@ -50,4 +65,40 @@ describe('collectRouteIds', () => {
|
||||
expect(String(ref)).toBe('RouteRef{test.ref}');
|
||||
expect(String(extRef)).toBe('ExternalRouteRef{test.extRef}');
|
||||
});
|
||||
|
||||
it('should report duplicate route IDs', () => {
|
||||
const ref = createRouteRef();
|
||||
const extRef = createExternalRouteRef();
|
||||
|
||||
collectRouteIds(
|
||||
[
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
routes: { 'mid.ref': ref },
|
||||
externalRoutes: { 'mid.extRef': extRef },
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test.mid',
|
||||
routes: { ref },
|
||||
externalRoutes: { extRef },
|
||||
}),
|
||||
],
|
||||
collector,
|
||||
);
|
||||
|
||||
expect(collector.collectErrors()).toEqual([
|
||||
{
|
||||
code: 'ROUTE_DUPLICATE',
|
||||
message:
|
||||
"Duplicate route id 'test.mid.ref' encountered while collecting routes",
|
||||
context: { routeId: 'test.mid.ref' },
|
||||
},
|
||||
{
|
||||
code: 'ROUTE_DUPLICATE',
|
||||
message:
|
||||
"Duplicate external route id 'test.mid.extRef' encountered while collecting routes",
|
||||
context: { routeId: 'test.mid.extRef' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import { toInternalExternalRouteRef } from '../../../frontend-plugin-api/src/rou
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalSubRouteRef } from '../../../frontend-plugin-api/src/routing/SubRouteRef';
|
||||
import { OpaqueFrontendPlugin } from '@internal/frontend';
|
||||
import { ErrorCollector } from '../wiring/createErrorCollector';
|
||||
|
||||
/** @internal */
|
||||
export interface RouteRefsById {
|
||||
@@ -38,7 +39,10 @@ export interface RouteRefsById {
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function collectRouteIds(features: FrontendFeature[]): RouteRefsById {
|
||||
export function collectRouteIds(
|
||||
features: FrontendFeature[],
|
||||
collector: ErrorCollector,
|
||||
): RouteRefsById {
|
||||
const routesById = new Map<string, RouteRef | SubRouteRef>();
|
||||
const externalRoutesById = new Map<string, ExternalRouteRef>();
|
||||
|
||||
@@ -50,7 +54,12 @@ export function collectRouteIds(features: FrontendFeature[]): RouteRefsById {
|
||||
for (const [name, ref] of Object.entries(feature.routes)) {
|
||||
const refId = `${feature.id}.${name}`;
|
||||
if (routesById.has(refId)) {
|
||||
throw new Error(`Unexpected duplicate route '${refId}'`);
|
||||
collector.report({
|
||||
code: 'ROUTE_DUPLICATE',
|
||||
message: `Duplicate route id '${refId}' encountered while collecting routes`,
|
||||
context: { routeId: refId },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isRouteRef(ref)) {
|
||||
@@ -65,7 +74,12 @@ export function collectRouteIds(features: FrontendFeature[]): RouteRefsById {
|
||||
for (const [name, ref] of Object.entries(feature.externalRoutes)) {
|
||||
const refId = `${feature.id}.${name}`;
|
||||
if (externalRoutesById.has(refId)) {
|
||||
throw new Error(`Unexpected duplicate external route '${refId}'`);
|
||||
collector.report({
|
||||
code: 'ROUTE_DUPLICATE',
|
||||
message: `Duplicate external route id '${refId}' encountered while collecting routes`,
|
||||
context: { routeId: refId },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const internalRef = toInternalExternalRouteRef(ref);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -20,6 +20,18 @@ import {
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { resolveRouteBindings } from './resolveRouteBindings';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
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 emptyIds = { routes: new Map(), externalRoutes: new Map() };
|
||||
|
||||
@@ -33,23 +45,30 @@ describe('resolveRouteBindings', () => {
|
||||
},
|
||||
new ConfigReader({}),
|
||||
emptyIds,
|
||||
collector,
|
||||
);
|
||||
|
||||
expect(result.get(external.myRoute)).toBe(ref);
|
||||
});
|
||||
|
||||
it('throws on unknown keys', () => {
|
||||
it('reports error on unknown keys', () => {
|
||||
const external = { myRoute: createExternalRouteRef() };
|
||||
const ref = createRouteRef();
|
||||
expect(() =>
|
||||
resolveRouteBindings(
|
||||
({ bind }) => {
|
||||
bind(external, { someOtherRoute: ref } as any);
|
||||
},
|
||||
new ConfigReader({}),
|
||||
emptyIds,
|
||||
),
|
||||
).toThrow('Key someOtherRoute is not an existing external route');
|
||||
resolveRouteBindings(
|
||||
({ bind }) => {
|
||||
bind(external, { someOtherRoute: ref } as any);
|
||||
},
|
||||
new ConfigReader({}),
|
||||
emptyIds,
|
||||
collector,
|
||||
);
|
||||
expect(collector.collectErrors()).toEqual([
|
||||
{
|
||||
code: 'ROUTE_NOT_FOUND',
|
||||
message: 'Key someOtherRoute is not an existing external route',
|
||||
context: { routeId: 'someOtherRoute' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads bindings from config', () => {
|
||||
@@ -64,6 +83,7 @@ describe('resolveRouteBindings', () => {
|
||||
routes: new Map([['myTarget', myTarget]]),
|
||||
externalRoutes: new Map([['mySource', mySource]]),
|
||||
},
|
||||
collector,
|
||||
);
|
||||
|
||||
expect(result.get(mySource)).toBe(myTarget);
|
||||
@@ -85,6 +105,7 @@ describe('resolveRouteBindings', () => {
|
||||
routes: new Map([['myTarget', myTarget]]),
|
||||
externalRoutes: new Map([['mySource', mySource]]),
|
||||
},
|
||||
collector,
|
||||
).get(mySource),
|
||||
).toBe(undefined);
|
||||
|
||||
@@ -100,57 +121,74 @@ describe('resolveRouteBindings', () => {
|
||||
routes: new Map([['myTarget', myTarget]]),
|
||||
externalRoutes: new Map([['mySource', mySource]]),
|
||||
},
|
||||
collector,
|
||||
).get(mySource),
|
||||
).toBe(myTarget);
|
||||
});
|
||||
|
||||
it('throws on invalid config', () => {
|
||||
it('reports errors on invalid config', () => {
|
||||
expect(() =>
|
||||
resolveRouteBindings(
|
||||
() => {},
|
||||
new ConfigReader({ app: { routes: { bindings: 'derp' } } }),
|
||||
emptyIds,
|
||||
collector,
|
||||
),
|
||||
).toThrow(
|
||||
"Invalid type in config for key 'app.routes.bindings' in 'mock-config', got string, wanted object",
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
resolveRouteBindings(
|
||||
() => {},
|
||||
new ConfigReader({ app: { routes: { bindings: { mySource: true } } } }),
|
||||
emptyIds,
|
||||
),
|
||||
).toThrow(
|
||||
"Invalid config at app.routes.bindings['mySource'], value must be a non-empty string",
|
||||
resolveRouteBindings(
|
||||
() => {},
|
||||
new ConfigReader({ app: { routes: { bindings: { mySource: true } } } }),
|
||||
emptyIds,
|
||||
collector,
|
||||
);
|
||||
expect(collector.collectErrors()).toEqual([
|
||||
{
|
||||
code: 'ROUTE_BINDING_INVALID_VALUE',
|
||||
message:
|
||||
"Invalid config at app.routes.bindings['mySource'], value must be a non-empty string or false",
|
||||
context: { routeId: 'mySource' },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
resolveRouteBindings(
|
||||
() => {},
|
||||
new ConfigReader({
|
||||
app: { routes: { bindings: { mySource: 'myTarget' } } },
|
||||
}),
|
||||
emptyIds,
|
||||
),
|
||||
).toThrow(
|
||||
"Invalid config at app.routes.bindings, 'mySource' is not a valid external route",
|
||||
resolveRouteBindings(
|
||||
() => {},
|
||||
new ConfigReader({
|
||||
app: { routes: { bindings: { mySource: 'myTarget' } } },
|
||||
}),
|
||||
emptyIds,
|
||||
collector,
|
||||
);
|
||||
expect(collector.collectErrors()).toEqual([
|
||||
{
|
||||
code: 'ROUTE_NOT_FOUND',
|
||||
message:
|
||||
"Invalid config at app.routes.bindings, 'mySource' is not a valid external route",
|
||||
context: { routeId: 'mySource' },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
resolveRouteBindings(
|
||||
() => {},
|
||||
new ConfigReader({
|
||||
app: { routes: { bindings: { mySource: 'myTarget' } } },
|
||||
}),
|
||||
{
|
||||
...emptyIds,
|
||||
externalRoutes: new Map([['mySource', createExternalRouteRef()]]),
|
||||
},
|
||||
),
|
||||
).toThrow(
|
||||
"Invalid config at app.routes.bindings['mySource'], 'myTarget' is not a valid route",
|
||||
resolveRouteBindings(
|
||||
() => {},
|
||||
new ConfigReader({
|
||||
app: { routes: { bindings: { mySource: 'myTarget' } } },
|
||||
}),
|
||||
{
|
||||
...emptyIds,
|
||||
externalRoutes: new Map([['mySource', createExternalRouteRef()]]),
|
||||
},
|
||||
collector,
|
||||
);
|
||||
expect(collector.collectErrors()).toEqual([
|
||||
{
|
||||
code: 'ROUTE_NOT_FOUND',
|
||||
message:
|
||||
"Invalid config at app.routes.bindings['mySource'], 'myTarget' is not a valid route",
|
||||
context: { routeId: 'myTarget' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('can have default targets, but at the lowest priority', () => {
|
||||
@@ -170,6 +208,7 @@ describe('resolveRouteBindings', () => {
|
||||
() => {},
|
||||
new ConfigReader({}),
|
||||
routesById,
|
||||
collector,
|
||||
);
|
||||
|
||||
expect(result.get(source)).toBe(target1);
|
||||
@@ -181,6 +220,7 @@ describe('resolveRouteBindings', () => {
|
||||
app: { routes: { bindings: { source: 'target2' } } },
|
||||
}),
|
||||
routesById,
|
||||
collector,
|
||||
);
|
||||
|
||||
expect(result.get(source)).toBe(target2);
|
||||
@@ -192,6 +232,7 @@ describe('resolveRouteBindings', () => {
|
||||
},
|
||||
new ConfigReader({}),
|
||||
routesById,
|
||||
collector,
|
||||
);
|
||||
|
||||
expect(result.get(source)).toBe(target2);
|
||||
@@ -210,6 +251,7 @@ describe('resolveRouteBindings', () => {
|
||||
() => {},
|
||||
new ConfigReader({}),
|
||||
routesById,
|
||||
collector,
|
||||
);
|
||||
|
||||
expect(result.get(source)).toBe(target1);
|
||||
@@ -219,6 +261,7 @@ describe('resolveRouteBindings', () => {
|
||||
() => {},
|
||||
new ConfigReader({ app: { routes: { bindings: { source: false } } } }),
|
||||
routesById,
|
||||
collector,
|
||||
);
|
||||
|
||||
expect(result.get(source)).toBe(undefined);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ExternalRouteRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { RouteRefsById } from './collectRouteIds';
|
||||
import { ErrorCollector } from '../wiring/createErrorCollector';
|
||||
import { Config } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
@@ -80,6 +81,7 @@ export function resolveRouteBindings(
|
||||
bindRoutes: ((context: { bind: CreateAppRouteBinder }) => void) | undefined,
|
||||
config: Config,
|
||||
routesById: RouteRefsById,
|
||||
collector: ErrorCollector,
|
||||
): Map<ExternalRouteRef, RouteRef | SubRouteRef> {
|
||||
const result = new Map<ExternalRouteRef, RouteRef | SubRouteRef>();
|
||||
const disabledExternalRefs = new Set<ExternalRouteRef>();
|
||||
@@ -93,7 +95,12 @@ export function resolveRouteBindings(
|
||||
for (const [key, value] of Object.entries(targetRoutes)) {
|
||||
const externalRoute = externalRoutes[key];
|
||||
if (!externalRoute) {
|
||||
throw new Error(`Key ${key} is not an existing external route`);
|
||||
collector.report({
|
||||
code: 'ROUTE_NOT_FOUND',
|
||||
message: `Key ${key} is not an existing external route`,
|
||||
context: { routeId: String(key) },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (value) {
|
||||
result.set(externalRoute, value);
|
||||
@@ -112,16 +119,22 @@ export function resolveRouteBindings(
|
||||
if (bindings) {
|
||||
for (const [externalRefId, targetRefId] of Object.entries(bindings)) {
|
||||
if (!isValidTargetRefId(targetRefId)) {
|
||||
throw new Error(
|
||||
`Invalid config at app.routes.bindings['${externalRefId}'], value must be a non-empty string or false`,
|
||||
);
|
||||
collector.report({
|
||||
code: 'ROUTE_BINDING_INVALID_VALUE',
|
||||
message: `Invalid config at app.routes.bindings['${externalRefId}'], value must be a non-empty string or false`,
|
||||
context: { routeId: externalRefId },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const externalRef = routesById.externalRoutes.get(externalRefId);
|
||||
if (!externalRef) {
|
||||
throw new Error(
|
||||
`Invalid config at app.routes.bindings, '${externalRefId}' is not a valid external route`,
|
||||
);
|
||||
collector.report({
|
||||
code: 'ROUTE_NOT_FOUND',
|
||||
message: `Invalid config at app.routes.bindings, '${externalRefId}' is not a valid external route`,
|
||||
context: { routeId: externalRefId },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if binding was already defined in code
|
||||
@@ -134,9 +147,14 @@ export function resolveRouteBindings(
|
||||
} else {
|
||||
const targetRef = routesById.routes.get(targetRefId);
|
||||
if (!targetRef) {
|
||||
throw new Error(
|
||||
`Invalid config at app.routes.bindings['${externalRefId}'], '${targetRefId}' is not a valid route`,
|
||||
);
|
||||
collector.report({
|
||||
code: 'ROUTE_NOT_FOUND',
|
||||
message: `Invalid config at app.routes.bindings['${externalRefId}'], '${String(
|
||||
targetRefId,
|
||||
)}' is not a valid route`,
|
||||
context: { routeId: String(targetRefId) },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
result.set(externalRef, targetRef);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,36 @@ 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';
|
||||
|
||||
const INSTANTIATION_FAILED = new Error('Instantiation failed');
|
||||
|
||||
/**
|
||||
* Like `array.map`, but if `INSTANTIATION_FAILED` is thrown, the iteration will continue but afterwards re-throw `INSTANTIATION_FAILED`
|
||||
* @returns
|
||||
*/
|
||||
function mapWithFailures<T, U>(
|
||||
iterable: Iterable<T>,
|
||||
callback: (item: T) => U,
|
||||
): U[] {
|
||||
let failed = false;
|
||||
const results = Array.from(iterable).map(item => {
|
||||
try {
|
||||
return callback(item);
|
||||
} catch (error) {
|
||||
if (error === INSTANTIATION_FAILED) {
|
||||
failed = true;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
return null as any;
|
||||
}
|
||||
});
|
||||
if (failed) {
|
||||
throw INSTANTIATION_FAILED;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
type Mutable<T> = {
|
||||
-readonly [P in keyof T]: T[P];
|
||||
@@ -39,37 +69,45 @@ function resolveV1InputDataMap(
|
||||
attachment: AppNode,
|
||||
inputName: string,
|
||||
) {
|
||||
return mapValues(dataMap, ref => {
|
||||
const value = attachment.instance?.getData(ref);
|
||||
if (value === undefined && !ref.config.optional) {
|
||||
const expected = Object.values(dataMap)
|
||||
.filter(r => !r.config.optional)
|
||||
.map(r => `'${r.id}'`)
|
||||
.join(', ');
|
||||
return Object.fromEntries(
|
||||
mapWithFailures(Object.entries(dataMap), ([key, ref]) => {
|
||||
const value = attachment.instance?.getData(ref);
|
||||
if (value === undefined && !ref.config.optional) {
|
||||
const expected = Object.values(dataMap)
|
||||
.filter(r => !r.config.optional)
|
||||
.map(r => `'${r.id}'`)
|
||||
.join(', ');
|
||||
|
||||
const provided = [...(attachment.instance?.getDataRefs() ?? [])]
|
||||
.map(r => `'${r.id}'`)
|
||||
.join(', ');
|
||||
const provided = [...(attachment.instance?.getDataRefs() ?? [])]
|
||||
.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})`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
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})`,
|
||||
);
|
||||
}
|
||||
return [key, value];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveInputDataContainer(
|
||||
extensionData: Array<ExtensionDataRef>,
|
||||
attachment: AppNode,
|
||||
inputName: string,
|
||||
collector: ErrorCollector<{ node: AppNode; inputName: string }>,
|
||||
): { node: AppNode } & ExtensionDataContainer<ExtensionDataRef> {
|
||||
const dataMap = new Map<string, unknown>();
|
||||
|
||||
for (const ref of extensionData) {
|
||||
mapWithFailures(extensionData, ref => {
|
||||
if (dataMap.has(ref.id)) {
|
||||
throw new Error(`Unexpected duplicate input data '${ref.id}'`);
|
||||
collector.report({
|
||||
code: 'EXTENSION_INPUT_DATA_IGNORED',
|
||||
message: `Unexpected duplicate input data '${ref.id}'`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const value = attachment.instance?.getData(ref);
|
||||
if (value === undefined && !ref.config.optional) {
|
||||
const expected = extensionData
|
||||
@@ -81,13 +119,15 @@ 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_INPUT_DATA_MISSING',
|
||||
message: `extension '${attachment.spec.id}' could not be attached because its output data (${provided}) does not match what the input '${inputName}' requires (${expected})`,
|
||||
});
|
||||
throw INSTANTIATION_FAILED;
|
||||
}
|
||||
|
||||
dataMap.set(ref.id, value);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
node: attachment,
|
||||
@@ -147,40 +187,52 @@ function resolveV1Inputs(
|
||||
},
|
||||
attachments: ReadonlyMap<string, AppNode[]>,
|
||||
) {
|
||||
return mapValues(inputMap, (input, inputName) => {
|
||||
const attachedNodes = attachments.get(inputName) ?? [];
|
||||
return Object.fromEntries(
|
||||
mapWithFailures(Object.entries(inputMap), ([inputName, input]) => {
|
||||
const attachedNodes = attachments.get(inputName) ?? [];
|
||||
|
||||
if (input.config.singleton) {
|
||||
if (attachedNodes.length > 1) {
|
||||
const attachedNodeIds = attachedNodes.map(e => e.spec.id);
|
||||
throw Error(
|
||||
`expected ${
|
||||
input.config.optional ? 'at most' : 'exactly'
|
||||
} one '${inputName}' input but received multiple: '${attachedNodeIds.join(
|
||||
"', '",
|
||||
)}'`,
|
||||
);
|
||||
} else if (attachedNodes.length === 0) {
|
||||
if (input.config.optional) {
|
||||
return undefined;
|
||||
if (input.config.singleton) {
|
||||
if (attachedNodes.length > 1) {
|
||||
const attachedNodeIds = attachedNodes.map(e => e.spec.id);
|
||||
throw Error(
|
||||
`expected ${
|
||||
input.config.optional ? 'at most' : 'exactly'
|
||||
} one '${inputName}' input but received multiple: '${attachedNodeIds.join(
|
||||
"', '",
|
||||
)}'`,
|
||||
);
|
||||
} else if (attachedNodes.length === 0) {
|
||||
if (input.config.optional) {
|
||||
return [inputName, undefined];
|
||||
}
|
||||
throw Error(`input '${inputName}' is required but was not received`);
|
||||
}
|
||||
throw Error(`input '${inputName}' is required but was not received`);
|
||||
}
|
||||
return {
|
||||
node: attachedNodes[0],
|
||||
output: resolveV1InputDataMap(
|
||||
input.extensionData,
|
||||
attachedNodes[0],
|
||||
return [
|
||||
inputName,
|
||||
),
|
||||
};
|
||||
}
|
||||
{
|
||||
node: attachedNodes[0],
|
||||
output: resolveV1InputDataMap(
|
||||
input.extensionData,
|
||||
attachedNodes[0],
|
||||
inputName,
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return attachedNodes.map(attachment => ({
|
||||
node: attachment,
|
||||
output: resolveV1InputDataMap(input.extensionData, attachment, inputName),
|
||||
}));
|
||||
}) as {
|
||||
return [
|
||||
inputName,
|
||||
attachedNodes.map(attachment => ({
|
||||
node: attachment,
|
||||
output: resolveV1InputDataMap(
|
||||
input.extensionData,
|
||||
attachment,
|
||||
inputName,
|
||||
),
|
||||
})),
|
||||
];
|
||||
}),
|
||||
) as {
|
||||
[inputName in string]: {
|
||||
node: AppNode;
|
||||
output: {
|
||||
@@ -198,6 +250,7 @@ function resolveV2Inputs(
|
||||
>;
|
||||
},
|
||||
attachments: ReadonlyMap<string, AppNode[]>,
|
||||
parentCollector: ErrorCollector<{ node: AppNode }>,
|
||||
): ResolvedExtensionInputs<{
|
||||
[inputName in string]: ExtensionInput<
|
||||
ExtensionDataRef,
|
||||
@@ -206,32 +259,43 @@ function resolveV2Inputs(
|
||||
}> {
|
||||
return mapValues(inputMap, (input, inputName) => {
|
||||
const attachedNodes = attachments.get(inputName) ?? [];
|
||||
const collector = parentCollector.child({ 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_ATTACHMENT_CONFLICT',
|
||||
message: `expected ${
|
||||
input.config.optional ? 'at most' : 'exactly'
|
||||
} one '${inputName}' input but received multiple: '${attachedNodeIds.join(
|
||||
"', '",
|
||||
)}'`,
|
||||
);
|
||||
} one '${inputName}' input but received multiple: '${attachedNodeIds}'`,
|
||||
});
|
||||
throw INSTANTIATION_FAILED;
|
||||
} else if (attachedNodes.length === 0) {
|
||||
if (input.config.optional) {
|
||||
return undefined;
|
||||
if (!input.config.optional) {
|
||||
collector.report({
|
||||
code: 'EXTENSION_ATTACHMENT_MISSING',
|
||||
message: `input '${inputName}' is required but was not received`,
|
||||
});
|
||||
throw INSTANTIATION_FAILED;
|
||||
}
|
||||
throw Error(`input '${inputName}' is required but was not received`);
|
||||
return undefined;
|
||||
}
|
||||
return resolveInputDataContainer(
|
||||
input.extensionData,
|
||||
attachedNodes[0],
|
||||
inputName,
|
||||
collector,
|
||||
);
|
||||
}
|
||||
|
||||
return attachedNodes.map(attachment =>
|
||||
resolveInputDataContainer(input.extensionData, attachment, inputName),
|
||||
return mapWithFailures(attachedNodes, attachment =>
|
||||
resolveInputDataContainer(
|
||||
input.extensionData,
|
||||
attachment,
|
||||
inputName,
|
||||
collector,
|
||||
),
|
||||
);
|
||||
}) as ResolvedExtensionInputs<{
|
||||
[inputName in string]: ExtensionInput<
|
||||
@@ -247,8 +311,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 +325,11 @@ export function createAppNodeInstance(options: {
|
||||
[x: string]: any;
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Invalid configuration for extension '${id}'; caused by ${e}`,
|
||||
);
|
||||
collector.report({
|
||||
code: 'EXTENSION_CONFIGURATION_INVALID',
|
||||
message: `Invalid configuration for extension '${id}'; caused by ${e}`,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -297,7 +365,11 @@ export function createAppNodeInstance(options: {
|
||||
node,
|
||||
apis,
|
||||
config: parsedConfig,
|
||||
inputs: resolveV2Inputs(internalExtension.inputs, attachments),
|
||||
inputs: resolveV2Inputs(
|
||||
internalExtension.inputs,
|
||||
attachments,
|
||||
collector,
|
||||
),
|
||||
};
|
||||
const outputDataValues = options.extensionFactoryMiddleware
|
||||
? createExtensionDataContainer(
|
||||
@@ -324,21 +396,34 @@ export function createAppNodeInstance(options: {
|
||||
}
|
||||
|
||||
const outputDataMap = new Map<string, unknown>();
|
||||
for (const value of outputDataValues) {
|
||||
mapWithFailures(outputDataValues, value => {
|
||||
if (outputDataMap.has(value.id)) {
|
||||
throw new Error(`duplicate extension data output '${value.id}'`);
|
||||
collector.report({
|
||||
code: 'EXTENSION_OUTPUT_CONFLICT',
|
||||
message: `extension factory output duplicate data '${value.id}'`,
|
||||
context: {
|
||||
dataRefId: value.id,
|
||||
},
|
||||
});
|
||||
throw INSTANTIATION_FAILED;
|
||||
} else {
|
||||
outputDataMap.set(value.id, value.value);
|
||||
}
|
||||
outputDataMap.set(value.id, value.value);
|
||||
}
|
||||
});
|
||||
|
||||
for (const ref of internalExtension.output) {
|
||||
const value = outputDataMap.get(ref.id);
|
||||
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_OUTPUT_MISSING',
|
||||
message: `missing required extension data output '${ref.id}'`,
|
||||
context: {
|
||||
dataRefId: ref.id,
|
||||
},
|
||||
});
|
||||
throw INSTANTIATION_FAILED;
|
||||
}
|
||||
} else {
|
||||
extensionData.set(ref.id, value);
|
||||
@@ -347,23 +432,36 @@ export function createAppNodeInstance(options: {
|
||||
}
|
||||
|
||||
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_OUTPUT_IGNORED',
|
||||
message: `unexpected output '${dataRefId}'`,
|
||||
context: {
|
||||
dataRefId: dataRefId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`unexpected extension version '${(internalExtension as any).version}'`,
|
||||
);
|
||||
collector.report({
|
||||
code: 'EXTENSION_INVALID',
|
||||
message: `unexpected extension version '${
|
||||
(internalExtension as any).version
|
||||
}'`,
|
||||
});
|
||||
throw INSTANTIATION_FAILED;
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to instantiate extension '${id}'${
|
||||
e.name === 'Error' ? `, ${e.message}` : `; caused by ${e.stack}`
|
||||
}`,
|
||||
);
|
||||
if (e !== INSTANTIATION_FAILED) {
|
||||
collector.report({
|
||||
code: 'EXTENSION_FACTORY_ERROR',
|
||||
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,
|
||||
collector: 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,
|
||||
});
|
||||
|
||||
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_IGNORED',
|
||||
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_IGNORED',
|
||||
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: 'INVALID_EXTENSION_CONFIG_KEY',
|
||||
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_IGNORED',
|
||||
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 seenExtensionIds = new Set<string>();
|
||||
const deduplicatedExtensions = configuredExtensions.filter(
|
||||
({ extension, params }) => {
|
||||
if (seenExtensionIds.has(extension.id)) {
|
||||
collector.report({
|
||||
code: 'EXTENSION_IGNORED',
|
||||
message: `The '${extension.id}' extension from the '${params.plugin.id}' plugin is a duplicate and will be ignored`,
|
||||
context: {
|
||||
plugin: params.plugin,
|
||||
extensionId: extension.id,
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
seenExtensionIds.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: 'INVALID_EXTENSION_CONFIG_KEY',
|
||||
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: 'INVALID_EXTENSION_CONFIG_KEY',
|
||||
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,16 @@ describe('buildAppTree', () => {
|
||||
`);
|
||||
});
|
||||
|
||||
it('throws an error when duplicated extensions are detected', () => {
|
||||
expect(() =>
|
||||
resolveAppTree('app', [
|
||||
it('ignores duplicate extensions', () => {
|
||||
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']);
|
||||
});
|
||||
|
||||
describe('redirects', () => {
|
||||
@@ -253,12 +280,30 @@ 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: 'EXTENSION_INPUT_REDIRECT_CONFLICT',
|
||||
message:
|
||||
"Duplicate redirect target for input 'test' in extension 'b'",
|
||||
context: {
|
||||
node: expect.objectContaining({
|
||||
spec: { ...baseSpec, id: 'b', extension: e2 },
|
||||
}),
|
||||
inputName: 'test',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should set the correct attachment point for a redirect', () => {
|
||||
@@ -285,22 +330,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 +414,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,15 +115,16 @@ const isValidAttachmentPoint = (
|
||||
export function resolveAppTree(
|
||||
rootNodeId: string,
|
||||
specs: AppNodeSpec[],
|
||||
errorCollector: ErrorCollector,
|
||||
): AppTree {
|
||||
const nodes = new Map<string, SerializableAppNode>();
|
||||
|
||||
const redirectTargetsByKey = new Map<string, { id: string; input: string }>();
|
||||
|
||||
for (const spec of specs) {
|
||||
// The main check with a more helpful error message happens in resolveAppNodeSpecs
|
||||
// The main check with a helpful error message happens in resolveAppNodeSpecs
|
||||
if (nodes.has(spec.id)) {
|
||||
throw new Error(`Unexpected duplicate extension id '${spec.id}'`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const node = new SerializableAppNode(spec);
|
||||
@@ -134,9 +136,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: 'EXTENSION_INPUT_REDIRECT_CONFLICT',
|
||||
message: `Duplicate redirect target for input '${inputName}' in extension '${spec.id}'`,
|
||||
context: {
|
||||
node,
|
||||
inputName,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
redirectTargetsByKey.set(key, { id: spec.id, input: inputName });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* 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, FrontendPlugin } from '@backstage/frontend-plugin-api';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type AppErrorTypes = {
|
||||
// resolveAppNodeSpecs
|
||||
EXTENSION_IGNORED: {
|
||||
context: { plugin: FrontendPlugin; extensionId: string };
|
||||
};
|
||||
INVALID_EXTENSION_CONFIG_KEY: {
|
||||
context: { extensionId: string };
|
||||
};
|
||||
// resolveAppTree
|
||||
EXTENSION_INPUT_REDIRECT_CONFLICT: {
|
||||
context: { node: AppNode; inputName: string };
|
||||
};
|
||||
// instantiateAppNodeTree
|
||||
EXTENSION_INPUT_DATA_IGNORED: {
|
||||
context: { node: AppNode; inputName: string };
|
||||
};
|
||||
EXTENSION_INPUT_DATA_MISSING: {
|
||||
context: { node: AppNode; inputName: string };
|
||||
};
|
||||
EXTENSION_ATTACHMENT_CONFLICT: {
|
||||
context: { node: AppNode; inputName: string };
|
||||
};
|
||||
EXTENSION_ATTACHMENT_MISSING: {
|
||||
context: { node: AppNode; inputName: string };
|
||||
};
|
||||
EXTENSION_CONFIGURATION_INVALID: {
|
||||
context: { node: AppNode };
|
||||
};
|
||||
EXTENSION_INVALID: {
|
||||
context: { node: AppNode };
|
||||
};
|
||||
EXTENSION_OUTPUT_CONFLICT: {
|
||||
context: { node: AppNode; dataRefId: string };
|
||||
};
|
||||
EXTENSION_OUTPUT_MISSING: {
|
||||
context: { node: AppNode; dataRefId: string };
|
||||
};
|
||||
EXTENSION_OUTPUT_IGNORED: {
|
||||
context: { node: AppNode; dataRefId: string };
|
||||
};
|
||||
EXTENSION_FACTORY_ERROR: {
|
||||
context: { node: AppNode };
|
||||
};
|
||||
// createSpecializedApp
|
||||
API_EXTENSION_INVALID: {
|
||||
context: { node: AppNode };
|
||||
};
|
||||
// routing
|
||||
ROUTE_DUPLICATE: {
|
||||
context: { routeId: string };
|
||||
};
|
||||
ROUTE_BINDING_INVALID_VALUE: {
|
||||
context: { routeId: string };
|
||||
};
|
||||
ROUTE_NOT_FOUND: {
|
||||
context: { routeId: string };
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type AppError =
|
||||
keyof AppErrorTypes extends infer ICode extends keyof AppErrorTypes
|
||||
? ICode extends any
|
||||
? {
|
||||
code: ICode;
|
||||
message: string;
|
||||
context: AppErrorTypes[ICode]['context'];
|
||||
}
|
||||
: never
|
||||
: never;
|
||||
|
||||
/** @internal */
|
||||
export interface ErrorCollector<TContext extends {} = {}> {
|
||||
report<TCode extends keyof AppErrorTypes>(
|
||||
report: Omit<
|
||||
AppErrorTypes[TCode]['context'],
|
||||
keyof TContext
|
||||
> extends infer IContext extends {}
|
||||
? {} extends IContext
|
||||
? {
|
||||
code: TCode;
|
||||
message: string;
|
||||
}
|
||||
: {
|
||||
code: TCode;
|
||||
message: string;
|
||||
context: IContext;
|
||||
}
|
||||
: never,
|
||||
): void;
|
||||
child<TAdditionalContext extends {}>(
|
||||
context: TAdditionalContext,
|
||||
): ErrorCollector<TContext & TAdditionalContext>;
|
||||
collectErrors(): AppError[] | undefined;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function createErrorCollector(
|
||||
context?: Partial<AppError['context']>,
|
||||
): ErrorCollector {
|
||||
const errors: AppError[] = [];
|
||||
const children: ErrorCollector[] = [];
|
||||
return {
|
||||
report(report: { code: string; message: string; context?: {} }) {
|
||||
errors.push({
|
||||
...report,
|
||||
context: { ...context, ...report.context },
|
||||
} as AppError);
|
||||
},
|
||||
collectErrors() {
|
||||
const allErrors = [
|
||||
...errors,
|
||||
...children.flatMap(child => child.collectErrors() ?? []),
|
||||
];
|
||||
errors.length = 0;
|
||||
if (allErrors.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return allErrors;
|
||||
},
|
||||
child(childContext) {
|
||||
const child = createErrorCollector({ ...context, ...childContext });
|
||||
children.push(child);
|
||||
return child as ErrorCollector<any>;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,18 +307,18 @@ 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);
|
||||
|
||||
const routeRefsById = collectRouteIds(features);
|
||||
const routeRefsById = collectRouteIds(features, collector);
|
||||
const routeResolutionApi = new RouteResolutionApiProxy(
|
||||
resolveRouteBindings(options?.bindRoutes, config, routeRefsById),
|
||||
resolveRouteBindings(options?.bindRoutes, config, routeRefsById, collector),
|
||||
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: 'API_EXTENSION_INVALID',
|
||||
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, type AppErrorTypes } from './createErrorCollector';
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { AppError } from '@backstage/frontend-app-api';
|
||||
import { AppErrorTypes } from '@backstage/frontend-app-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { ConfigApi } from '@backstage/frontend-plugin-api';
|
||||
import { CreateAppRouteBinder } from '@backstage/frontend-app-api';
|
||||
@@ -45,6 +47,16 @@ export function discoverAvailableFeatures(config: Config): {
|
||||
features: (FrontendFeature | FrontendFeatureLoader)[];
|
||||
};
|
||||
|
||||
// @public
|
||||
export function maybeCreateErrorPage(
|
||||
app: {
|
||||
errors?: AppError[];
|
||||
},
|
||||
options?: {
|
||||
warningCodes?: Array<keyof AppErrorTypes>;
|
||||
},
|
||||
): JSX_2.Element | undefined;
|
||||
|
||||
// @public (undocumented)
|
||||
export function resolveAsyncFeatures(options: {
|
||||
config: Config;
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import appPlugin from '@backstage/plugin-app';
|
||||
import { discoverAvailableFeatures } from './discovery';
|
||||
import { resolveAsyncFeatures } from './resolution';
|
||||
import { maybeCreateErrorPage } from './maybeCreateErrorPage';
|
||||
|
||||
/**
|
||||
* Options for {@link createApp}.
|
||||
@@ -136,6 +137,11 @@ export function createApp(options?: CreateAppOptions): {
|
||||
advanced: options?.advanced,
|
||||
});
|
||||
|
||||
const errorPage = maybeCreateErrorPage(app);
|
||||
if (errorPage) {
|
||||
return { default: () => errorPage };
|
||||
}
|
||||
|
||||
const rootEl = app.tree.root.instance!.getData(
|
||||
coreExtensionData.reactElement,
|
||||
);
|
||||
|
||||
@@ -24,3 +24,4 @@ export { createApp, type CreateAppOptions } from './createApp';
|
||||
export { createPublicSignInApp } from './createPublicSignInApp';
|
||||
export { discoverAvailableFeatures } from './discovery';
|
||||
export { resolveAsyncFeatures } from './resolution';
|
||||
export { maybeCreateErrorPage } from './maybeCreateErrorPage';
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright 2024 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 { FrontendPluginInfo } from '@backstage/frontend-plugin-api';
|
||||
import { JSX, useEffect, useState } from 'react';
|
||||
import { AppError, AppErrorTypes } from '@backstage/frontend-app-api';
|
||||
|
||||
const DEFAULT_WARNING_CODES: Array<keyof AppErrorTypes> = [
|
||||
'EXTENSION_IGNORED',
|
||||
'INVALID_EXTENSION_CONFIG_KEY',
|
||||
'EXTENSION_INPUT_DATA_IGNORED',
|
||||
'EXTENSION_OUTPUT_IGNORED',
|
||||
];
|
||||
|
||||
function AppErrorItem(props: { error: AppError }): JSX.Element {
|
||||
const { context } = props.error;
|
||||
|
||||
const node = 'node' in context ? context.node : undefined;
|
||||
const extensionId =
|
||||
'extensionId' in context ? context.extensionId : node?.spec.id;
|
||||
const routeId = 'routeId' in context ? context.routeId : undefined;
|
||||
const plugin = 'plugin' in context ? context.plugin : node?.spec.plugin;
|
||||
const pluginId = plugin?.id ?? 'N/A';
|
||||
|
||||
const [info, setInfo] = useState<FrontendPluginInfo | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
plugin?.info().then(setInfo, error => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to load info for plugin ${plugin.id}: ${error}`);
|
||||
});
|
||||
}, [plugin]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<b>{props.error.code}</b>: {props.error.message}
|
||||
<pre style={{ marginLeft: '1rem' }}>
|
||||
{extensionId && <div>extensionId: {extensionId}</div>}
|
||||
{routeId && <div>routeId: {routeId}</div>}
|
||||
{pluginId && <div>pluginId: {pluginId}</div>}
|
||||
{info && (
|
||||
<div>
|
||||
package: {info.packageName}@{info.version}
|
||||
</div>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppErrorPage(props: { errors: AppError[] }): JSX.Element {
|
||||
return (
|
||||
<div style={{ margin: '1rem' }}>
|
||||
<h2>App startup failed</h2>
|
||||
{props.errors.map((error, index) => (
|
||||
<AppErrorItem error={error} key={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If there are any unrecoverable errors in the app, this will return an error page in the form of a JSX element.
|
||||
*
|
||||
* If there are any recoverable errors, they will always be logged as warnings in the console.
|
||||
* @public
|
||||
*/
|
||||
export function maybeCreateErrorPage(
|
||||
app: { errors?: AppError[] },
|
||||
options?: {
|
||||
warningCodes?: Array<keyof AppErrorTypes>;
|
||||
},
|
||||
): JSX.Element | undefined {
|
||||
if (!app.errors) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const errors = new Array<AppError>();
|
||||
const warnings = new Array<AppError>();
|
||||
|
||||
const warningCodes = new Set(options?.warningCodes ?? DEFAULT_WARNING_CODES);
|
||||
for (const error of app.errors) {
|
||||
if (warningCodes.has(error.code)) {
|
||||
warnings.push(error);
|
||||
} else {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('App startup encountered warnings:');
|
||||
for (const warning of warnings) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`${warning.code}: ${warning.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return <AppErrorPage errors={errors} />;
|
||||
}
|
||||
@@ -1078,7 +1078,7 @@ describe('createExtension', () => {
|
||||
.add(multi2Ext)
|
||||
.get(outputRef),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Failed to instantiate extension 'subject', override data provided for input 'multi' must match the length of the original inputs"`,
|
||||
`"Failed to resolve the extension tree: Failed to instantiate extension 'subject', override data provided for input 'multi' must match the length of the original inputs"`,
|
||||
);
|
||||
|
||||
// Mix forward and data override
|
||||
@@ -1101,7 +1101,7 @@ describe('createExtension', () => {
|
||||
.add(multi2Ext)
|
||||
.get(outputRef),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Failed to instantiate extension 'subject', override data for input 'multi' may not mix forwarded inputs with data overrides"`,
|
||||
`"Failed to resolve the extension tree: Failed to instantiate extension 'subject', override data for input 'multi' may not mix forwarded inputs with data overrides"`,
|
||||
);
|
||||
|
||||
// Required input not provided
|
||||
@@ -1124,7 +1124,7 @@ describe('createExtension', () => {
|
||||
.add(multi2Ext)
|
||||
.get(outputRef),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Failed to instantiate extension 'subject', missing required extension data value(s) 'test1'"`,
|
||||
`"Failed to resolve the extension tree: Failed to instantiate extension 'subject', missing required extension data value(s) 'test1'"`,
|
||||
);
|
||||
|
||||
// Wrong value provided
|
||||
@@ -1153,7 +1153,7 @@ describe('createExtension', () => {
|
||||
.add(multi2Ext)
|
||||
.get(outputRef),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Failed to instantiate extension 'subject', extension data 'test2' was provided but not declared"`,
|
||||
`"Failed to resolve the extension tree: Failed to instantiate extension 'subject', extension data 'test2' was provided but not declared"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,8 @@ import { resolveAppNodeSpecs } from '../../../frontend-app-api/src/tree/resolveA
|
||||
import { instantiateAppNodeTree } from '../../../frontend-app-api/src/tree/instantiateAppNodeTree';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { readAppExtensionsConfig } from '../../../frontend-app-api/src/tree/readAppExtensionsConfig';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { createErrorCollector } from '../../../frontend-app-api/src/wiring/createErrorCollector';
|
||||
import { TestApiRegistry } from '@backstage/test-utils';
|
||||
import { OpaqueExtensionDefinition } from '@internal/frontend';
|
||||
|
||||
@@ -191,16 +193,29 @@ export class ExtensionTester<UOutput extends ExtensionDataRef> {
|
||||
);
|
||||
}
|
||||
|
||||
const collector = createErrorCollector();
|
||||
|
||||
const tree = resolveAppTree(
|
||||
subject.id,
|
||||
resolveAppNodeSpecs({
|
||||
features: [],
|
||||
builtinExtensions: this.#extensions.map(_ => _.extension),
|
||||
parameters: readAppExtensionsConfig(this.#getConfig()),
|
||||
collector,
|
||||
}),
|
||||
collector,
|
||||
);
|
||||
|
||||
instantiateAppNodeTree(tree.root, TestApiRegistry.from());
|
||||
instantiateAppNodeTree(tree.root, TestApiRegistry.from(), collector);
|
||||
|
||||
const errors = collector.collectErrors();
|
||||
if (errors) {
|
||||
throw new Error(
|
||||
`Failed to resolve the extension tree: ${errors
|
||||
.map(e => e.message)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.#tree = tree;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user