diff --git a/packages/frontend-app-api/src/wiring/phaseApis.tsx b/packages/frontend-app-api/src/wiring/phaseApis.tsx new file mode 100644 index 0000000000..08eb20c17a --- /dev/null +++ b/packages/frontend-app-api/src/wiring/phaseApis.tsx @@ -0,0 +1,312 @@ +/* + * 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 { + AnyApiFactory, + ApiHolder, + AppTree, + AppTreeApi, + appTreeApiRef, + ConfigApi, + configApiRef, + createApiFactory, + ExternalRouteRef, + identityApiRef, + RouteFunc, + RouteRef, + RouteResolutionApi, + routeResolutionApiRef, + SubRouteRef, + type AnyRouteRefParams, + type AppNode, + type ExtensionFactoryMiddleware, + type IdentityApi, +} from '@backstage/frontend-plugin-api'; +import { matchRoutes } from 'react-router-dom'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy'; +import { createRouteAliasResolver } from '../routing/RouteAliasResolver'; +import { RouteResolver } from '../routing/RouteResolver'; +import { collectRouteIds } from '../routing/collectRouteIds'; +import { + extractRouteInfoFromAppNode, + type RouteInfo, +} from '../routing/extractRouteInfoFromAppNode'; +import { type BackstageRouteObject } from '../routing/types'; +import { instantiateAppNodeTree } from '../tree/instantiateAppNodeTree'; +import { + FrontendApiRegistry, + FrontendApiResolver, +} from './FrontendApiRegistry'; +import { type ExtensionPredicateContext } from './predicates'; +import { type ErrorCollector } from './createErrorCollector'; + +// Helps delay callers from reaching out to the API before the app tree has been materialized +export class AppTreeApiProxy implements AppTreeApi { + #routeInfo?: RouteInfo; + private readonly tree: AppTree; + private readonly appBasePath: string; + + constructor(tree: AppTree, appBasePath: string) { + this.tree = tree; + this.appBasePath = appBasePath; + } + + private checkIfInitialized() { + if (!this.#routeInfo) { + 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`, + ); + } + } + + getTree() { + this.checkIfInitialized(); + + return { tree: this.tree }; + } + + getNodesByRoutePath(routePath: string): { nodes: AppNode[] } { + this.checkIfInitialized(); + const routeInfo = this.#routeInfo; + if (!routeInfo) { + 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`, + ); + } + + let path = routePath; + if (path.startsWith(this.appBasePath)) { + path = path.slice(this.appBasePath.length); + } + + const matchedRoutes = matchRoutes(routeInfo.routeObjects, path); + + const matchedAppNodes = + matchedRoutes?.flatMap(routeObj => { + const appNode = routeObj.route.appNode; + return appNode ? [appNode] : []; + }) || []; + + return { nodes: matchedAppNodes }; + } + + initialize(routeInfo: RouteInfo) { + this.#routeInfo = routeInfo; + } +} + +// Helps delay callers from reaching out to the API before the app tree has been materialized +export class RouteResolutionApiProxy implements RouteResolutionApi { + #delegate: RouteResolutionApi | undefined; + #routeObjects: BackstageRouteObject[] | undefined; + + private readonly routeBindings: Map; + private readonly appBasePath: string; + + constructor( + routeBindings: Map, + appBasePath: string, + ) { + this.routeBindings = routeBindings; + this.appBasePath = appBasePath; + } + + resolve( + anyRouteRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, + options?: { sourcePath?: string }, + ): 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( + routeInfo: RouteInfo, + routeRefsById: Map, + ) { + this.#delegate = new RouteResolver( + routeInfo.routePaths, + routeInfo.routeParents, + routeInfo.routeObjects, + this.routeBindings, + this.appBasePath, + routeInfo.routeAliasResolver, + routeRefsById, + ); + this.#routeObjects = routeInfo.routeObjects; + + return routeInfo; + } + + getRouteObjects() { + return this.#routeObjects; + } +} + +export class PreparedAppIdentityProxy extends AppIdentityProxy { + #onTargetSet?: + | (( + identityApi: Parameters[0], + ) => Promise | void) + | undefined; + #onTargetError?: ((error: unknown) => void) | undefined; + + setTargetHandlers(options: { + onTargetSet?( + identityApi: Parameters[0], + ): Promise | void; + onTargetError?(error: unknown): void; + }) { + this.#onTargetSet = options.onTargetSet; + this.#onTargetError = options.onTargetError; + } + + clearTargetHandlers() { + this.#onTargetSet = undefined; + this.#onTargetError = undefined; + } + + override setTarget( + identityApi: Parameters[0], + targetOptions: Parameters[1], + ) { + super.setTarget(identityApi, targetOptions); + + const onTargetSet = this.#onTargetSet; + if (!onTargetSet) { + return; + } + + const onTargetError = this.#onTargetError; + this.clearTargetHandlers(); + + try { + void Promise.resolve(onTargetSet(identityApi)).catch(error => { + onTargetError?.(error); + }); + } catch (error) { + onTargetError?.(error); + } + } +} + +export function createPhaseApis(options: { + tree: AppTree; + config: ConfigApi; + appApiRegistry: FrontendApiRegistry; + fallbackApis?: ApiHolder; + includeConfigApi: boolean; + appBasePath: string; + routeBindings: Map; + staticFactories: AnyApiFactory[]; +}) { + const appTreeApi = new AppTreeApiProxy(options.tree, options.appBasePath); + const routeResolutionApi = new RouteResolutionApiProxy( + options.routeBindings, + options.appBasePath, + ); + const identityProxy = new PreparedAppIdentityProxy(); + const phaseApiRegistry = new FrontendApiRegistry(); + phaseApiRegistry.registerAll([ + createApiFactory(appTreeApiRef, appTreeApi), + ...(options.includeConfigApi + ? [createApiFactory(configApiRef, options.config)] + : []), + createApiFactory(routeResolutionApiRef, routeResolutionApi), + createApiFactory(identityApiRef, identityProxy), + ...options.staticFactories, + ]); + + const apis = new FrontendApiResolver({ + primaryRegistry: phaseApiRegistry, + secondaryRegistry: options.appApiRegistry, + fallbackApis: options.fallbackApis, + }); + + return { + apis, + routeResolutionApi, + appTreeApi, + identityApiProxy: identityProxy, + }; +} + +export function instantiateAndInitializePhaseTree(options: { + tree: AppTree; + apis: ApiHolder; + collector: ErrorCollector; + extensionFactoryMiddleware?: ExtensionFactoryMiddleware; + routeResolutionApi: RouteResolutionApiProxy; + appTreeApi: AppTreeApiProxy; + routeRefsById: ReturnType; + stopAtSessionBoundary?: boolean; + skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean; + onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void; + predicateContext?: ExtensionPredicateContext; + stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean; +}) { + let stopAtAttachment = options.stopAtAttachment; + if (options.stopAtSessionBoundary) { + stopAtAttachment = ({ node, input }) => + isSessionBoundaryAttachment(node, input); + } + + instantiateAppNodeTree( + options.tree.root, + options.apis, + options.collector, + options.extensionFactoryMiddleware, + { + ...(stopAtAttachment ? { stopAtAttachment } : {}), + skipChild: options.skipChild, + onMissingApi: options.onMissingApi, + predicateContext: options.predicateContext, + }, + ); + + const routeInfo = extractRouteInfoFromAppNode( + options.tree.root, + createRouteAliasResolver(options.routeRefsById), + ); + + options.routeResolutionApi.initialize( + routeInfo, + options.routeRefsById.routes, + ); + options.appTreeApi.initialize(routeInfo); +} + +export function setIdentityApiTarget(options: { + identityApiProxy: AppIdentityProxy; + identityApi: IdentityApi; + signOutTargetUrl: string; +}) { + options.identityApiProxy.setTarget(options.identityApi, { + signOutTargetUrl: options.signOutTargetUrl, + }); +} + +function isSessionBoundaryAttachment(node: AppNode, input: string) { + return node.spec.id === 'app/root' && input === 'children'; +} diff --git a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx index b916545160..6f4655309e 100644 --- a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx @@ -20,19 +20,8 @@ import { AnyApiFactory, ApiHolder, AppTree, - AppTreeApi, - appTreeApiRef, ConfigApi, - configApiRef, coreExtensionData, - RouteRef, - ExternalRouteRef, - SubRouteRef, - AnyRouteRefParams, - RouteFunc, - RouteResolutionApi, - createApiFactory, - routeResolutionApiRef, AppNode, AppNodeInstance, ExtensionDataRef, @@ -57,13 +46,7 @@ import { toInternalExtension, } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition'; -import { - extractRouteInfoFromAppNode, - RouteInfo, -} from '../routing/extractRouteInfoFromAppNode'; - import { CreateAppRouteBinder } from '../routing'; -import { RouteResolver } from '../routing/RouteResolver'; import { resolveRouteBindings } from '../routing/resolveRouteBindings'; import { collectRouteIds } from '../routing/collectRouteIds'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports @@ -76,24 +59,23 @@ import { Root } from '../extensions/Root'; import { resolveAppTree } from '../tree/resolveAppTree'; import { resolveAppNodeSpecs } from '../tree/resolveAppNodeSpecs'; import { readAppExtensionsConfig } from '../tree/readAppExtensionsConfig'; -import { - instantiateAppNodeSubtree, - instantiateAppNodeTree, -} from '../tree/instantiateAppNodeTree'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy'; -import { BackstageRouteObject } from '../routing/types'; -import { matchRoutes } from 'react-router-dom'; +import { instantiateAppNodeSubtree } from '../tree/instantiateAppNodeTree'; import { createPluginInfoAttacher, FrontendPluginInfoResolver, } from './createPluginInfoAttacher'; -import { createRouteAliasResolver } from '../routing/RouteAliasResolver'; import { AppError, createErrorCollector, ErrorCollector, } from './createErrorCollector'; +import { + AppTreeApiProxy, + createPhaseApis, + instantiateAndInitializePhaseTree, + RouteResolutionApiProxy, + setIdentityApiTarget, +} from './phaseApis'; import { collectPredicateReferences, createPredicateContextLoader, @@ -197,163 +179,6 @@ const signInPageComponentDataRef = createExtensionDataRef< ComponentType >().with({ id: 'core.sign-in-page.component' }); -// Helps delay callers from reaching out to the API before the app tree has been materialized -class AppTreeApiProxy implements AppTreeApi { - #routeInfo?: RouteInfo; - private readonly tree: AppTree; - private readonly appBasePath: string; - - constructor(tree: AppTree, appBasePath: string) { - this.tree = tree; - this.appBasePath = appBasePath; - } - - private checkIfInitialized() { - if (!this.#routeInfo) { - 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`, - ); - } - } - - getTree() { - this.checkIfInitialized(); - - return { tree: this.tree }; - } - - getNodesByRoutePath(routePath: string): { nodes: AppNode[] } { - this.checkIfInitialized(); - const routeInfo = this.#routeInfo; - if (!routeInfo) { - 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`, - ); - } - - let path = routePath; - if (path.startsWith(this.appBasePath)) { - path = path.slice(this.appBasePath.length); - } - - const matchedRoutes = matchRoutes(routeInfo.routeObjects, path); - - const matchedAppNodes = - matchedRoutes?.flatMap(routeObj => { - const appNode = routeObj.route.appNode; - return appNode ? [appNode] : []; - }) || []; - - return { nodes: matchedAppNodes }; - } - - initialize(routeInfo: RouteInfo) { - this.#routeInfo = routeInfo; - } -} - -// Helps delay callers from reaching out to the API before the app tree has been materialized -class RouteResolutionApiProxy implements RouteResolutionApi { - #delegate: RouteResolutionApi | undefined; - #routeObjects: BackstageRouteObject[] | undefined; - - private readonly routeBindings: Map; - private readonly appBasePath: string; - - constructor( - routeBindings: Map, - appBasePath: string, - ) { - this.routeBindings = routeBindings; - this.appBasePath = appBasePath; - } - - resolve( - anyRouteRef: - | RouteRef - | SubRouteRef - | ExternalRouteRef, - options?: { sourcePath?: string }, - ): 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( - routeInfo: RouteInfo, - routeRefsById: Map, - ) { - this.#delegate = new RouteResolver( - routeInfo.routePaths, - routeInfo.routeParents, - routeInfo.routeObjects, - this.routeBindings, - this.appBasePath, - routeInfo.routeAliasResolver, - routeRefsById, - ); - this.#routeObjects = routeInfo.routeObjects; - - return routeInfo; - } - - getRouteObjects() { - return this.#routeObjects; - } -} - -class PreparedAppIdentityProxy extends AppIdentityProxy { - #onTargetSet?: - | (( - identityApi: Parameters[0], - ) => Promise | void) - | undefined; - #onTargetError?: ((error: unknown) => void) | undefined; - - setTargetHandlers(options: { - onTargetSet?( - identityApi: Parameters[0], - ): Promise | void; - onTargetError?(error: unknown): void; - }) { - this.#onTargetSet = options.onTargetSet; - this.#onTargetError = options.onTargetError; - } - - clearTargetHandlers() { - this.#onTargetSet = undefined; - this.#onTargetError = undefined; - } - - override setTarget( - identityApi: Parameters[0], - targetOptions: Parameters[1], - ) { - super.setTarget(identityApi, targetOptions); - - const onTargetSet = this.#onTargetSet; - if (!onTargetSet) { - return; - } - - const onTargetError = this.#onTargetError; - this.clearTargetHandlers(); - - try { - void Promise.resolve(onTargetSet(identityApi)).catch(error => { - onTargetError?.(error); - }); - } catch (error) { - onTargetError?.(error); - } - } -} - /** * Options for {@link prepareSpecializedApp}. * @@ -1051,95 +876,6 @@ function createBootstrapApp(options: { }; } -function createPhaseApis(options: { - tree: AppTree; - config: ConfigApi; - appApiRegistry: FrontendApiRegistry; - fallbackApis?: ApiHolder; - includeConfigApi: boolean; - appBasePath: string; - routeBindings: Map; - staticFactories: AnyApiFactory[]; -}) { - const appTreeApi = new AppTreeApiProxy(options.tree, options.appBasePath); - const routeResolutionApi = new RouteResolutionApiProxy( - options.routeBindings, - options.appBasePath, - ); - const identityProxy = new PreparedAppIdentityProxy(); - const phaseApiRegistry = new FrontendApiRegistry(); - phaseApiRegistry.registerAll([ - createApiFactory(appTreeApiRef, appTreeApi), - ...(options.includeConfigApi - ? [createApiFactory(configApiRef, options.config)] - : []), - createApiFactory(routeResolutionApiRef, routeResolutionApi), - createApiFactory(identityApiRef, identityProxy), - ...options.staticFactories, - ]); - - const apis = new FrontendApiResolver({ - primaryRegistry: phaseApiRegistry, - secondaryRegistry: options.appApiRegistry, - fallbackApis: options.fallbackApis, - }); - - return { - apis, - routeResolutionApi, - appTreeApi, - identityApiProxy: identityProxy, - }; -} - -function instantiateAndInitializePhaseTree(options: { - tree: AppTree; - apis: ApiHolder; - collector: ErrorCollector; - extensionFactoryMiddleware?: ExtensionFactoryMiddleware; - routeResolutionApi: RouteResolutionApiProxy; - appTreeApi: AppTreeApiProxy; - routeRefsById: ReturnType; - stopAtSessionBoundary?: boolean; - skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean; - onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void; - predicateContext?: ExtensionPredicateContext; -}) { - instantiateAppNodeTree( - options.tree.root, - options.apis, - options.collector, - options.extensionFactoryMiddleware, - { - ...(options.stopAtSessionBoundary - ? { - stopAtAttachment: ({ - node, - input, - }: { - node: AppNode; - input: string; - }) => isSessionBoundaryAttachment(node, input), - } - : {}), - skipChild: options.skipChild, - onMissingApi: options.onMissingApi, - predicateContext: options.predicateContext, - }, - ); - - const routeInfo = extractRouteInfoFromAppNode( - options.tree.root, - createRouteAliasResolver(options.routeRefsById), - ); - - options.routeResolutionApi.initialize( - routeInfo, - options.routeRefsById.routes, - ); - options.appTreeApi.initialize(routeInfo); -} - function prepareFinalizedTree(options: { tree: AppTree }) { for (const appRootNode of getFinalizationBoundaryNodes(options.tree)) { const attachments = appRootNode.edges.attachments as Map; @@ -1267,16 +1003,6 @@ function asError(error: unknown): Error { return new Error(String(error)); } -function setIdentityApiTarget(options: { - identityApiProxy: AppIdentityProxy; - identityApi: IdentityApi; - signOutTargetUrl: string; -}) { - options.identityApiProxy.setTarget(options.identityApi, { - signOutTargetUrl: options.signOutTargetUrl, - }); -} - const EMPTY_API_HOLDER: ApiHolder = { get() { return undefined;