frontend-*-api: better error when factories return invalid data

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-08-07 00:18:20 +02:00
parent 85af229b00
commit 1c2cc37a70
9 changed files with 159 additions and 1 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/frontend-plugin-api': patch
'@backstage/frontend-app-api': patch
---
Improved runtime error message clarity when extension factories don't return an iterable object.
@@ -18,10 +18,13 @@ import {
AppNode,
Extension,
ExtensionDataRef,
ExtensionDefinition,
ExtensionFactoryMiddleware,
ExtensionInput,
PortableSchema,
ResolvedExtensionInput,
createExtension,
createExtensionBlueprint,
createExtensionDataRef,
createExtensionInput,
} from '@backstage/frontend-plugin-api';
@@ -957,6 +960,127 @@ describe('instantiateAppNodeTree', () => {
);
});
it('should throw if extension factories do not provide an iterable object', () => {
function createInstance(
extension: ExtensionDefinition,
middleware?: ExtensionFactoryMiddleware,
) {
return createAppNodeInstance({
extensionFactoryMiddleware: middleware,
apis: testApis,
node: makeNode(
resolveExtensionDefinition(extension, { namespace: 'test' }),
),
attachments: new Map(),
});
}
const baseOpts = {
attachTo: { id: 'ignored', input: 'ignored' },
output: [testDataRef],
};
const badFactory = () => 'not-iterable' as any;
const goodFactory = () => [testDataRef('test')];
expect(() =>
createInstance(
createExtension({
attachTo: { id: 'ignored', input: 'ignored' },
output: [testDataRef],
factory: badFactory,
}),
),
).toThrow(
`Failed to instantiate extension 'test', extension factory did not provide an iterable object`,
);
expect(() =>
createInstance(
createExtension({
...baseOpts,
factory: goodFactory,
}).override({
factory: badFactory,
}),
),
).toThrow(
`Failed to instantiate extension 'test', extension factory override did not provide an iterable object`,
);
// Bad middleware
expect(() =>
createInstance(
createExtension({
...baseOpts,
factory: goodFactory,
}),
() => 'not-iterable' as any,
),
).toThrow(
`Failed to instantiate extension 'test', extension factory middleware did not provide an iterable object`,
);
expect(() =>
createInstance(
createExtensionBlueprint({
kind: 'test',
...baseOpts,
factory: badFactory,
}).make({ params: {} }),
),
).toThrow(
`Failed to instantiate extension 'test:test', extension factory did not provide an iterable object`,
);
// Using makeWithOverrides
expect(() =>
createInstance(
createExtensionBlueprint({
kind: 'test',
...baseOpts,
factory: goodFactory,
}).makeWithOverrides({
factory: badFactory,
}),
),
).toThrow(
`Failed to instantiate extension 'test:test', extension factory did not provide an iterable object`,
);
// Using makeWithOverrides and factory middleware
expect(() =>
createInstance(
createExtensionBlueprint({
kind: 'test',
...baseOpts,
factory: goodFactory,
}).makeWithOverrides({
factory: badFactory,
}),
orig => orig(),
),
).toThrow(
`Failed to instantiate extension 'test:test', extension factory did not provide an iterable object`,
);
// Using makeWithOverrides and factory middleware
expect(() =>
createInstance(
createExtensionBlueprint({
kind: 'test',
...baseOpts,
factory: badFactory,
}).makeWithOverrides({
factory: orig => orig({ params: {} }),
}),
orig => orig(),
),
).toThrow(
`Failed to instantiate extension 'test:test', original blueprint factory did not provide an iterable object`,
);
});
it('should forward extension factory errors', () => {
expect(() =>
createAppNodeInstance({
@@ -309,11 +309,20 @@ export function createAppNodeInstance(options: {
inputs: context.inputs,
config: overrideContext?.config ?? context.config,
}),
'extension factory',
);
}, context),
'extension factory middleware',
)
: internalExtension.factory(context);
if (
typeof outputDataValues !== 'object' ||
!outputDataValues?.[Symbol.iterator]
) {
throw new Error('extension factory did not provide an iterable object');
}
const outputDataMap = new Map<string, unknown>();
for (const value of outputDataValues) {
if (outputDataMap.has(value.id)) {
@@ -373,6 +373,7 @@ function mergeExtensionFactoryMiddleware(
apis: ctx.apis,
config: ctxOverrides?.config ?? ctx.config,
}),
'extension factory middleware',
);
}, ctx);
};
@@ -26,8 +26,13 @@ export function createExtensionDataContainer<UData extends ExtensionDataRef>(
? ExtensionDataValue<IData, IId>
: never
>,
contextName: string,
declaredRefs?: ExtensionDataRef<any, any, any>[],
): ExtensionDataContainer<UData> {
if (typeof values !== 'object' || !values?.[Symbol.iterator]) {
throw new Error(`${contextName} did not provide an iterable object`);
}
const container = new Map<string, ExtensionDataValue<any, any>>();
const verifyRefs =
declaredRefs && new Map(declaredRefs.map(ref => [ref.id, ref]));
@@ -448,6 +448,7 @@ export function createExtension<
) as any,
[ctxParamsSymbol as any]: innerContext?.params,
}) as Iterable<any>,
'original extension factory',
options.output,
);
},
@@ -459,6 +460,15 @@ export function createExtension<
},
);
if (
typeof parentResult !== 'object' ||
!parentResult?.[Symbol.iterator]
) {
throw new Error(
'extension factory override did not provide an iterable object',
);
}
const deduplicatedResult = new Map<
string,
ExtensionDataValue<any, any>
@@ -391,7 +391,7 @@ describe('createExtensionBlueprint', () => {
});
const mockInput = (node: string, ...data: ExtensionDataValue<any, any>[]) =>
Object.assign(createExtensionDataContainer(data), {
Object.assign(createExtensionDataContainer(data, 'mock'), {
node,
});
const mockParentInputs = {
@@ -521,6 +521,7 @@ export function createExtensionBlueprint<
) as any,
},
) as Iterable<any>,
'original blueprint factory',
options.output,
);
},
@@ -119,6 +119,7 @@ export function resolveInputOverrides(
if (providedData) {
const providedContainer = createExtensionDataContainer(
providedData as Iterable<ExtensionDataValue<any, any>>,
'extension input override',
declaredInput.extensionData,
);
if (!originalInput) {
@@ -157,6 +158,7 @@ export function resolveInputOverrides(
newInputs[name] = providedData.map((data, i) => {
const providedContainer = createExtensionDataContainer(
data as Iterable<ExtensionDataValue<any, any>>,
'extension input override',
declaredInput.extensionData,
);
return Object.assign(providedContainer, {