frontend-app-api: move app node instance creation into instantiateAppNodeTree

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-10-18 11:59:44 +02:00
parent be2afe8d48
commit 2b1ceb2fe1
3 changed files with 159 additions and 171 deletions
@@ -1,155 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
AnyExtensionDataMap,
AnyExtensionInputMap,
ExtensionDataRef,
} from '@backstage/frontend-plugin-api';
import mapValues from 'lodash/mapValues';
import { AppNode, AppNodeInstance, AppNodeSpec } from './types';
type AppNodeWithInstance = AppNode & { instance: AppNodeInstance };
function resolveInputData(
dataMap: AnyExtensionDataMap,
attachment: AppNodeWithInstance,
inputName: string,
) {
return mapValues(dataMap, ref => {
const value = attachment.instance.getData(ref);
if (value === undefined && !ref.config.optional) {
throw new Error(
`input '${inputName}' did not receive required extension data '${ref.id}' from extension '${attachment.spec.id}'`,
);
}
return value;
});
}
function resolveInputs(
inputMap: AnyExtensionInputMap,
attachments: ReadonlyMap<string, AppNode[]>,
) {
const undeclaredAttachments = Array.from(attachments.entries()).filter(
([inputName]) => inputMap[inputName] === undefined,
);
// TODO: Make this a warning rather than an error
if (undeclaredAttachments.length > 0) {
throw new Error(
`received undeclared input${
undeclaredAttachments.length > 1 ? 's' : ''
} ${undeclaredAttachments
.map(
([k, exts]) =>
`'${k}' from extension${exts.length > 1 ? 's' : ''} '${exts
.map(e => e.spec.id)
.join("', '")}'`,
)
.join(' and ')}`,
);
}
return mapValues(inputMap, (input, inputName) => {
const attachedNodes =
attachments
.get(inputName)
?.filter((node): node is AppNodeWithInstance =>
Boolean(node.instance),
) ?? [];
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 resolveInputData(input.extensionData, attachedNodes[0], inputName);
}
return attachedNodes.map(attachment =>
resolveInputData(input.extensionData, attachment, inputName),
);
});
}
/** @internal */
export function createAppNodeInstance(options: {
spec: AppNodeSpec;
attachments: ReadonlyMap<string, AppNode[]>;
}): AppNodeInstance {
const { spec, attachments } = options;
const { id, extension, config, source } = spec;
const extensionData = new Map<string, unknown>();
const extensionDataRefs = new Set<ExtensionDataRef<unknown>>();
let parsedConfig: unknown;
try {
parsedConfig = extension.configSchema?.parse(config ?? {});
} catch (e) {
throw new Error(
`Invalid configuration for extension '${id}'; caused by ${e}`,
);
}
try {
extension.factory({
source,
config: parsedConfig,
bind: namedOutputs => {
for (const [name, output] of Object.entries(namedOutputs)) {
const ref = extension.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);
}
},
inputs: resolveInputs(extension.inputs, attachments),
});
} catch (e) {
throw new Error(
`Failed to instantiate extension '${id}'${
e.name === 'Error' ? `, ${e.message}` : `; caused by ${e}`
}`,
);
}
return {
getDataRefs() {
return extensionDataRefs.values();
},
getData<T>(ref: ExtensionDataRef<T>): T | undefined {
return extensionData.get(ref.id) as T | undefined;
},
};
}
@@ -14,30 +14,178 @@
* limitations under the License.
*/
import { createAppNodeInstance } from './createAppNodeInstance';
import { AppNode, Mutable } from './types';
import {
AnyExtensionDataMap,
AnyExtensionInputMap,
ExtensionDataRef,
} from '@backstage/frontend-plugin-api';
import mapValues from 'lodash/mapValues';
import { AppNode, AppNodeInstance, AppNodeSpec } from './types';
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
function resolveInputData(
dataMap: AnyExtensionDataMap,
attachment: { id: string; instance: AppNodeInstance },
inputName: string,
) {
return mapValues(dataMap, ref => {
const value = attachment.instance.getData(ref);
if (value === undefined && !ref.config.optional) {
throw new Error(
`input '${inputName}' did not receive required extension data '${ref.id}' from extension '${attachment.id}'`,
);
}
return value;
});
}
function resolveInputs(
inputMap: AnyExtensionInputMap,
attachments: ReadonlyMap<string, { id: string; instance: AppNodeInstance }[]>,
) {
const undeclaredAttachments = Array.from(attachments.entries()).filter(
([inputName]) => inputMap[inputName] === undefined,
);
// TODO: Make this a warning rather than an error
if (undeclaredAttachments.length > 0) {
throw new Error(
`received undeclared input${
undeclaredAttachments.length > 1 ? 's' : ''
} ${undeclaredAttachments
.map(
([k, exts]) =>
`'${k}' from extension${exts.length > 1 ? 's' : ''} '${exts
.map(e => e.id)
.join("', '")}'`,
)
.join(' and ')}`,
);
}
return mapValues(inputMap, (input, inputName) => {
const attachedNodes = attachments.get(inputName) ?? [];
if (input.config.singleton) {
if (attachedNodes.length > 1) {
const attachedNodeIds = attachedNodes.map(e => e.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 resolveInputData(input.extensionData, attachedNodes[0], inputName);
}
return attachedNodes.map(attachment =>
resolveInputData(input.extensionData, attachment, inputName),
);
});
}
/** @internal */
export function createAppNodeInstance(options: {
spec: AppNodeSpec;
attachments: ReadonlyMap<string, { id: string; instance: AppNodeInstance }[]>;
}): AppNodeInstance {
const { spec, attachments } = options;
const { id, extension, config, source } = spec;
const extensionData = new Map<string, unknown>();
const extensionDataRefs = new Set<ExtensionDataRef<unknown>>();
let parsedConfig: unknown;
try {
parsedConfig = extension.configSchema?.parse(config ?? {});
} catch (e) {
throw new Error(
`Invalid configuration for extension '${id}'; caused by ${e}`,
);
}
try {
extension.factory({
source,
config: parsedConfig,
bind: namedOutputs => {
for (const [name, output] of Object.entries(namedOutputs)) {
const ref = extension.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);
}
},
inputs: resolveInputs(extension.inputs, attachments),
});
} catch (e) {
throw new Error(
`Failed to instantiate extension '${id}'${
e.name === 'Error' ? `, ${e.message}` : `; caused by ${e}`
}`,
);
}
return {
getDataRefs() {
return extensionDataRefs.values();
},
getData<T>(ref: ExtensionDataRef<T>): T | undefined {
return extensionData.get(ref.id) as T | undefined;
},
};
}
/**
* Starting at the provided node, instantiate all reachable nodes in the graph that have not been disabled.
* @internal
*/
export function instantiateAppNodeTree(rootNode: AppNode): void {
function createInstance(node: AppNode): void {
function createInstance(node: AppNode): AppNodeInstance | undefined {
if (node.instance) {
return;
return node.instance;
}
if (node.spec.disabled) {
return;
return undefined;
}
for (const children of node.edges.attachments.values()) {
for (const child of children) {
createInstance(child);
}
const instantiatedAttachments = new Map<
string,
{ id: string; instance: AppNodeInstance }[]
>();
for (const [input, children] of node.edges.attachments) {
const instantiatedChildren = children.flatMap(child => {
const childInstance = createInstance(child);
if (!childInstance) {
return [];
}
return [{ id: child.spec.id, instance: childInstance }];
});
instantiatedAttachments.set(input, instantiatedChildren);
}
(node as Mutable<AppNode>).instance = createAppNodeInstance({
spec: node.spec,
attachments: node.edges.attachments,
attachments: instantiatedAttachments,
});
return;
return node.instance;
}
createInstance(rootNode);
@@ -20,11 +20,6 @@ import {
ExtensionDataRef,
} from '@backstage/frontend-plugin-api';
/** @internal */
export type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
/**
* The specification for this node in the app graph.
* @public