Merge pull request #26138 from backstage/mob/move-2-root

NFS: Introduce the `Root` extension
This commit is contained in:
Patrik Oldsberg
2024-08-22 15:15:20 +02:00
committed by GitHub
22 changed files with 310 additions and 269 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-test-utils': patch
---
Internal update to add support for passing an `ApiRegistry` when creating the node tree
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': patch
---
A new `apis` parameter has been added to `factory` for extensions. This is a way to access utility APIs without being coupled to the React context.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
Added the `root` extension the replace the `app` extension as the root of the app.
@@ -74,7 +74,7 @@ describe('collectLegacyRoutes', () => {
},
{
id: 'api:plugin.scoringdata.service',
attachTo: { id: 'app', input: 'apis' },
attachTo: { id: 'root', input: 'apis' },
disabled: false,
},
],
@@ -90,7 +90,7 @@ describe('collectLegacyRoutes', () => {
},
{
id: 'api:plugin.stackstorm.service',
attachTo: { id: 'app', input: 'apis' },
attachTo: { id: 'root', input: 'apis' },
disabled: false,
},
],
@@ -112,7 +112,7 @@ describe('collectLegacyRoutes', () => {
},
{
id: 'api:plugin.puppetdb.service',
attachTo: { id: 'app', input: 'apis' },
attachTo: { id: 'root', input: 'apis' },
disabled: false,
},
],
@@ -211,7 +211,7 @@ describe('collectLegacyRoutes', () => {
{
id: 'api:plugin.catalog.service',
attachTo: {
id: 'app',
id: 'root',
input: 'apis',
},
defaultConfig: undefined,
@@ -220,7 +220,7 @@ describe('collectLegacyRoutes', () => {
{
id: 'api:catalog-react.starred-entities',
attachTo: {
id: 'app',
id: 'root',
input: 'apis',
},
defaultConfig: undefined,
@@ -229,7 +229,7 @@ describe('collectLegacyRoutes', () => {
{
id: 'api:plugin.catalog.entity-presentation',
attachTo: {
id: 'app',
id: 'root',
input: 'apis',
},
defaultConfig: undefined,
@@ -242,7 +242,7 @@ describe('collectLegacyRoutes', () => {
extensions: [
{
id: 'api:plugin.scoringdata.service',
attachTo: { id: 'app', input: 'apis' },
attachTo: { id: 'root', input: 'apis' },
disabled: false,
},
],
@@ -66,7 +66,7 @@ describe('convertLegacyApp', () => {
},
{
id: 'api:plugin.scoringdata.service',
attachTo: { id: 'app', input: 'apis' },
attachTo: { id: 'root', input: 'apis' },
disabled: false,
},
],
@@ -82,7 +82,7 @@ describe('convertLegacyApp', () => {
},
{
id: 'api:plugin.stackstorm.service',
attachTo: { id: 'app', input: 'apis' },
attachTo: { id: 'root', input: 'apis' },
disabled: false,
},
],
@@ -104,7 +104,7 @@ describe('convertLegacyApp', () => {
},
{
id: 'api:plugin.puppetdb.service',
attachTo: { id: 'app', input: 'apis' },
attachTo: { id: 'root', input: 'apis' },
disabled: false,
},
],
@@ -16,7 +16,6 @@
import React from 'react';
import {
ApiBlueprint,
ExtensionBoundary,
coreExtensionData,
createExtension,
@@ -25,9 +24,8 @@ import {
export const App = createExtension({
namespace: 'app',
attachTo: { id: 'root', input: 'default' }, // ignored
attachTo: { id: 'root', input: 'app' },
inputs: {
apis: createExtensionInput([ApiBlueprint.dataRefs.factory]),
root: createExtensionInput([coreExtensionData.reactElement], {
singleton: true,
}),
@@ -1,5 +1,5 @@
/*
* Copyright 2023 The Backstage Authors
* Copyright 2024 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.
@@ -14,4 +14,22 @@
* limitations under the License.
*/
export { createAppTree } from './createAppTree';
import {
ApiBlueprint,
coreExtensionData,
createExtension,
createExtensionInput,
} from '@backstage/frontend-plugin-api';
export const Root = createExtension({
namespace: 'root',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
app: createExtensionInput([coreExtensionData.reactElement], {
singleton: true,
}),
apis: createExtensionInput([ApiBlueprint.dataRefs.factory]),
},
output: [],
factory: () => [],
});
@@ -28,9 +28,13 @@ import {
createFrontendPlugin,
createRouteRef,
} from '@backstage/frontend-plugin-api';
import { MockConfigApi } from '@backstage/test-utils';
import { createAppTree } from '../tree';
import { MockConfigApi, TestApiRegistry } from '@backstage/test-utils';
import { builtinExtensions } from '../wiring/createApp';
import { readAppExtensionsConfig } from '../tree/readAppExtensionsConfig';
import { resolveAppNodeSpecs } from '../tree/resolveAppNodeSpecs';
import { resolveAppTree } from '../tree/resolveAppTree';
import { instantiateAppNodeTree } from '../tree/instantiateAppNodeTree';
const ref1 = createRouteRef();
const ref2 = createRouteRef();
@@ -77,11 +81,18 @@ function routeInfoFromExtensions(extensions: ExtensionDefinition<any, any>[]) {
id: 'test',
extensions,
});
const tree = createAppTree({
config: new MockConfigApi({}),
builtinExtensions,
features: [plugin],
});
const tree = resolveAppTree(
'app',
resolveAppNodeSpecs({
features: [plugin],
builtinExtensions,
parameters: readAppExtensionsConfig(new MockConfigApi({})),
forbidden: new Set(['app']),
}),
);
instantiateAppNodeTree(tree.root, TestApiRegistry.from());
return extractRouteInfoFromAppNode(tree.root);
}
@@ -1,96 +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 {
createExtension,
createExtensionOverrides,
createFrontendPlugin,
} from '@backstage/frontend-plugin-api';
import { MockConfigApi } from '@backstage/test-utils';
import { createAppTree } from './createAppTree';
const extBase = {
id: 'test',
attachTo: { id: 'app', input: 'root' },
output: [],
factory: () => [],
};
describe('createAppTree', () => {
it('throws an error when a app extension is parametrized', () => {
const config = new MockConfigApi({
app: {
extensions: [
{
app: {},
},
],
},
});
const features = [
createFrontendPlugin({
id: 'plugin',
extensions: [],
}),
];
expect(() =>
createAppTree({ features, config, builtinExtensions: [] }),
).toThrow("Configuration of the 'app' extension is forbidden");
});
it('throws an error when a app extension is overridden', () => {
const config = new MockConfigApi({});
const features = [
createExtensionOverrides({
extensions: [
createExtension({
name: 'app',
attachTo: { id: 'app/routes', input: 'route' },
inputs: {},
output: [],
factory: () => [],
}),
],
}),
];
expect(() =>
createAppTree({ features, config, builtinExtensions: [] }),
).toThrow(
"It is forbidden to override the following extension(s): 'app', which is done by one or more extension overrides",
);
});
it('throws an error when duplicated extension overrides are detected', () => {
expect(() =>
createAppTree({
features: [
createExtensionOverrides({
extensions: [
createExtension({ ...extBase, name: 'a' }),
createExtension({ ...extBase, name: 'a' }),
createExtension({ ...extBase, name: 'b' }),
],
}),
createExtensionOverrides({
extensions: [createExtension({ ...extBase, name: 'b' })],
}),
],
config: new MockConfigApi({}),
builtinExtensions: [],
}),
).toThrow('The following extensions had duplicate overrides: a, b');
});
});
@@ -1,45 +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 { Extension, FrontendFeature } from '@backstage/frontend-plugin-api';
import { readAppExtensionsConfig } from './readAppExtensionsConfig';
import { resolveAppTree } from './resolveAppTree';
import { resolveAppNodeSpecs } from './resolveAppNodeSpecs';
import { AppTree } from '@backstage/frontend-plugin-api';
import { Config } from '@backstage/config';
import { instantiateAppNodeTree } from './instantiateAppNodeTree';
/** @internal */
export interface CreateAppTreeOptions {
features: FrontendFeature[];
builtinExtensions: Extension<any, any>[];
config: Config;
}
/** @internal */
export function createAppTree(options: CreateAppTreeOptions): AppTree {
const tree = resolveAppTree(
'app',
resolveAppNodeSpecs({
features: options.features,
builtinExtensions: options.builtinExtensions,
parameters: readAppExtensionsConfig(options.config),
forbidden: new Set(['app']),
}),
);
instantiateAppNodeTree(tree.root);
return tree;
}
@@ -38,8 +38,9 @@ import {
} from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { createSchemaFromZod } from '../../../frontend-plugin-api/src/schema/createSchemaFromZod';
import { withLogCollector } from '@backstage/test-utils';
import { TestApiRegistry, withLogCollector } from '@backstage/test-utils';
const testApis = TestApiRegistry.from();
const testDataRef = createExtensionDataRef<string>().with({ id: 'test' });
const otherDataRef = createExtensionDataRef<number>().with({ id: 'other' });
const inputMirrorDataRef = createExtensionDataRef<unknown>().with({
@@ -82,6 +83,7 @@ function makeInstanceWithId<TConfig, TConfigInput>(
instance: createAppNodeInstance({
node,
attachments: new Map(),
apis: testApis,
}),
};
}
@@ -149,12 +151,12 @@ describe('instantiateAppNodeTree', () => {
makeSpec(simpleExtension, { id: 'root-node' }),
]);
expect(tree.root.instance).not.toBeDefined();
instantiateAppNodeTree(tree.root);
instantiateAppNodeTree(tree.root, testApis);
expect(tree.root.instance).toBeDefined();
expect(tree.root.instance?.getData(testDataRef)).toBe('test');
// Multiple calls should have no effect
instantiateAppNodeTree(tree.root);
instantiateAppNodeTree(tree.root, testApis);
expect(tree.root.instance).toBeDefined();
});
@@ -163,7 +165,7 @@ describe('instantiateAppNodeTree', () => {
makeSpec(simpleExtension, { id: 'root-node', disabled: true }),
]);
expect(tree.root.instance).not.toBeDefined();
instantiateAppNodeTree(tree.root);
instantiateAppNodeTree(tree.root, testApis);
expect(tree.root.instance).not.toBeDefined();
});
@@ -195,7 +197,7 @@ describe('instantiateAppNodeTree', () => {
expect(tree.root.instance).not.toBeDefined();
expect(childNode?.instance).not.toBeDefined();
instantiateAppNodeTree(tree.root);
instantiateAppNodeTree(tree.root, testApis);
expect(tree.root.instance).toBeDefined();
expect(childNode?.instance).toBeDefined();
expect(tree.root.instance?.getData(inputMirrorDataRef)).toMatchObject({
@@ -205,7 +207,7 @@ describe('instantiateAppNodeTree', () => {
});
// Multiple calls should have no effect
instantiateAppNodeTree(tree.root);
instantiateAppNodeTree(tree.root, testApis);
expect(tree.root.instance).toBeDefined();
expect(childNode?.instance).toBeDefined();
});
@@ -243,7 +245,7 @@ describe('instantiateAppNodeTree', () => {
expect(tree.root.instance).not.toBeDefined();
expect(childNode?.instance).not.toBeDefined();
instantiateAppNodeTree(tree.root);
instantiateAppNodeTree(tree.root, testApis);
expect(tree.root.instance).toBeDefined();
expect(childNode?.instance).not.toBeDefined();
expect(tree.root.instance?.getData(inputMirrorDataRef)).toEqual({
@@ -257,6 +259,7 @@ describe('instantiateAppNodeTree', () => {
const instance = createAppNodeInstance({
node: makeNode(simpleExtension),
attachments,
apis: testApis,
});
expect(Array.from(instance.getDataRefs())).toEqual([
@@ -297,6 +300,7 @@ describe('instantiateAppNodeTree', () => {
],
]);
const instance = createAppNodeInstance({
apis: testApis,
attachments,
node: makeNode(
createV1Extension({
@@ -365,6 +369,7 @@ describe('instantiateAppNodeTree', () => {
it('should refuse to create an extension with invalid config', () => {
expect(() =>
createAppNodeInstance({
apis: testApis,
node: makeNode(simpleExtension, {
config: { other: 'not-a-number' },
}),
@@ -378,6 +383,7 @@ describe('instantiateAppNodeTree', () => {
it('should forward extension factory errors', () => {
expect(() =>
createAppNodeInstance({
apis: testApis,
node: makeNode(
createV1Extension({
namespace: 'app',
@@ -401,6 +407,7 @@ describe('instantiateAppNodeTree', () => {
it('should refuse to create an instance with duplicate output', () => {
expect(() =>
createAppNodeInstance({
apis: testApis,
node: makeNode(
createV1Extension({
namespace: 'app',
@@ -425,6 +432,7 @@ describe('instantiateAppNodeTree', () => {
it('should refuse to create an instance with disconnected output data', () => {
expect(() =>
createAppNodeInstance({
apis: testApis,
node: makeNode(
createV1Extension({
namespace: 'app',
@@ -448,6 +456,7 @@ describe('instantiateAppNodeTree', () => {
it('should refuse to create an instance with missing required input', () => {
expect(() =>
createAppNodeInstance({
apis: testApis,
node: makeNode(
createV1Extension({
namespace: 'app',
@@ -475,6 +484,7 @@ describe('instantiateAppNodeTree', () => {
it('should warn when creating an instance with undeclared inputs', () => {
const { warn } = withLogCollector(['warn'], () =>
createAppNodeInstance({
apis: testApis,
attachments: new Map([
[
'declared',
@@ -518,6 +528,7 @@ describe('instantiateAppNodeTree', () => {
it('should refuse to create an instance with multiple undeclared inputs', () => {
const { warn } = withLogCollector(['warn'], () =>
createAppNodeInstance({
apis: testApis,
attachments: new Map([
[
'undeclared1',
@@ -552,6 +563,7 @@ describe('instantiateAppNodeTree', () => {
it('should refuse to create an instance with multiple inputs for required singleton', () => {
expect(() =>
createAppNodeInstance({
apis: testApis,
attachments: new Map([
[
'singleton',
@@ -587,6 +599,7 @@ describe('instantiateAppNodeTree', () => {
it('should refuse to create an instance with multiple inputs for optional singleton', () => {
expect(() =>
createAppNodeInstance({
apis: testApis,
attachments: new Map([
[
'singleton',
@@ -622,6 +635,7 @@ describe('instantiateAppNodeTree', () => {
it('should refuse to create an instance with multiple inputs that did not provide required data', () => {
expect(() =>
createAppNodeInstance({
apis: testApis,
attachments: new Map([
['singleton', [makeInstanceWithId(simpleExtension, undefined)]],
]),
@@ -713,12 +727,12 @@ describe('instantiateAppNodeTree', () => {
makeSpec(simpleExtension, { id: 'root-node' }),
]);
expect(tree.root.instance).not.toBeDefined();
instantiateAppNodeTree(tree.root);
instantiateAppNodeTree(tree.root, testApis);
expect(tree.root.instance).toBeDefined();
expect(tree.root.instance?.getData(testDataRef)).toBe('test');
// Multiple calls should have no effect
instantiateAppNodeTree(tree.root);
instantiateAppNodeTree(tree.root, testApis);
expect(tree.root.instance).toBeDefined();
});
@@ -727,7 +741,7 @@ describe('instantiateAppNodeTree', () => {
makeSpec(simpleExtension, { id: 'root-node', disabled: true }),
]);
expect(tree.root.instance).not.toBeDefined();
instantiateAppNodeTree(tree.root);
instantiateAppNodeTree(tree.root, testApis);
expect(tree.root.instance).not.toBeDefined();
});
@@ -757,7 +771,7 @@ describe('instantiateAppNodeTree', () => {
expect(tree.root.instance).not.toBeDefined();
expect(childNode?.instance).not.toBeDefined();
instantiateAppNodeTree(tree.root);
instantiateAppNodeTree(tree.root, testApis);
expect(tree.root.instance).toBeDefined();
expect(childNode?.instance).toBeDefined();
expect(tree.root.instance?.getData(inputMirrorDataRef)).toMatchObject({
@@ -765,7 +779,7 @@ describe('instantiateAppNodeTree', () => {
});
// Multiple calls should have no effect
instantiateAppNodeTree(tree.root);
instantiateAppNodeTree(tree.root, testApis);
expect(tree.root.instance).toBeDefined();
expect(childNode?.instance).toBeDefined();
});
@@ -801,7 +815,7 @@ describe('instantiateAppNodeTree', () => {
expect(tree.root.instance).not.toBeDefined();
expect(childNode?.instance).not.toBeDefined();
instantiateAppNodeTree(tree.root);
instantiateAppNodeTree(tree.root, testApis);
expect(tree.root.instance).toBeDefined();
expect(childNode?.instance).not.toBeDefined();
expect(tree.root.instance?.getData(inputMirrorDataRef)).toEqual({
@@ -815,6 +829,7 @@ describe('instantiateAppNodeTree', () => {
const instance = createAppNodeInstance({
node: makeNode(simpleExtension),
attachments,
apis: testApis,
});
expect(Array.from(instance.getDataRefs())).toEqual([testDataRef]);
@@ -854,6 +869,7 @@ describe('instantiateAppNodeTree', () => {
const instance = createAppNodeInstance({
attachments,
apis: testApis,
node: makeNode(
resolveExtensionDefinition(
createExtension({
@@ -912,6 +928,7 @@ describe('instantiateAppNodeTree', () => {
it('should refuse to create an extension with invalid config', () => {
expect(() =>
createAppNodeInstance({
apis: testApis,
node: makeNode(simpleExtension, {
config: { other: 'not-a-number' },
}),
@@ -925,6 +942,7 @@ describe('instantiateAppNodeTree', () => {
it('should forward extension factory errors', () => {
expect(() =>
createAppNodeInstance({
apis: testApis,
node: makeNode(
resolveExtensionDefinition(
createExtension({
@@ -950,6 +968,7 @@ describe('instantiateAppNodeTree', () => {
it('should refuse to create an instance with duplicate output', () => {
expect(() =>
createAppNodeInstance({
apis: testApis,
node: makeNode(
resolveExtensionDefinition(
createExtension({
@@ -973,6 +992,7 @@ describe('instantiateAppNodeTree', () => {
it('should refuse to create an instance without required', () => {
expect(() =>
createAppNodeInstance({
apis: testApis,
node: makeNode(
resolveExtensionDefinition(
createExtension({
@@ -996,6 +1016,7 @@ describe('instantiateAppNodeTree', () => {
it('should refuse to create an instance with unknown output data', () => {
expect(() =>
createAppNodeInstance({
apis: testApis,
node: makeNode(
resolveExtensionDefinition(
// @ts-expect-error
@@ -1020,6 +1041,7 @@ describe('instantiateAppNodeTree', () => {
it('should refuse to create an instance with missing required input', () => {
expect(() =>
createAppNodeInstance({
apis: testApis,
node: makeNode(
resolveExtensionDefinition(
createExtension({
@@ -1046,6 +1068,7 @@ describe('instantiateAppNodeTree', () => {
it('should warn when creating an instance with undeclared inputs', () => {
const { warn } = withLogCollector(['warn'], () =>
createAppNodeInstance({
apis: testApis,
attachments: new Map([
[
'declared',
@@ -1089,6 +1112,7 @@ describe('instantiateAppNodeTree', () => {
it('should refuse to create an instance with multiple undeclared inputs', () => {
const { warn } = withLogCollector(['warn'], () =>
createAppNodeInstance({
apis: testApis,
attachments: new Map([
[
'undeclared1',
@@ -1125,6 +1149,7 @@ describe('instantiateAppNodeTree', () => {
it('should refuse to create an instance with multiple inputs for required singleton', () => {
expect(() =>
createAppNodeInstance({
apis: testApis,
attachments: new Map([
[
'singleton',
@@ -1159,6 +1184,7 @@ describe('instantiateAppNodeTree', () => {
it('should refuse to create an instance with multiple inputs for optional singleton', () => {
expect(() =>
createAppNodeInstance({
apis: testApis,
attachments: new Map([
[
'singleton',
@@ -1194,6 +1220,7 @@ describe('instantiateAppNodeTree', () => {
it('should refuse to create an instance with multiple inputs that did not provide required data', () => {
expect(() =>
createAppNodeInstance({
apis: testApis,
attachments: new Map([
['singleton', [makeInstanceWithId(simpleExtension, undefined)]],
]),
@@ -16,6 +16,7 @@
import {
AnyExtensionDataRef,
ApiHolder,
ExtensionDataContainer,
ExtensionDataRef,
ExtensionInput,
@@ -242,9 +243,10 @@ function resolveV2Inputs(
/** @internal */
export function createAppNodeInstance(options: {
node: AppNode;
apis: ApiHolder;
attachments: ReadonlyMap<string, AppNode[]>;
}): AppNodeInstance {
const { node, attachments } = options;
const { node, apis, attachments } = options;
const { id, extension, config } = node.spec;
const extensionData = new Map<string, unknown>();
const extensionDataRefs = new Set<ExtensionDataRef<unknown>>();
@@ -268,6 +270,7 @@ export function createAppNodeInstance(options: {
if (internalExtension.version === 'v1') {
const namedOutputs = internalExtension.factory({
node,
apis,
config: parsedConfig,
inputs: resolveV1Inputs(internalExtension.inputs, attachments),
});
@@ -288,6 +291,7 @@ export function createAppNodeInstance(options: {
} else if (internalExtension.version === 'v2') {
const outputDataValues = internalExtension.factory({
node,
apis,
config: parsedConfig,
inputs: resolveV2Inputs(internalExtension.inputs, attachments),
});
@@ -349,7 +353,10 @@ export function createAppNodeInstance(options: {
* Starting at the provided node, instantiate all reachable nodes in the tree that have not been disabled.
* @internal
*/
export function instantiateAppNodeTree(rootNode: AppNode): void {
export function instantiateAppNodeTree(
rootNode: AppNode,
apis: ApiHolder,
): void {
function createInstance(node: AppNode): AppNodeInstance | undefined {
if (node.instance) {
return node.instance;
@@ -375,6 +382,7 @@ export function instantiateAppNodeTree(rootNode: AppNode): void {
(node as Mutable<AppNode>).instance = createAppNodeInstance({
node,
apis,
attachments: instantiatedAttachments,
});
@@ -32,7 +32,7 @@ import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/res
/** @internal */
export function resolveAppNodeSpecs(options: {
features?: FrontendFeature[];
builtinExtensions?: Extension<unknown>[];
builtinExtensions?: Extension<any, any>[];
parameters?: Array<ExtensionParameters>;
forbidden?: Set<string>;
}): AppNodeSpec[] {
@@ -255,28 +255,32 @@ describe('createApp', () => {
const { tree } = appTreeApi!.getTree();
expect(String(tree.root)).toMatchInlineSnapshot(`
"<app out=[core.reactElement]>
root [
<app/root out=[core.reactElement]>
children [
<app/layout out=[core.reactElement]>
content [
<app/routes out=[core.reactElement]>
routes [
<page:my-plugin out=[core.routing.path, core.reactElement] />
"<root>
app [
<app out=[core.reactElement]>
root [
<app/root out=[core.reactElement]>
children [
<app/layout out=[core.reactElement]>
content [
<app/routes out=[core.reactElement]>
routes [
<page:my-plugin out=[core.routing.path, core.reactElement] />
]
</app/routes>
]
</app/routes>
nav [
<app/nav out=[core.reactElement] />
]
</app/layout>
]
nav [
<app/nav out=[core.reactElement] />
elements [
<app-root-element:app/oauth-request-dialog out=[core.reactElement] />
<app-root-element:app/alert-display out=[core.reactElement] />
]
</app/layout>
</app/root>
]
elements [
<app-root-element:app/oauth-request-dialog out=[core.reactElement] />
<app-root-element:app/alert-display out=[core.reactElement] />
]
</app/root>
</app>
]
apis [
<api:app-theme out=[core.api.factory]>
@@ -315,7 +319,7 @@ describe('createApp', () => {
<api:core.auth.vmware-cloud out=[core.api.factory] />
<api:plugin.permission.api out=[core.api.factory] />
]
</app>"
</root>"
`);
});
@@ -19,10 +19,18 @@ import { ConfigReader } from '@backstage/config';
import {
ApiBlueprint,
AppTree,
AppTreeApi,
appTreeApiRef,
coreExtensionData,
FrontendFeature,
RouteRef,
ExternalRouteRef,
SubRouteRef,
AnyRouteRefParams,
RouteFunc,
RouteResolutionApiResolveOptions,
RouteResolutionApi,
createApiFactory,
routeResolutionApiRef,
} from '@backstage/frontend-plugin-api';
import { App } from '../extensions/App';
@@ -73,7 +81,6 @@ import { CreateAppRouteBinder } from '../routing';
import { RouteResolver } from '../routing/RouteResolver';
import { resolveRouteBindings } from '../routing/resolveRouteBindings';
import { collectRouteIds } from '../routing/collectRouteIds';
import { createAppTree } from '../tree';
import {
DefaultProgressComponent,
DefaultErrorBoundaryComponent,
@@ -93,12 +100,20 @@ import { TranslationsApi } from '../extensions/TranslationsApi';
import { ComponentsApi } from '../extensions/ComponentsApi';
import { AppLanguageApi } from '../extensions/AppLanguageApi';
import { FeatureFlagsApi } from '../extensions/FeatureFlagsApi';
import { Root } from '../extensions/Root';
import { resolveAppTree } from '../tree/resolveAppTree';
import { resolveAppNodeSpecs } from '../tree/resolveAppNodeSpecs';
import { readAppExtensionsConfig } from '../tree/readAppExtensionsConfig';
import { instantiateAppNodeTree } from '../tree/instantiateAppNodeTree';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { ApiRegistry } from '../../../core-app-api/src/apis/system/ApiRegistry';
const DefaultApis = defaultApis.map(factory =>
ApiBlueprint.make({ namespace: factory.api.id, params: { factory } }),
);
export const builtinExtensions = [
Root,
App,
AppRoot,
AppRoutes,
@@ -230,6 +245,70 @@ export function createApp(options?: {
};
}
// Helps delay callers from reaching out to the API before the app tree has been materialized
class AppTreeApiProxy implements AppTreeApi {
#safeToUse: boolean = false;
constructor(private readonly tree: AppTree) {}
getTree() {
if (!this.#safeToUse) {
throw new Error(
`You can't access the AppTreeApi during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
);
}
return { tree: this.tree };
}
initialize() {
this.#safeToUse = true;
}
}
// Helps delay callers from reaching out to the API before the app tree has been materialized
class RouteResolutionApiProxy implements RouteResolutionApi {
#delegate: RouteResolutionApi | undefined;
constructor(
private readonly tree: AppTree,
private readonly routeBindings: Map<
ExternalRouteRef,
RouteRef | SubRouteRef
>,
private readonly basePath: string,
) {}
resolve<TParams extends AnyRouteRefParams>(
anyRouteRef:
| RouteRef<TParams>
| SubRouteRef<TParams>
| ExternalRouteRef<TParams>,
options?: RouteResolutionApiResolveOptions,
): RouteFunc<TParams> | undefined {
if (!this.#delegate) {
throw new Error(
`You can't access the RouteResolver during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
);
}
return this.#delegate.resolve(anyRouteRef, options);
}
initialize() {
const routeInfo = extractRouteInfoFromAppNode(this.tree.root);
this.#delegate = new RouteResolver(
routeInfo.routePaths,
routeInfo.routeParents,
routeInfo.routeObjects,
this.routeBindings,
this.basePath,
);
return routeInfo;
}
}
/**
* Synchronous version of {@link createApp}, expecting all features and
* config to have been loaded already.
@@ -248,32 +327,46 @@ export function createSpecializedApp(options?: {
const features = deduplicateFeatures(duplicatedFeatures);
const tree = createAppTree({
features,
builtinExtensions,
config,
});
const tree = resolveAppTree(
'root',
resolveAppNodeSpecs({
features,
builtinExtensions,
parameters: readAppExtensionsConfig(config),
forbidden: new Set(['root']),
}),
);
const routeInfo = extractRouteInfoFromAppNode(tree.root);
const routeBindings = resolveRouteBindings(
options?.bindRoutes,
config,
collectRouteIds(features),
const factories = createApiFactories({ tree });
const appTreeApi = new AppTreeApiProxy(tree);
const routeResolutionApi = new RouteResolutionApiProxy(
tree,
resolveRouteBindings(
options?.bindRoutes,
config,
collectRouteIds(features),
),
getBasePath(config),
);
const appIdentityProxy = new AppIdentityProxy();
const apiHolder = createApiHolder(
tree,
config,
appIdentityProxy,
new RouteResolver(
routeInfo.routePaths,
routeInfo.routeParents,
routeInfo.routeObjects,
routeBindings,
getBasePath(config),
),
);
const apiHolder = createApiHolder({
factories,
staticFactories: [
createApiFactory(appTreeApiRef, appTreeApi),
createApiFactory(configApiRef, config),
createApiFactory(routeResolutionApiRef, routeResolutionApi),
createApiFactory(identityApiRef, appIdentityProxy),
],
});
for (const appNode of tree.root.edges.attachments.get('app') ?? []) {
instantiateAppNodeTree(appNode, apiHolder);
}
const routeInfo = routeResolutionApi.initialize();
appTreeApi.initialize();
if (isProtectedApp()) {
const discoveryApi = apiHolder.get(discoveryApiRef);
@@ -310,7 +403,9 @@ export function createSpecializedApp(options?: {
}
}
const rootEl = tree.root.instance!.getData(coreExtensionData.reactElement);
const rootEl = tree.root.edges.attachments
.get('app')![0]
.instance!.getData(coreExtensionData.reactElement);
const AppComponent = () => (
<ApiProvider apis={apiHolder}>
@@ -331,49 +426,37 @@ export function createSpecializedApp(options?: {
};
}
function createApiHolder(
tree: AppTree,
configApi: ConfigApi,
appIdentityProxy: AppIdentityProxy,
routeResolutionApi: RouteResolutionApi,
): ApiHolder {
function createApiFactories(options: { tree: AppTree }): AnyApiFactory[] {
const emptyApiHolder = ApiRegistry.from([]);
const factories = new Array<AnyApiFactory>();
for (const apiNode of options.tree.root.edges.attachments.get('apis') ?? []) {
instantiateAppNodeTree(apiNode, emptyApiHolder);
const apiFactory = apiNode.instance?.getData(ApiBlueprint.dataRefs.factory);
if (!apiFactory) {
throw new Error(
`No API factory found in for extension ${apiNode.spec.id}`,
);
}
factories.push(apiFactory);
}
return factories;
}
function createApiHolder(options: {
factories: AnyApiFactory[];
staticFactories: AnyApiFactory[];
}): ApiHolder {
const factoryRegistry = new ApiFactoryRegistry();
const pluginApis =
tree.root.edges.attachments
.get('apis')
?.map(e => e.instance?.getData(ApiBlueprint.dataRefs.factory))
.filter((x): x is AnyApiFactory => !!x) ?? [];
for (const factory of pluginApis) {
for (const factory of options.factories) {
factoryRegistry.register('default', factory);
}
factoryRegistry.register('static', {
api: identityApiRef,
deps: {},
factory: () => appIdentityProxy,
});
factoryRegistry.register('static', {
api: appTreeApiRef,
deps: {},
factory: () => ({
getTree: () => ({ tree }),
}),
});
factoryRegistry.register('static', {
api: routeResolutionApiRef,
deps: {},
factory: () => routeResolutionApi,
});
factoryRegistry.register('static', {
api: configApiRef,
deps: {},
factory: () => configApi,
});
for (const factory of options.staticFactories) {
factoryRegistry.register('static', factory);
}
ApiResolver.validateFactories(factoryRegistry, factoryRegistry.getAllApis());
@@ -650,6 +650,7 @@ export type CreateExtensionBlueprintOptions<
params: TParams,
context: {
node: AppNode;
apis: ApiHolder;
config: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
};
@@ -730,6 +731,7 @@ export type CreateExtensionOptions<
};
factory(context: {
node: AppNode;
apis: ApiHolder;
config: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
};
@@ -953,6 +955,7 @@ export interface ExtensionBlueprint<
) => ExtensionDataContainer<UOutput>,
context: {
node: AppNode;
apis: ApiHolder;
config: TConfig & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
@@ -1145,6 +1148,7 @@ export interface ExtensionDefinition<
}) => ExtensionDataContainer<UOutput>,
context: {
node: AppNode;
apis: ApiHolder;
config: TConfig & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
@@ -38,7 +38,7 @@ describe('ApiBlueprint', () => {
{
"$$type": "@backstage/ExtensionDefinition",
"attachTo": {
"id": "app",
"id": "root",
"input": "apis",
},
"configSchema": undefined,
@@ -87,7 +87,7 @@ describe('ApiBlueprint', () => {
{
"$$type": "@backstage/ExtensionDefinition",
"attachTo": {
"id": "app",
"id": "root",
"input": "apis",
},
"configSchema": {
@@ -28,7 +28,7 @@ const factoryDataRef = createExtensionDataRef<AnyApiFactory>().with({
*/
export const ApiBlueprint = createExtensionBlueprint({
kind: 'api',
attachTo: { id: 'app', input: 'apis' },
attachTo: { id: 'root', input: 'apis' },
output: [factoryDataRef],
dataRefs: {
factory: factoryDataRef,
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { AppNode } from '../apis';
import { ApiHolder, AppNode } from '../apis';
import { PortableSchema } from '../schema';
import { Expand } from '../types';
import {
@@ -133,6 +133,7 @@ export type CreateExtensionOptions<
};
factory(context: {
node: AppNode;
apis: ApiHolder;
config: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
};
@@ -203,6 +204,7 @@ export interface ExtensionDefinition<
}) => ExtensionDataContainer<UOutput>,
context: {
node: AppNode;
apis: ApiHolder;
config: TConfig & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
@@ -273,6 +275,7 @@ export type InternalExtensionDefinition<
};
factory(context: {
node: AppNode;
apis: ApiHolder;
config: TConfig;
inputs: {
[inputName in string]: unknown;
@@ -292,6 +295,7 @@ export type InternalExtensionDefinition<
readonly output: Array<AnyExtensionDataRef>;
factory(context: {
node: AppNode;
apis: ApiHolder;
config: TConfig;
inputs: ResolvedExtensionInputs<{
[inputName in string]: ExtensionInput<
@@ -437,10 +441,11 @@ export function createExtension<
},
}
: undefined,
factory: ({ node, config, inputs }) => {
factory: ({ node, apis, config, inputs }) => {
if (!overrideOptions.factory) {
return newOptions.factory({
node,
apis,
config: config as any,
inputs: inputs as any,
});
@@ -450,6 +455,7 @@ export function createExtension<
return createExtensionDataContainer<UOutput>(
newOptions.factory({
node,
apis,
config: (innerContext?.config ?? config) as any,
inputs: resolveInputOverrides(
newOptions.inputs,
@@ -462,6 +468,7 @@ export function createExtension<
},
{
node,
apis,
config: config as any,
inputs: inputs as any,
},
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { AppNode } from '../apis';
import { ApiHolder, AppNode } from '../apis';
import { Expand } from '../types';
import {
ExtensionDefinition,
@@ -70,6 +70,7 @@ export type CreateExtensionBlueprintOptions<
params: TParams,
context: {
node: AppNode;
apis: ApiHolder;
config: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
};
@@ -172,6 +173,7 @@ export interface ExtensionBlueprint<
) => ExtensionDataContainer<UOutput>,
context: {
node: AppNode;
apis: ApiHolder;
config: TConfig & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
@@ -300,11 +302,12 @@ export function createExtensionBlueprint<
},
}
: undefined,
factory: ({ node, config, inputs }) => {
factory: ({ node, config, inputs, apis }) => {
return args.factory(
(innerParams, innerContext) => {
return createExtensionDataContainer<UOutput>(
options.factory(innerParams, {
apis,
node,
config: (innerContext?.config ?? config) as any,
inputs: resolveInputOverrides(
@@ -317,6 +320,7 @@ export function createExtensionBlueprint<
);
},
{
apis,
node,
config: config as any,
inputs: inputs as any,
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { AppNode } from '../apis';
import { ApiHolder, AppNode } from '../apis';
import {
ExtensionDefinition,
ResolvedExtensionInputs,
@@ -57,6 +57,7 @@ export type InternalExtension<TConfig, TConfigInput> = Extension<
[name in string]: AnyExtensionDataRef;
};
factory(context: {
apis: ApiHolder;
node: AppNode;
config: TConfig;
inputs: {
@@ -76,6 +77,7 @@ export type InternalExtension<TConfig, TConfigInput> = Extension<
};
readonly output: Array<AnyExtensionDataRef>;
factory(options: {
apis: ApiHolder;
node: AppNode;
config: TConfig;
inputs: ResolvedExtensionInputs<{
@@ -49,6 +49,7 @@ import { resolveAppNodeSpecs } from '../../../frontend-app-api/src/tree/resolveA
import { instantiateAppNodeTree } from '../../../frontend-app-api/src/tree/instantiateAppNodeTree';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { readAppExtensionsConfig } from '../../../frontend-app-api/src/tree/readAppExtensionsConfig';
import { TestApiRegistry } from '@backstage/test-utils';
const NavItem = (props: {
routeRef: RouteRef<undefined>;
@@ -320,7 +321,7 @@ export class ExtensionTester<UOutput extends AnyExtensionDataRef> {
}),
);
instantiateAppNodeTree(tree.root);
instantiateAppNodeTree(tree.root, TestApiRegistry.from());
this.#tree = tree;