frontend-app-api: add support for v2 extensions

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-07-27 17:01:52 +02:00
parent 0d24c1fa3e
commit 3f7029edf0
2 changed files with 1237 additions and 514 deletions
File diff suppressed because it is too large Load Diff
@@ -16,8 +16,11 @@
import {
AnyExtensionDataMap,
AnyExtensionDataRef,
AnyExtensionInputMap,
ExtensionDataContainer,
ExtensionDataRef,
ExtensionInput,
ResolvedExtensionInputs,
} from '@backstage/frontend-plugin-api';
import mapValues from 'lodash/mapValues';
@@ -29,7 +32,7 @@ type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
function resolveInputData(
function resolveInputDataMap(
dataMap: AnyExtensionDataMap,
attachment: AppNode,
inputName: string,
@@ -54,35 +57,76 @@ function resolveInputData(
});
}
function resolveInputs(
function resolveInputDataContainer(
extensionData: Array<AnyExtensionDataRef>,
attachment: AppNode,
inputName: string,
): { node: AppNode } & ExtensionDataContainer<AnyExtensionDataRef> {
const dataMap = new Map<string, unknown>();
for (const ref of extensionData) {
if (dataMap.has(ref.id)) {
throw new Error(`Unexpected duplicate input data '${ref.id}'`);
}
const value = attachment.instance?.getData(ref);
if (value === undefined && !ref.config.optional) {
const expected = extensionData
.filter(r => !r.config.optional)
.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})`,
);
}
dataMap.set(ref.id, value);
}
return {
node: attachment,
get(ref) {
return dataMap.get(ref.id);
},
} as { node: AppNode } & ExtensionDataContainer<AnyExtensionDataRef>;
}
function reportUndeclaredAttachments(
id: string,
inputMap: AnyExtensionInputMap,
inputMap: { [name in string]: unknown },
attachments: ReadonlyMap<string, AppNode[]>,
): ResolvedExtensionInputs<AnyExtensionInputMap> {
) {
const undeclaredAttachments = Array.from(attachments.entries()).filter(
([inputName]) => inputMap[inputName] === undefined,
);
if (process.env.NODE_ENV !== 'production') {
const inputNames = Object.keys(inputMap);
const inputNames = Object.keys(inputMap);
for (const [name, nodes] of undeclaredAttachments) {
const pl = nodes.length > 1;
// eslint-disable-next-line no-console
console.warn(
[
`The extension${pl ? 's' : ''} '${nodes
.map(n => n.spec.id)
.join("', '")}' ${pl ? 'are' : 'is'}`,
`attached to the input '${name}' of the extension '${id}', but it`,
inputNames.length === 0
? 'has no inputs'
: `has no such input (candidates are '${inputNames.join("', '")}')`,
].join(' '),
);
}
for (const [name, nodes] of undeclaredAttachments) {
const pl = nodes.length > 1;
// eslint-disable-next-line no-console
console.warn(
[
`The extension${pl ? 's' : ''} '${nodes
.map(n => n.spec.id)
.join("', '")}' ${pl ? 'are' : 'is'}`,
`attached to the input '${name}' of the extension '${id}', but it`,
inputNames.length === 0
? 'has no inputs'
: `has no such input (candidates are '${inputNames.join("', '")}')`,
].join(' '),
);
}
}
function resolveV1Inputs(
inputMap: AnyExtensionInputMap,
attachments: ReadonlyMap<string, AppNode[]>,
): ResolvedExtensionInputs<AnyExtensionInputMap> {
return mapValues(inputMap, (input, inputName) => {
const attachedNodes = attachments.get(inputName) ?? [];
@@ -104,7 +148,7 @@ function resolveInputs(
}
return {
node: attachedNodes[0],
output: resolveInputData(
output: resolveInputDataMap(
input.extensionData,
attachedNodes[0],
inputName,
@@ -114,11 +158,62 @@ function resolveInputs(
return attachedNodes.map(attachment => ({
node: attachment,
output: resolveInputData(input.extensionData, attachment, inputName),
output: resolveInputDataMap(input.extensionData, attachment, inputName),
}));
}) as ResolvedExtensionInputs<AnyExtensionInputMap>;
}
function resolveV2Inputs(
inputMap: {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
},
attachments: ReadonlyMap<string, AppNode[]>,
): ResolvedExtensionInputs<{
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
}> {
return mapValues(inputMap, (input, inputName) => {
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;
}
throw Error(`input '${inputName}' is required but was not received`);
}
return resolveInputDataContainer(
input.extensionData,
attachedNodes[0],
inputName,
);
}
return attachedNodes.map(attachment =>
resolveInputDataContainer(input.extensionData, attachment, inputName),
);
}) as ResolvedExtensionInputs<{
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
}>;
}
/** @internal */
export function createAppNodeInstance(options: {
node: AppNode;
@@ -141,24 +236,71 @@ export function createAppNodeInstance(options: {
try {
const internalExtension = toInternalExtension(extension);
const namedOutputs = internalExtension.factory({
node,
config: parsedConfig,
inputs: resolveInputs(id, internalExtension.inputs, attachments),
});
if (process.env.NODE_ENV !== 'production') {
reportUndeclaredAttachments(id, internalExtension.inputs, attachments);
}
for (const [name, output] of Object.entries(namedOutputs)) {
const ref = internalExtension.output[name];
if (!ref) {
throw new Error(`unknown output provided via '${name}'`);
if (internalExtension.version === 'v1') {
const namedOutputs = internalExtension.factory({
node,
config: parsedConfig,
inputs: resolveV1Inputs(internalExtension.inputs, attachments),
});
for (const [name, output] of Object.entries(namedOutputs)) {
const ref = internalExtension.output[name];
if (!ref) {
throw new Error(`unknown output provided via '${name}'`);
}
if (extensionData.has(ref.id)) {
throw new Error(
`duplicate extension data '${ref.id}' received via output '${name}'`,
);
}
extensionData.set(ref.id, output);
extensionDataRefs.add(ref);
}
if (extensionData.has(ref.id)) {
} else if (internalExtension.version === 'v2') {
const outputDataValues = internalExtension.factory({
node,
config: parsedConfig,
inputs: resolveV2Inputs(internalExtension.inputs, attachments),
});
const outputDataMap = new Map<string, unknown>();
for (const value of outputDataValues) {
if (outputDataMap.has(value.id)) {
throw new Error(`duplicate extension data output '${value.id}'`);
}
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}'`,
);
}
} else {
extensionData.set(ref.id, value);
extensionDataRefs.add(ref);
}
}
if (outputDataMap.size > 0) {
throw new Error(
`duplicate extension data '${ref.id}' received via output '${name}'`,
`unexpected output '${Array.from(outputDataMap.keys()).join(
"', '",
)}'`,
);
}
extensionData.set(ref.id, output);
extensionDataRefs.add(ref);
} else {
throw new Error(
`unexpected extension version '${(internalExtension as any).version}'`,
);
}
} catch (e) {
throw new Error(