frontend-app-api: rework app error types

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-09-07 13:41:16 +02:00
parent 018de42a01
commit 02fced51db
5 changed files with 174 additions and 76 deletions
+91 -11
View File
@@ -5,7 +5,6 @@
```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';
@@ -18,16 +17,97 @@ 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;
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;
};
};
};
@@ -1098,7 +1098,7 @@ describe('instantiateAppNodeTree', () => {
});
const errors = collector.collectErrors();
for (const error of errors ?? []) {
expect(error.context?.node).toBe(node);
expect('node' in error.context && error.context.node).toBe(node);
}
return errors;
}
@@ -95,7 +95,7 @@ function resolveInputDataContainer(
extensionData: Array<ExtensionDataRef>,
attachment: AppNode,
inputName: string,
collector: ErrorCollector<'node' | 'inputName'>,
collector: ErrorCollector<{ node: AppNode; inputName: string }>,
): { node: AppNode } & ExtensionDataContainer<ExtensionDataRef> {
const dataMap = new Map<string, unknown>();
@@ -250,7 +250,7 @@ function resolveV2Inputs(
>;
},
attachments: ReadonlyMap<string, AppNode[]>,
parentCollector: ErrorCollector<'node'>,
parentCollector: ErrorCollector<{ node: AppNode }>,
): ResolvedExtensionInputs<{
[inputName in string]: ExtensionInput<
ExtensionDataRef,
@@ -15,63 +15,81 @@
*/
import { AppNode, FrontendPlugin } from '@backstage/frontend-plugin-api';
import { Expand } from '@backstage/types';
type AppErrorTypes = {
/**
* @public
*/
export type AppErrorTypes = {
// resolveAppNodeSpecs
EXTENSION_IGNORED: 'plugin' | 'extensionId';
INVALID_EXTENSION_CONFIG_KEY: 'extensionId';
// resolveAppTree
EXTENSION_INPUT_REDIRECT_CONFLICT: 'node' | 'inputName';
// instantiateAppNodeTree
EXTENSION_INPUT_DATA_IGNORED: 'node' | 'inputName';
EXTENSION_INPUT_DATA_MISSING: 'node' | 'inputName';
EXTENSION_ATTACHMENT_CONFLICT: 'node' | 'inputName';
EXTENSION_ATTACHMENT_MISSING: 'node' | 'inputName';
EXTENSION_CONFIGURATION_INVALID: 'node';
EXTENSION_INVALID: 'node';
EXTENSION_OUTPUT_CONFLICT: 'node' | 'dataRefId';
EXTENSION_OUTPUT_MISSING: 'node' | 'dataRefId';
EXTENSION_OUTPUT_IGNORED: 'node' | 'dataRefId';
EXTENSION_FACTORY_ERROR: 'node';
// createSpecializedApp
API_EXTENSION_INVALID: 'node';
};
type AppErrorContext = {
node?: AppNode;
plugin?: FrontendPlugin;
extensionId?: string;
dataRefId?: string;
inputName?: string;
};
/** @public */
export type AppError<TCode extends keyof AppErrorTypes = keyof AppErrorTypes> =
{
code: TCode;
message: string;
context: Expand<
AppErrorContext &
Pick<
Required<AppErrorContext>,
keyof AppErrorTypes extends TCode ? never : AppErrorTypes[TCode]
>
>;
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 };
};
};
/**
* @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 keyof AppErrorContext = never,
> {
// Type-only: here to make sure that all required keys are present
$$contextKeys: { [K in TContext]: K };
export interface ErrorCollector<TContext extends {} = {}> {
report<TCode extends keyof AppErrorTypes>(
report: Exclude<
AppErrorTypes[TCode],
TContext
> extends infer IContext extends keyof AppErrorContext
? [IContext] extends [never]
report: Omit<
AppErrorTypes[TCode]['context'],
keyof TContext
> extends infer IContext extends {}
? [{}] extends [IContext]
? {
code: TCode;
message: string;
@@ -79,28 +97,28 @@ export interface ErrorCollector<
: {
code: TCode;
message: string;
context: Pick<Required<AppErrorContext>, IContext>;
context: IContext;
}
: never,
): void;
child<TAdditionalContext extends AppErrorContext>(
context?: TAdditionalContext & AppErrorContext,
): ErrorCollector<
(TContext | keyof TAdditionalContext) & keyof AppErrorContext
>;
child<TAdditionalContext extends {}>(
context: TAdditionalContext,
): ErrorCollector<TContext & TAdditionalContext>;
collectErrors(): AppError[] | undefined;
}
/** @internal */
export function createErrorCollector(
context?: AppError['context'],
context?: Partial<AppError['context']>,
): ErrorCollector {
const errors: AppError[] = [];
const children: ErrorCollector[] = [];
return {
$$contextKeys: null as any,
report(report) {
errors.push({ ...report, context: { ...context, ...report.context } });
report(report: { code: string; message: string; context?: {} }) {
errors.push({
...report,
context: { ...context, ...report.context },
} as AppError);
},
collectErrors() {
const allErrors = [
@@ -19,4 +19,4 @@ export {
type CreateSpecializedAppOptions,
} from './createSpecializedApp';
export { type FrontendPluginInfoResolver } from './createPluginInfoAttacher';
export { type AppError } from './createErrorCollector';
export { type AppError, type AppErrorTypes } from './createErrorCollector';