diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts index a33c5943a1..cbf24a98fa 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts @@ -64,7 +64,7 @@ function resolveInputDataContainer( extensionData: Array, attachment: AppNode, inputName: string, - collector: ErrorCollector, + collector: ErrorCollector<'node' | 'inputName'>, ): ({ node: AppNode } & ExtensionDataContainer) | undefined { const dataMap = new Map(); @@ -211,7 +211,7 @@ function resolveV2Inputs( >; }, attachments: ReadonlyMap, - collector: ErrorCollector, + parentCollector: ErrorCollector<'node'>, ): | undefined | ResolvedExtensionInputs<{ @@ -223,6 +223,7 @@ function resolveV2Inputs( let failed = false; const resolvedInputs = mapValues(inputMap, (input, inputName) => { const attachedNodes = attachments.get(inputName) ?? []; + const collector = parentCollector.child({ inputName }); if (input.config.singleton) { if (attachedNodes.length > 1) { @@ -232,7 +233,6 @@ function resolveV2Inputs( message: `expected ${ input.config.optional ? 'at most' : 'exactly' } one '${inputName}' input but received multiple: '${attachedNodeIds}'`, - context: { inputName }, }); failed = true; return undefined; @@ -241,7 +241,6 @@ function resolveV2Inputs( collector.report({ code: 'EXTENSION_MISSING_REQUIRED_INPUT', message: `input '${inputName}' is required but was not received`, - context: { inputName }, }); failed = true; } @@ -481,7 +480,7 @@ export function createAppNodeInstance(options: { export function instantiateAppNodeTree( rootNode: AppNode, apis: ApiHolder, - errors: ErrorCollector, + collector: ErrorCollector, extensionFactoryMiddleware?: ExtensionFactoryMiddleware, ): boolean { function createInstance(node: AppNode): AppNodeInstance | undefined { @@ -512,7 +511,7 @@ export function instantiateAppNodeTree( node, apis, attachments: instantiatedAttachments, - collector: errors, + collector, }); return node.instance; diff --git a/packages/frontend-app-api/src/wiring/createErrorCollector.ts b/packages/frontend-app-api/src/wiring/createErrorCollector.ts index 65ef91ccfc..494e6c2d85 100644 --- a/packages/frontend-app-api/src/wiring/createErrorCollector.ts +++ b/packages/frontend-app-api/src/wiring/createErrorCollector.ts @@ -19,34 +19,96 @@ import { AppNodeSpec, FrontendPlugin, } from '@backstage/frontend-plugin-api'; +import { Expand } from '@backstage/types'; -/** @public */ -export type AppError = { - code: string; - message: string; - context?: { - node?: AppNode; - spec?: AppNodeSpec; - plugin?: FrontendPlugin; - extensionId?: string; - inputName?: string; - dataRefId?: string; - }; +type AppErrorTypes = { + // resolveAppNodeSpecs + EXTENSION_FORBIDDEN: 'plugin' | 'extensionId'; + EXTENSION_DUPLICATED: 'plugin' | 'extensionId'; + EXTENSION_CONFIG_FORBIDDEN: 'extensionId'; + EXTENSION_CONFIG_UNKNOWN_EXTENSION: 'extensionId'; + // resolveAppTree + DUPLICATE_EXTENSION_ID: 'spec'; + DUPLICATE_REDIRECT_TARGET: 'spec' | 'inputName'; + // instantiateAppNodeTree + EXTENSION_DUPLICATE_INPUT: 'node' | 'inputName'; + EXTENSION_MISSING_INPUT_DATA: 'node' | 'inputName'; + EXTENSION_TOO_MANY_ATTACHMENTS: 'node' | 'inputName'; + EXTENSION_MISSING_REQUIRED_INPUT: 'node' | 'inputName'; + INVALID_CONFIGURATION: 'node'; + EXTENSION_FACTORY_INVALID_OUTPUT: 'node'; + EXTENSION_FACTORY_DUPLICATE_OUTPUT: 'node' | 'dataRefId'; + EXTENSION_FACTORY_MISSING_REQUIRED_OUTPUT: 'node' | 'dataRefId'; + EXTENSION_FACTORY_UNEXPECTED_OUTPUT: 'node' | 'dataRefId'; + UNEXPECTED_EXTENSION_VERSION: 'node'; + FAILED_TO_INSTANTIATE_EXTENSION: 'node'; + // createSpecializedApp + NO_API_FACTORY: 'node'; }; +type AppErrorContext = { + node?: AppNode; + spec?: AppNodeSpec; + plugin?: FrontendPlugin; + extensionId?: string; + dataRefId?: string; + inputName?: string; +}; + +/** @public */ +export type AppError = + { + code: TCode; + message: string; + context: Expand< + AppErrorContext & + Pick< + Required, + keyof AppErrorTypes extends TCode ? never : AppErrorTypes[TCode] + > + >; + }; + /** @internal */ -export interface ErrorCollector { - report(report: AppError): void; - child(context?: AppError['context']): ErrorCollector; +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 }; + report( + report: Exclude< + AppErrorTypes[TCode], + TContext + > extends infer IContext extends keyof AppErrorContext + ? [IContext] extends [never] + ? { + code: TCode; + message: string; + } + : { + code: TCode; + message: string; + context: Pick, IContext>; + } + : never, + ): void; + child( + context?: TAdditionalContext & AppErrorContext, + ): ErrorCollector< + (TContext | keyof TAdditionalContext) & keyof AppErrorContext + >; collectErrors(): AppError[] | undefined; } /** @internal */ -export function createErrorCollector(context?: AppError['context']) { +export function createErrorCollector( + context?: AppError['context'], +): ErrorCollector { const errors: AppError[] = []; const children: ErrorCollector[] = []; return { - report(report: AppError) { + $$contextKeys: null as any, + report(report) { errors.push({ ...report, context: { ...context, ...report.context } }); }, collectErrors() { @@ -60,10 +122,10 @@ export function createErrorCollector(context?: AppError['context']) { } return allErrors; }, - child(childContext: AppError['context']) { + child(childContext) { const child = createErrorCollector(childContext); children.push(child); - return child; + return child as ErrorCollector; }, }; }