diff --git a/packages/frontend-app-api/src/extensions/App.tsx b/packages/frontend-app-api/src/extensions/App.tsx index e99b83eb5a..1eeba30f64 100644 --- a/packages/frontend-app-api/src/extensions/App.tsx +++ b/packages/frontend-app-api/src/extensions/App.tsx @@ -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, }), diff --git a/packages/frontend-app-api/src/extensions/Root.ts b/packages/frontend-app-api/src/extensions/Root.ts new file mode 100644 index 0000000000..e33f794cd6 --- /dev/null +++ b/packages/frontend-app-api/src/extensions/Root.ts @@ -0,0 +1,35 @@ +/* + * 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. + * 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 { + 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: () => [], +}); diff --git a/packages/frontend-app-api/src/tree/createAppTree.test.ts b/packages/frontend-app-api/src/tree/createAppTree.test.ts deleted file mode 100644 index f5df5270cd..0000000000 --- a/packages/frontend-app-api/src/tree/createAppTree.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/packages/frontend-app-api/src/tree/createAppTree.ts b/packages/frontend-app-api/src/tree/createAppTree.ts deleted file mode 100644 index ee9704c3c3..0000000000 --- a/packages/frontend-app-api/src/tree/createAppTree.ts +++ /dev/null @@ -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[]; - 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; -} diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts index fd8d324652..cc17bfae27 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts @@ -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; }): AppNodeInstance { - const { node, attachments } = options; + const { node, apis, attachments } = options; const { id, extension, config } = node.spec; const extensionData = new Map(); const extensionDataRefs = new Set>(); @@ -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).instance = createAppNodeInstance({ node, + apis, attachments: instantiatedAttachments, }); diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts index 0560066f6b..b92485c5d6 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts @@ -32,7 +32,7 @@ import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/res /** @internal */ export function resolveAppNodeSpecs(options: { features?: FrontendFeature[]; - builtinExtensions?: Extension[]; + builtinExtensions?: Extension[]; parameters?: Array; forbidden?: Set; }): AppNodeSpec[] { diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index 37594f74d4..21ad84a5f5 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -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 RouteResolverProxy implements RouteResolutionApi { + #delegate: RouteResolutionApi | undefined; + + constructor( + private readonly tree: AppTree, + private readonly routeBindings: Map< + ExternalRouteRef, + RouteRef | SubRouteRef + >, + private readonly basePath: string, + ) {} + + resolve( + anyRouteRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, + options?: RouteResolutionApiResolveOptions, + ): RouteFunc | 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 routeResolver = new RouteResolverProxy( + 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, routeResolver), + createApiFactory(identityApiRef, appIdentityProxy), + ], + }); + + for (const appNode of tree.root.edges.attachments.get('app') ?? []) { + instantiateAppNodeTree(appNode, apiHolder); + } + + const routeInfo = routeResolver.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 = () => ( @@ -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(); + + 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()); diff --git a/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.ts b/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.ts index 73eec80b81..1f28a3cf54 100644 --- a/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.ts +++ b/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.ts @@ -28,7 +28,7 @@ const factoryDataRef = createExtensionDataRef().with({ */ export const ApiBlueprint = createExtensionBlueprint({ kind: 'api', - attachTo: { id: 'app', input: 'apis' }, + attachTo: { id: 'root', input: 'apis' }, output: [factoryDataRef], dataRefs: { factory: factoryDataRef, diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 84438d2f85..9d1568dc2f 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -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>; }; @@ -203,6 +204,7 @@ export interface ExtensionDefinition< }) => ExtensionDataContainer, context: { node: AppNode; + apis: ApiHolder; config: TConfig & { [key in keyof TExtensionConfigSchema]: z.infer< ReturnType @@ -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; 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( 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, }, diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index 7cc1dcd678..25eb55d21f 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -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>; }; @@ -172,6 +173,7 @@ export interface ExtensionBlueprint< ) => ExtensionDataContainer, context: { node: AppNode; + apis: ApiHolder; config: TConfig & { [key in keyof TExtensionConfigSchema]: z.infer< ReturnType @@ -300,11 +302,12 @@ export function createExtensionBlueprint< }, } : undefined, - factory: ({ node, config, inputs }) => { + factory: ({ node, config, inputs, apis }) => { return args.factory( (innerParams, innerContext) => { return createExtensionDataContainer( 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, diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts index 3cbc28f8ff..fe25164603 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts @@ -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 = Extension< [name in string]: AnyExtensionDataRef; }; factory(context: { + apis: ApiHolder; node: AppNode; config: TConfig; inputs: { @@ -76,6 +77,7 @@ export type InternalExtension = Extension< }; readonly output: Array; factory(options: { + apis: ApiHolder; node: AppNode; config: TConfig; inputs: ResolvedExtensionInputs<{ diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.tsx index 63de46a76e..2e51466f49 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.tsx @@ -50,6 +50,9 @@ import { instantiateAppNodeTree } from '../../../frontend-app-api/src/tree/insta // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { readAppExtensionsConfig } from '../../../frontend-app-api/src/tree/readAppExtensionsConfig'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { ApiRegistry } from '../../../core-app-api/src/apis/system/ApiRegistry'; + const NavItem = (props: { routeRef: RouteRef; title: string; @@ -320,7 +323,7 @@ export class ExtensionTester { }), ); - instantiateAppNodeTree(tree.root); + instantiateAppNodeTree(tree.root, ApiRegistry.from([])); this.#tree = tree;