From d2bee21c914ddbaa7a2ef613411ec3b8fc18d32d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Oct 2023 00:30:46 +0200 Subject: [PATCH] frontend-app-api: add initial app graph builder Signed-off-by: Patrik Oldsberg --- .../src/wiring/graph/buildAppGraph.test.ts | 74 +++++++++++++ .../src/wiring/graph/buildAppGraph.ts | 104 ++++++++++++++++++ .../src/wiring/graph/types.ts | 26 +++-- 3 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 packages/frontend-app-api/src/wiring/graph/buildAppGraph.test.ts create mode 100644 packages/frontend-app-api/src/wiring/graph/buildAppGraph.ts diff --git a/packages/frontend-app-api/src/wiring/graph/buildAppGraph.test.ts b/packages/frontend-app-api/src/wiring/graph/buildAppGraph.test.ts new file mode 100644 index 0000000000..1d5d8f415f --- /dev/null +++ b/packages/frontend-app-api/src/wiring/graph/buildAppGraph.test.ts @@ -0,0 +1,74 @@ +/* + * 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 { createExtension } from '@backstage/frontend-plugin-api'; +import { buildAppGraph } from './buildAppGraph'; + +const extBaseConfig = { + id: 'test', + attachTo: { id: 'root', input: 'default' }, + output: {}, + factory() {}, +}; + +const extension = createExtension(extBaseConfig); + +const baseSpec = { extension, disabled: false }; + +describe('buildAppGraph', () => { + it('creates an empty graph', () => { + const graph = buildAppGraph([]); + expect([...graph.rootNodes.keys()]).toEqual([]); + expect([...graph.orphanNodes.keys()]).toEqual([]); + }); + + it('should create a graph', () => { + const graph = buildAppGraph([ + { ...baseSpec, id: 'a' }, + { ...baseSpec, id: 'b' }, + { ...baseSpec, id: 'c' }, + { ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx1' }, + { ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx2' }, + { ...baseSpec, attachTo: { id: 'b', input: 'y' }, id: 'by1' }, + { ...baseSpec, attachTo: { id: 'd', input: 'x' }, id: 'dx1' }, + ]); + expect([...graph.rootNodes.keys()]).toEqual(['a', 'b', 'c']); + expect([...graph.orphanNodes.keys()]).toEqual(['dx1']); + }); + + it('should create a graph out of order', () => { + const graph = buildAppGraph([ + { ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx2' }, + { ...baseSpec, id: 'a' }, + { ...baseSpec, attachTo: { id: 'b', input: 'y' }, id: 'by1' }, + { ...baseSpec, id: 'b' }, + { ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx1' }, + { ...baseSpec, id: 'c' }, + { ...baseSpec, attachTo: { id: 'd', input: 'x' }, id: 'dx1' }, + ]); + expect([...graph.rootNodes.keys()]).toEqual(['a', 'b', 'c']); + expect([...graph.orphanNodes.keys()]).toEqual(['dx1']); + }); + + it('throws an error when duplicated extensions are detected', () => { + expect(() => + buildAppGraph([ + { ...baseSpec, id: 'a' }, + { ...baseSpec, id: 'a' }, + ]), + ).toThrow("Unexpected duplicate extension id 'a'"); + }); +}); diff --git a/packages/frontend-app-api/src/wiring/graph/buildAppGraph.ts b/packages/frontend-app-api/src/wiring/graph/buildAppGraph.ts new file mode 100644 index 0000000000..151faa0e1d --- /dev/null +++ b/packages/frontend-app-api/src/wiring/graph/buildAppGraph.ts @@ -0,0 +1,104 @@ +/* + * 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 { AppNode, AppNodeEdges, AppNodeSpec } from './types'; + +type Mutable = { + -readonly [P in keyof T]: T[P]; +}; + +/** + * Build the app graph by iterating through all node specs and constructing the app + * tree with all attachments in the same order as they appear in the input specs array. + * @internal + */ +export function buildAppGraph(specs: AppNodeSpec[]) { + const nodes = new Map(); + const rootNodes = new Map(); + // While iterating through the inputs specs we keep track of all nodes that were created + // before their parent, and attach them later when the parent is created. + // As we find the parents and attach the children, we remove them from this map. This means + // that after iterating through all input specs, this will be a map for each root node. + const orphansByParent = new Map< + string /* parentId */, + { orphan: AppNode; input: string }[] + >(); + + for (const spec of specs) { + // The main check with a more helpful error message happens in resolveAppNodeSpecs + if (nodes.has(spec.id)) { + throw new Error(`Unexpected duplicate extension id '${spec.id}'`); + } + + const node: AppNode = { + spec, + edges: { + attachments: new Map(), + }, + }; + nodes.set(spec.id, node); + + if (spec.attachTo) { + const parent = nodes.get(spec.attachTo.id); + if (parent) { + (node.edges as Mutable).attachedTo = { + node: parent, + input: spec.attachTo.input, + }; + const parentInputEdges = parent.edges.attachments.get( + spec.attachTo.input, + ); + if (parentInputEdges) { + parentInputEdges.push(node); + } else { + parent.edges.attachments.set(spec.attachTo.input, [node]); + } + } else { + const orphanNodesForParent = orphansByParent.get(spec.attachTo.id); + const orphan = { orphan: node, input: spec.attachTo.input }; + if (orphanNodesForParent) { + orphanNodesForParent.push(orphan); + } else { + orphansByParent.set(spec.attachTo.id, [orphan]); + } + } + } else { + rootNodes.set(spec.id, node); + } + + const orphanedChildren = orphansByParent.get(spec.id); + if (orphanedChildren) { + orphansByParent.delete(spec.id); + for (const { orphan, input } of orphanedChildren) { + (orphan.edges as Mutable).attachedTo = { node, input }; + const attachments = node.edges.attachments.get(input); + if (attachments) { + attachments.push(orphan); + } else { + node.edges.attachments.set(input, [orphan]); + } + } + } + } + + const orphanNodes = new Map( + Array.from(orphansByParent).flatMap(([, orphans]) => + orphans.map(({ orphan }) => [orphan.spec.id, orphan]), + ), + ); + + return { rootNodes, orphanNodes }; +} diff --git a/packages/frontend-app-api/src/wiring/graph/types.ts b/packages/frontend-app-api/src/wiring/graph/types.ts index 3c5cd5df1b..f6de7018f5 100644 --- a/packages/frontend-app-api/src/wiring/graph/types.ts +++ b/packages/frontend-app-api/src/wiring/graph/types.ts @@ -25,12 +25,12 @@ import { * @public */ export interface AppNodeSpec { - id: string; - attachTo: { id: string; input: string }; - extension: Extension; - disabled: boolean; - config?: unknown; - source?: BackstagePlugin; + readonly id: string; + readonly attachTo?: { id: string; input: string }; + readonly extension: Extension; + readonly disabled: boolean; + readonly config?: unknown; + readonly source?: BackstagePlugin; } /** @@ -38,8 +38,8 @@ export interface AppNodeSpec { * @public */ export interface AppNodeEdges { - attachedTo: { node: AppNode; input: string }; - attachments: Map; + readonly attachedTo?: { node: AppNode; input: string }; + readonly attachments: Map; } /** @@ -57,9 +57,13 @@ export interface AppNodeInstance { */ export interface AppNode { /** The specification for how this node should be instantiated */ - spec: AppNodeSpec; + readonly spec: AppNodeSpec; /** The edges from this node to other nodes in the app graph */ - edges: AppNodeEdges; + readonly edges: AppNodeEdges; /** The instance of this node, if it was instantiated */ - instance?: AppNodeInstance; + readonly instance?: AppNodeInstance; +} + +export interface AppGraph { + rootNodes: Map; }