frontend-app-api: refine app error types

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-09-07 11:45:40 +02:00
parent 74ea3e3a86
commit d27c032776
8 changed files with 74 additions and 95 deletions
@@ -411,7 +411,7 @@ describe('instantiateAppNodeTree', () => {
).toBeUndefined();
expect(collector.collectErrors()).toEqual([
{
code: 'INVALID_CONFIGURATION',
code: 'EXTENSION_CONFIGURATION_INVALID',
message:
"Invalid configuration for extension 'app/test'; caused by Error: Expected number, received string at 'other'",
context: { node },
@@ -443,7 +443,7 @@ describe('instantiateAppNodeTree', () => {
).toBeUndefined();
expect(collector.collectErrors()).toEqual([
{
code: 'FAILED_TO_INSTANTIATE_EXTENSION',
code: 'EXTENSION_FACTORY_ERROR',
message:
"Failed to instantiate extension 'app/test'; caused by NopeError: NOPE",
context: { node },
@@ -476,7 +476,7 @@ describe('instantiateAppNodeTree', () => {
).toBeUndefined();
expect(collector.collectErrors()).toEqual([
{
code: 'FAILED_TO_INSTANTIATE_EXTENSION',
code: 'EXTENSION_FACTORY_ERROR',
message:
"Failed to instantiate extension 'app/test', duplicate extension data 'test' received via output 'test2'",
context: { node },
@@ -508,7 +508,7 @@ describe('instantiateAppNodeTree', () => {
).toBeUndefined();
expect(collector.collectErrors()).toEqual([
{
code: 'FAILED_TO_INSTANTIATE_EXTENSION',
code: 'EXTENSION_FACTORY_ERROR',
message:
"Failed to instantiate extension 'app/test', unknown output provided via 'nonexistent'",
context: { node },
@@ -544,7 +544,7 @@ describe('instantiateAppNodeTree', () => {
).toBeUndefined();
expect(collector.collectErrors()).toEqual([
{
code: 'FAILED_TO_INSTANTIATE_EXTENSION',
code: 'EXTENSION_FACTORY_ERROR',
message:
"Failed to instantiate extension 'app/test', input 'singleton' is required but was not received",
context: { node },
@@ -669,7 +669,7 @@ describe('instantiateAppNodeTree', () => {
).toBeUndefined();
expect(collector.collectErrors()).toEqual([
{
code: 'FAILED_TO_INSTANTIATE_EXTENSION',
code: 'EXTENSION_FACTORY_ERROR',
message:
"Failed to instantiate extension 'app/test', expected exactly one 'singleton' input but received multiple: 'app/test', 'app/test'",
context: { node },
@@ -713,7 +713,7 @@ describe('instantiateAppNodeTree', () => {
).toBeUndefined();
expect(collector.collectErrors()).toEqual([
{
code: 'FAILED_TO_INSTANTIATE_EXTENSION',
code: 'EXTENSION_FACTORY_ERROR',
message:
"Failed to instantiate extension 'app/test', expected at most one 'singleton' input but received multiple: 'app/test', 'app/test'",
context: { node },
@@ -751,7 +751,7 @@ describe('instantiateAppNodeTree', () => {
).toBeUndefined();
expect(collector.collectErrors()).toEqual([
{
code: 'FAILED_TO_INSTANTIATE_EXTENSION',
code: 'EXTENSION_FACTORY_ERROR',
message:
"Failed to instantiate extension 'app/test', extension 'app/test' could not be attached because its output data ('test', 'other') does not match what the input 'singleton' requires ('other')",
context: { node },
@@ -1073,7 +1073,7 @@ describe('instantiateAppNodeTree', () => {
).toBeUndefined();
expect(collector.collectErrors()).toEqual([
{
code: 'INVALID_CONFIGURATION',
code: 'EXTENSION_CONFIGURATION_INVALID',
message:
"Invalid configuration for extension 'app/test'; caused by Error: Expected number, received string at 'other'",
context: { node },
@@ -1121,7 +1121,7 @@ describe('instantiateAppNodeTree', () => {
),
).toEqual([
{
code: 'EXTENSION_FACTORY_INVALID_OUTPUT',
code: 'EXTENSION_INVALID',
message: 'extension factory did not provide an iterable object',
context: { node: expect.anything() },
},
@@ -1138,8 +1138,8 @@ describe('instantiateAppNodeTree', () => {
),
).toEqual([
{
// TODO: This should probably be EXTENSION_FACTORY_INVALID_OUTPUT
code: 'FAILED_TO_INSTANTIATE_EXTENSION',
// TODO: This should probably be EXTENSION_INVALID
code: 'EXTENSION_FACTORY_ERROR',
message:
"Failed to instantiate extension 'test', extension factory override did not provide an iterable object",
context: { node: expect.anything() },
@@ -1157,8 +1157,8 @@ describe('instantiateAppNodeTree', () => {
),
).toEqual([
{
// TODO: This should probably be EXTENSION_FACTORY_INVALID_OUTPUT
code: 'FAILED_TO_INSTANTIATE_EXTENSION',
// TODO: This should probably be EXTENSION_INVALID
code: 'EXTENSION_FACTORY_ERROR',
message:
"Failed to instantiate extension 'test', extension factory middleware did not provide an iterable object",
context: { node: expect.anything() },
@@ -1175,7 +1175,7 @@ describe('instantiateAppNodeTree', () => {
),
).toEqual([
{
code: 'EXTENSION_FACTORY_INVALID_OUTPUT',
code: 'EXTENSION_INVALID',
message: 'extension factory did not provide an iterable object',
context: { node: expect.anything() },
},
@@ -1194,7 +1194,7 @@ describe('instantiateAppNodeTree', () => {
),
).toEqual([
{
code: 'EXTENSION_FACTORY_INVALID_OUTPUT',
code: 'EXTENSION_INVALID',
message: 'extension factory did not provide an iterable object',
context: { node: expect.anything() },
},
@@ -1214,8 +1214,8 @@ describe('instantiateAppNodeTree', () => {
),
).toEqual([
{
// TODO: This should probably be EXTENSION_FACTORY_INVALID_OUTPUT
code: 'FAILED_TO_INSTANTIATE_EXTENSION',
// TODO: This should probably be EXTENSION_INVALID
code: 'EXTENSION_FACTORY_ERROR',
message:
"Failed to instantiate extension 'test:test', extension factory did not provide an iterable object",
context: { node: expect.anything() },
@@ -1236,8 +1236,8 @@ describe('instantiateAppNodeTree', () => {
),
).toEqual([
{
// TODO: This should probably be EXTENSION_FACTORY_INVALID_OUTPUT
code: 'FAILED_TO_INSTANTIATE_EXTENSION',
// TODO: This should probably be EXTENSION_INVALID
code: 'EXTENSION_FACTORY_ERROR',
message:
"Failed to instantiate extension 'test:test', original blueprint factory did not provide an iterable object",
context: { node: expect.anything() },
@@ -1271,7 +1271,7 @@ describe('instantiateAppNodeTree', () => {
).toBeUndefined();
expect(collector.collectErrors()).toEqual([
{
code: 'FAILED_TO_INSTANTIATE_EXTENSION',
code: 'EXTENSION_FACTORY_ERROR',
message:
"Failed to instantiate extension 'app/test'; caused by NopeError: NOPE",
context: { node },
@@ -1303,7 +1303,7 @@ describe('instantiateAppNodeTree', () => {
).toBeUndefined();
expect(collector.collectErrors()).toEqual([
{
code: 'EXTENSION_FACTORY_DUPLICATE_OUTPUT',
code: 'EXTENSION_OUTPUT_CONFLICT',
message: "extension factory output duplicate data 'test'",
context: { dataRefId: 'test', node },
},
@@ -1335,7 +1335,7 @@ describe('instantiateAppNodeTree', () => {
).toBeUndefined();
expect(collector.collectErrors()).toEqual([
{
code: 'EXTENSION_FACTORY_MISSING_REQUIRED_OUTPUT',
code: 'EXTENSION_OUTPUT_MISSING',
message: "missing required extension data output 'test'",
context: {
dataRefId: 'test',
@@ -1370,7 +1370,7 @@ describe('instantiateAppNodeTree', () => {
).toBeDefined();
expect(collector.collectErrors()).toEqual([
{
code: 'EXTENSION_FACTORY_UNEXPECTED_OUTPUT',
code: 'EXTENSION_OUTPUT_IGNORED',
message: "unexpected output 'test'",
context: { dataRefId: 'test', node },
},
@@ -1404,7 +1404,7 @@ describe('instantiateAppNodeTree', () => {
).toBeUndefined();
expect(collector.collectErrors()).toEqual([
{
code: 'EXTENSION_MISSING_REQUIRED_INPUT',
code: 'EXTENSION_ATTACHMENT_MISSING',
message: "input 'singleton' is required but was not received",
context: { inputName: 'singleton', node },
},
@@ -1529,7 +1529,7 @@ describe('instantiateAppNodeTree', () => {
).toBeUndefined();
expect(collector.collectErrors()).toEqual([
{
code: 'EXTENSION_TOO_MANY_ATTACHMENTS',
code: 'EXTENSION_ATTACHMENT_CONFLICT',
message:
"expected exactly one 'singleton' input but received multiple: 'app/test', 'app/test'",
context: { inputName: 'singleton', node },
@@ -1573,7 +1573,7 @@ describe('instantiateAppNodeTree', () => {
).toBeUndefined();
expect(collector.collectErrors()).toEqual([
{
code: 'EXTENSION_TOO_MANY_ATTACHMENTS',
code: 'EXTENSION_ATTACHMENT_CONFLICT',
message:
"expected at most one 'singleton' input but received multiple: 'app/test', 'app/test'",
context: { inputName: 'singleton', node },
@@ -1610,7 +1610,7 @@ describe('instantiateAppNodeTree', () => {
).toBeUndefined();
expect(collector.collectErrors()).toEqual([
{
code: 'EXTENSION_MISSING_INPUT_DATA',
code: 'EXTENSION_INPUT_DATA_MISSING',
message:
"extension 'app/test' could not be attached because its output data ('test') does not match what the input 'singleton' requires ('other')",
context: { node, inputName: 'singleton' },
@@ -102,7 +102,7 @@ function resolveInputDataContainer(
mapWithFailures(extensionData, ref => {
if (dataMap.has(ref.id)) {
collector.report({
code: 'EXTENSION_DUPLICATE_INPUT',
code: 'EXTENSION_INPUT_DATA_IGNORED',
message: `Unexpected duplicate input data '${ref.id}'`,
});
return;
@@ -120,7 +120,7 @@ function resolveInputDataContainer(
.join(', ');
collector.report({
code: 'EXTENSION_MISSING_INPUT_DATA',
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;
@@ -265,7 +265,7 @@ function resolveV2Inputs(
if (attachedNodes.length > 1) {
const attachedNodeIds = attachedNodes.map(e => e.spec.id).join("', '");
collector.report({
code: 'EXTENSION_TOO_MANY_ATTACHMENTS',
code: 'EXTENSION_ATTACHMENT_CONFLICT',
message: `expected ${
input.config.optional ? 'at most' : 'exactly'
} one '${inputName}' input but received multiple: '${attachedNodeIds}'`,
@@ -274,7 +274,7 @@ function resolveV2Inputs(
} else if (attachedNodes.length === 0) {
if (!input.config.optional) {
collector.report({
code: 'EXTENSION_MISSING_REQUIRED_INPUT',
code: 'EXTENSION_ATTACHMENT_MISSING',
message: `input '${inputName}' is required but was not received`,
});
throw INSTANTIATION_FAILED;
@@ -326,7 +326,7 @@ export function createAppNodeInstance(options: {
};
} catch (e) {
collector.report({
code: 'INVALID_CONFIGURATION',
code: 'EXTENSION_CONFIGURATION_INVALID',
message: `Invalid configuration for extension '${id}'; caused by ${e}`,
});
return undefined;
@@ -393,7 +393,7 @@ export function createAppNodeInstance(options: {
!outputDataValues?.[Symbol.iterator]
) {
collector.report({
code: 'EXTENSION_FACTORY_INVALID_OUTPUT',
code: 'EXTENSION_INVALID',
message: 'extension factory did not provide an iterable object',
});
throw INSTANTIATION_FAILED;
@@ -403,7 +403,7 @@ export function createAppNodeInstance(options: {
mapWithFailures(outputDataValues, value => {
if (outputDataMap.has(value.id)) {
collector.report({
code: 'EXTENSION_FACTORY_DUPLICATE_OUTPUT',
code: 'EXTENSION_OUTPUT_CONFLICT',
message: `extension factory output duplicate data '${value.id}'`,
context: {
dataRefId: value.id,
@@ -421,7 +421,7 @@ export function createAppNodeInstance(options: {
if (value === undefined) {
if (!ref.config.optional) {
collector.report({
code: 'EXTENSION_FACTORY_MISSING_REQUIRED_OUTPUT',
code: 'EXTENSION_OUTPUT_MISSING',
message: `missing required extension data output '${ref.id}'`,
context: {
dataRefId: ref.id,
@@ -439,7 +439,7 @@ export function createAppNodeInstance(options: {
for (const dataRefId of outputDataMap.keys()) {
// TODO: Make this a warning
collector.report({
code: 'EXTENSION_FACTORY_UNEXPECTED_OUTPUT',
code: 'EXTENSION_OUTPUT_IGNORED',
message: `unexpected output '${dataRefId}'`,
context: {
dataRefId: dataRefId,
@@ -449,7 +449,7 @@ export function createAppNodeInstance(options: {
}
} else {
collector.report({
code: 'UNEXPECTED_EXTENSION_VERSION',
code: 'EXTENSION_INVALID',
message: `unexpected extension version '${
(internalExtension as any).version
}'`,
@@ -459,7 +459,7 @@ export function createAppNodeInstance(options: {
} catch (e) {
if (e !== INSTANTIATION_FAILED) {
collector.report({
code: 'FAILED_TO_INSTANTIATE_EXTENSION',
code: 'EXTENSION_FACTORY_ERROR',
message: `Failed to instantiate extension '${id}'${
e.name === 'Error' ? `, ${e.message}` : `; caused by ${e}`
}`,
@@ -440,7 +440,7 @@ describe('resolveAppNodeSpecs', () => {
expect(collector.collectErrors()).toEqual([
{
code: 'EXTENSION_FORBIDDEN',
code: 'EXTENSION_IGNORED',
message:
"It is forbidden to override the 'test/forbidden' extension, attempted by the 'test' plugin",
context: {
@@ -473,7 +473,7 @@ describe('resolveAppNodeSpecs', () => {
expect(collector.collectErrors()).toEqual([
{
code: 'EXTENSION_FORBIDDEN',
code: 'EXTENSION_IGNORED',
message:
"It is forbidden to override the 'forbidden' extension, attempted by the 'forbidden' plugin",
context: {
@@ -496,7 +496,7 @@ describe('resolveAppNodeSpecs', () => {
expect(collector.collectErrors()).toEqual([
{
code: 'EXTENSION_CONFIG_FORBIDDEN',
code: 'INVALID_EXTENSION_CONFIG_KEY',
message: "Configuration of the 'forbidden' extension is forbidden",
context: {
extensionId: 'forbidden',
@@ -56,7 +56,7 @@ export function resolveAppNodeSpecs(options: {
) => {
if (forbidden.has(extension.id)) {
collector.report({
code: 'EXTENSION_FORBIDDEN',
code: 'EXTENSION_IGNORED',
message: `It is forbidden to override the '${extension.id}' extension, attempted by the '${extension.plugin.id}' plugin`,
context: {
plugin: extension.plugin,
@@ -153,13 +153,13 @@ export function resolveAppNodeSpecs(options: {
}
}
const seendExtensionIds = new Set<string>();
const seenExtensionIds = new Set<string>();
const deduplicatedExtensions = configuredExtensions.filter(
({ extension, params }) => {
if (seendExtensionIds.has(extension.id)) {
if (seenExtensionIds.has(extension.id)) {
collector.report({
code: 'EXTENSION_DUPLICATED',
message: `The extension '${extension.id}' is duplicated`,
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,
@@ -167,7 +167,7 @@ export function resolveAppNodeSpecs(options: {
});
return false;
}
seendExtensionIds.add(extension.id);
seenExtensionIds.add(extension.id);
return true;
},
);
@@ -178,7 +178,7 @@ export function resolveAppNodeSpecs(options: {
if (forbidden.has(extensionId)) {
collector.report({
code: 'EXTENSION_CONFIG_FORBIDDEN',
code: 'INVALID_EXTENSION_CONFIG_KEY',
message: `Configuration of the '${extensionId}' extension is forbidden`,
context: {
extensionId,
@@ -206,7 +206,7 @@ export function resolveAppNodeSpecs(options: {
order.set(extensionId, existing);
} else {
collector.report({
code: 'EXTENSION_CONFIG_UNKNOWN_EXTENSION',
code: 'INVALID_EXTENSION_CONFIG_KEY',
message: `Extension ${extensionId} does not exist`,
context: {
extensionId,
@@ -238,7 +238,7 @@ describe('buildAppTree', () => {
`);
});
it('emits an error when duplicated extensions are detected', () => {
it('ignores duplicate extensions', () => {
const tree = resolveAppTree(
'a',
[
@@ -248,13 +248,6 @@ describe('buildAppTree', () => {
collector,
);
expect(Array.from(tree.nodes.keys())).toEqual(['a']);
expect(collector.collectErrors()).toEqual([
{
code: 'DUPLICATE_EXTENSION_ID',
message: "Unexpected duplicate extension id 'a'",
context: { spec: { ...baseSpec, id: 'a' } },
},
]);
});
describe('redirects', () => {
@@ -300,11 +293,13 @@ describe('buildAppTree', () => {
expect(collector.collectErrors()).toEqual([
{
code: 'DUPLICATE_REDIRECT_TARGET',
code: 'EXTENSION_INPUT_REDIRECT_CONFLICT',
message:
"Duplicate redirect target for input 'test' in extension 'b'",
context: {
spec: { ...baseSpec, id: 'b', extension: e2 },
node: expect.objectContaining({
spec: { ...baseSpec, id: 'b', extension: e2 },
}),
inputName: 'test',
},
},
@@ -122,15 +122,8 @@ export function resolveAppTree(
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)) {
errorCollector.report({
code: 'DUPLICATE_EXTENSION_ID',
message: `Unexpected duplicate extension id '${spec.id}'`,
context: {
spec,
},
});
continue;
}
@@ -144,10 +137,10 @@ export function resolveAppTree(
const key = makeRedirectKey(replace);
if (redirectTargetsByKey.has(key)) {
errorCollector.report({
code: 'DUPLICATE_REDIRECT_TARGET',
code: 'EXTENSION_INPUT_REDIRECT_CONFLICT',
message: `Duplicate redirect target for input '${inputName}' in extension '${spec.id}'`,
context: {
spec,
node,
inputName,
},
});
@@ -14,41 +14,32 @@
* limitations under the License.
*/
import {
AppNode,
AppNodeSpec,
FrontendPlugin,
} from '@backstage/frontend-plugin-api';
import { AppNode, FrontendPlugin } from '@backstage/frontend-plugin-api';
import { Expand } from '@backstage/types';
type AppErrorTypes = {
// resolveAppNodeSpecs
EXTENSION_FORBIDDEN: 'plugin' | 'extensionId';
EXTENSION_DUPLICATED: 'plugin' | 'extensionId';
EXTENSION_CONFIG_FORBIDDEN: 'extensionId';
EXTENSION_CONFIG_UNKNOWN_EXTENSION: 'extensionId';
EXTENSION_IGNORED: 'plugin' | 'extensionId';
INVALID_EXTENSION_CONFIG_KEY: 'extensionId';
// resolveAppTree
DUPLICATE_EXTENSION_ID: 'spec';
DUPLICATE_REDIRECT_TARGET: 'spec' | 'inputName';
EXTENSION_INPUT_REDIRECT_CONFLICT: 'node' | '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';
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
NO_API_FACTORY: 'node';
API_EXTENSION_INVALID: 'node';
};
type AppErrorContext = {
node?: AppNode;
spec?: AppNodeSpec;
plugin?: FrontendPlugin;
extensionId?: string;
dataRefId?: string;
@@ -394,7 +394,7 @@ function createApiFactories(options: {
factories.push(apiFactory);
} else {
options.collector.report({
code: 'NO_API_FACTORY',
code: 'API_EXTENSION_INVALID',
message: `API extension '${apiNode.spec.id}' did not output an API factory`,
context: {
node: apiNode,