From 230bfb58edd31e1030c22b00f32dc6b239e07807 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 31 Jul 2025 10:54:08 +0200 Subject: [PATCH] frontend-app-api: rework route aliases to work for resolution and restrict to same plugin Signed-off-by: Patrik Oldsberg --- .../src/routing/RouteAliasResolver.ts | 90 +++++++++++++++++++ .../src/routing/RouteResolver.test.ts | 46 +++++++++- .../src/routing/RouteResolver.ts | 6 +- .../extractRouteInfoFromAppNode.test.ts | 33 ++++++- .../routing/extractRouteInfoFromAppNode.ts | 60 +++++-------- .../src/wiring/createSpecializedApp.tsx | 7 +- packages/frontend-app-api/src/wiring/types.ts | 2 + 7 files changed, 197 insertions(+), 47 deletions(-) create mode 100644 packages/frontend-app-api/src/routing/RouteAliasResolver.ts diff --git a/packages/frontend-app-api/src/routing/RouteAliasResolver.ts b/packages/frontend-app-api/src/routing/RouteAliasResolver.ts new file mode 100644 index 0000000000..64e5eb7ce8 --- /dev/null +++ b/packages/frontend-app-api/src/routing/RouteAliasResolver.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2025 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 { RouteRef } from '@backstage/frontend-plugin-api'; +import { RouteRefsById } from './collectRouteIds'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { toInternalRouteRef } from '../../../frontend-plugin-api/src/routing/RouteRef'; + +/** + * @internal + */ +export type RouteAliasResolver = { + (routeRef: RouteRef, pluginId?: string): RouteRef; + (routeRef?: RouteRef, pluginId?: string): RouteRef | undefined; +}; + +/** + * Creates a route alias resolver that resolves aliases based on the route IDs + * @internal + */ +export function createRouteAliasResolver( + routeRefsById: RouteRefsById, +): RouteAliasResolver { + const resolver = (routeRef: RouteRef | undefined, pluginId?: string) => { + if (!routeRef) { + return undefined; + } + + let currentRef = routeRef; + for (let i = 0; i < 100; i++) { + const alias = toInternalRouteRef(currentRef).alias; + if (alias) { + if (pluginId) { + const [aliasPluginId] = alias.split('.'); + if (aliasPluginId !== pluginId) { + throw new Error( + `Refused to resolve alias '${alias}' for ${currentRef} as it points to a different plugin, the expected plugin is '${pluginId}' but the alias points to '${aliasPluginId}'`, + ); + } + } + const aliasRef = routeRefsById.routes.get(alias); + if (!aliasRef) { + throw new Error( + `Unable to resolve RouteRef alias '${alias}' for ${currentRef}`, + ); + } + if (aliasRef.$$type === '@backstage/SubRouteRef') { + throw new Error( + `RouteRef alias '${alias}' for ${currentRef} points to a SubRouteRef, which is not supported`, + ); + } + currentRef = aliasRef; + } else { + return currentRef; + } + } + throw new Error(`Alias loop detected for ${routeRef}`); + }; + + return resolver as RouteAliasResolver; +} + +/** + * Creates a route alias resolver that resolves aliases based on a map of route refs to their aliases + * @internal + */ +export function createExactRouteAliasResolver( + routeAliases: Map, +): RouteAliasResolver { + const resolver = (routeRef?: RouteRef) => { + if (routeRef && routeAliases.has(routeRef)) { + return routeAliases.get(routeRef); + } + return routeRef; + }; + return resolver as RouteAliasResolver; +} diff --git a/packages/frontend-app-api/src/routing/RouteResolver.test.ts b/packages/frontend-app-api/src/routing/RouteResolver.test.ts index 497e7397cb..fb5d75decf 100644 --- a/packages/frontend-app-api/src/routing/RouteResolver.test.ts +++ b/packages/frontend-app-api/src/routing/RouteResolver.test.ts @@ -25,6 +25,10 @@ import { import { BackstagePlugin } from '@backstage/core-plugin-api'; import { RouteResolver } from './RouteResolver'; import { MATCH_ALL_ROUTE } from './extractRouteInfoFromAppNode'; +import { + createExactRouteAliasResolver, + createRouteAliasResolver, +} from './RouteAliasResolver'; const rest = { element: null, @@ -47,9 +51,18 @@ function src(sourcePath: string) { return { sourcePath }; } +const emptyResolver = createExactRouteAliasResolver(new Map()); + describe('RouteResolver', () => { it('should not resolve anything with an empty resolver', () => { - const r = new RouteResolver(new Map(), new Map(), [], new Map(), ''); + const r = new RouteResolver( + new Map(), + new Map(), + [], + new Map(), + '', + emptyResolver, + ); expect(r.resolve(ref1, src('/'))?.()).toBe(undefined); expect(r.resolve(ref2, src('/'))?.({ x: '1x' })).toBe(undefined); @@ -70,6 +83,7 @@ describe('RouteResolver', () => { [{ routeRefs: new Set([ref1]), path: 'my-route', ...rest }], new Map(), '', + emptyResolver, ); expect(r.resolve(ref1, src('/'))?.()).toBe('/my-route'); @@ -109,6 +123,7 @@ describe('RouteResolver', () => { [externalRef2, subRef3], ]), '', + emptyResolver, ); expect(r.resolve(ref1, src('/'))?.()).toBe('/my-route'); @@ -131,6 +146,32 @@ describe('RouteResolver', () => { ); }); + it('should resolve a route with an alias', () => { + const r = new RouteResolver( + new Map([[ref1, 'my-route']]), + new Map(), + [ + { + routeRefs: new Set([ref1]), + path: 'my-route', + ...rest, + children: [], + }, + ], + new Map(), + '', + createRouteAliasResolver({ + routes: new Map([['test.ref1', ref1]]), + externalRoutes: new Map(), + }), + ); + + expect(r.resolve(ref1, src('/'))?.()).toBe('/my-route'); + expect( + r.resolve(createRouteRef({ aliasFor: 'test.ref1' }), src('/'))?.(), + ).toBe('/my-route'); + }); + it('should resolve the most specific match', () => { const r = new RouteResolver( new Map([ @@ -167,6 +208,7 @@ describe('RouteResolver', () => { ], new Map(), '', + emptyResolver, ); expect(r.resolve(ref2, src('/'))?.({ x: 'x' })).toBe('/root/x'); @@ -222,6 +264,7 @@ describe('RouteResolver', () => { [externalRef2, subRef3], ]), '', + emptyResolver, ); const l = '/my-grandparent/my-y/my-parent/my-x'; @@ -298,6 +341,7 @@ describe('RouteResolver', () => { ], new Map(), '/base', + emptyResolver, ); expect(r.resolve(ref2, src('/'))?.({ x: 'a/#&?b' })).toBe( diff --git a/packages/frontend-app-api/src/routing/RouteResolver.ts b/packages/frontend-app-api/src/routing/RouteResolver.ts index ff7966c4db..d4706a7bed 100644 --- a/packages/frontend-app-api/src/routing/RouteResolver.ts +++ b/packages/frontend-app-api/src/routing/RouteResolver.ts @@ -35,6 +35,7 @@ import { } from '../../../frontend-plugin-api/src/routing/SubRouteRef'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { isExternalRouteRef } from '../../../frontend-plugin-api/src/routing/ExternalRouteRef'; +import { RouteAliasResolver } from './RouteAliasResolver'; // Joins a list of paths together, avoiding trailing and duplicate slashes export function joinPaths(...paths: string[]): string { @@ -189,6 +190,7 @@ export class RouteResolver implements RouteResolutionApi { RouteRef | SubRouteRef >, private readonly appBasePath: string, // base path without a trailing slash + private readonly routeAliasResolver: RouteAliasResolver, ) {} resolve( @@ -200,7 +202,9 @@ export class RouteResolver implements RouteResolutionApi { ): RouteFunc | undefined { // First figure out what our target absolute ref is, as well as our target path. const [targetRef, targetPath] = resolveTargetRef( - anyRouteRef, + anyRouteRef?.$$type === '@backstage/RouteRef' + ? this.routeAliasResolver(anyRouteRef) + : anyRouteRef, this.routePaths, this.routeBindings, ); diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts index 8ac54559d2..349e1db2e6 100644 --- a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts @@ -38,6 +38,7 @@ import { instantiateAppNodeTree } from '../tree/instantiateAppNodeTree'; import { Root } from '../extensions/Root'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition'; +import { createRouteAliasResolver } from './RouteAliasResolver'; const ref1 = createRouteRef(); const ref2 = createRouteRef(); @@ -107,10 +108,13 @@ function routeInfoFromExtensions( instantiateAppNodeTree(tree.root, TestApiRegistry.from()); - return extractRouteInfoFromAppNode(tree.root, { - ...emptyRouteRefsById, - ...(routeRefsById && { routes: new Map(Object.entries(routeRefsById)) }), - }); + return extractRouteInfoFromAppNode( + tree.root, + createRouteAliasResolver({ + ...emptyRouteRefsById, + ...(routeRefsById && { routes: new Map(Object.entries(routeRefsById)) }), + }), + ); } function sortedEntries(map: Map): [RouteRef, T][] { @@ -684,6 +688,27 @@ describe('discovery', () => { ]); }); + it('should refuse to resolve aliases pointing to other plugins', () => { + expect(() => + routeInfoFromExtensions( + [ + // Source for this is the 'test' plugin + createTestExtension({ + name: 'page1', + path: 'page1', + routeRef: createRouteRef({ aliasFor: 'other.root' }), + }), + ], + + { + 'other.root': createRouteRef(), + }, + ), + ).toThrow( + /Refused to resolve alias 'other.root' for RouteRef{created at 'at .*extractRouteInfoFromAppNode\.test\.ts:\d+:\d+'} as it points to a different plugin, the expected plugin is 'test' but the alias points to 'other'/, + ); + }); + it('should bail on infinite route alias loops', () => { const loop1 = createRouteRef({ aliasFor: 'test.loop2' }); const loop2 = createRouteRef({ aliasFor: 'test.loop3' }); diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts index 03f3398ca9..74dfcf29a5 100644 --- a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts @@ -18,9 +18,10 @@ import { RouteRef, coreExtensionData } from '@backstage/frontend-plugin-api'; import { BackstageRouteObject } from './types'; import { AppNode } from '@backstage/frontend-plugin-api'; import { toLegacyPlugin } from './toLegacyPlugin'; -import { RouteRefsById } from './collectRouteIds'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { toInternalRouteRef } from '../../../frontend-plugin-api/src/routing/RouteRef'; +import { + createExactRouteAliasResolver, + RouteAliasResolver, +} from './RouteAliasResolver'; // We always add a child that matches all subroutes but without any route refs. This makes // sure that we're always able to match each route no matter how deep the navigation goes. @@ -43,43 +44,14 @@ export function joinPaths(...paths: string[]): string { return normalized; } -function createRouteAliasResolver(routeRefsById: RouteRefsById) { - return (routeRef?: RouteRef) => { - if (!routeRef) { - return undefined; - } - - let currentRef = routeRef; - for (let i = 0; i < 100; i++) { - const alias = toInternalRouteRef(currentRef).alias; - if (alias) { - const aliasRef = routeRefsById.routes.get(alias); - if (!aliasRef) { - throw new Error( - `Unable to resolve RouteRef alias '${alias}' for ${currentRef}`, - ); - } - if (aliasRef.$$type === '@backstage/SubRouteRef') { - throw new Error( - `RouteRef alias '${alias}' for ${currentRef} points to a SubRouteRef, which is not supported`, - ); - } - currentRef = aliasRef; - } else { - return currentRef; - } - } - throw new Error(`Alias loop detected for ${routeRef}`); - }; -} - export function extractRouteInfoFromAppNode( node: AppNode, - routeRefsById: RouteRefsById, + routeAliasResolver: RouteAliasResolver, ): { routePaths: Map; routeParents: Map; routeObjects: BackstageRouteObject[]; + routeAliasResolver: RouteAliasResolver; } { // This tracks the route path for each route ref, the value is the route path relative to the parent ref const routePaths = new Map(); @@ -89,8 +61,9 @@ export function extractRouteInfoFromAppNode( // This route object tree is passed to react-router in order to be able to look up the current route // ref or extension/source based on our current location. const routeObjects = new Array(); - - const routeAliasResolver = createRouteAliasResolver(routeRefsById); + // This tracks all resolved route aliases. By storing and re-using the resolutions here we make sure that it's not + // possible to pass an aliased route ref directly to the resolver, e.g. `useRouteRef(createRouteRef({ aliasFor: 'example.root' }))` + const routeAliases = new Map(); function visit( current: AppNode, @@ -104,9 +77,11 @@ export function extractRouteInfoFromAppNode( ?.getData(coreExtensionData.routePath) ?.replace(/^\//, ''); - const routeRef = routeAliasResolver( - current.instance?.getData(coreExtensionData.routeRef), - ); + const foundRouteRef = current.instance?.getData(coreExtensionData.routeRef); + const routeRef = routeAliasResolver(foundRouteRef, current.spec.plugin?.id); + if (foundRouteRef && routeRef !== foundRouteRef) { + routeAliases.set(foundRouteRef, routeRef); + } const parentChildren = parentObj?.children ?? routeObjects; let currentObj = parentObj; @@ -187,5 +162,10 @@ export function extractRouteInfoFromAppNode( visit(node); - return { routePaths, routeParents, routeObjects }; + return { + routePaths, + routeParents, + routeObjects, + routeAliasResolver: createExactRouteAliasResolver(routeAliases), + }; } diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index f7fac49625..5743200c21 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -81,6 +81,7 @@ import { createPluginInfoAttacher, FrontendPluginInfoResolver, } from './createPluginInfoAttacher'; +import { createRouteAliasResolver } from '../routing/RouteAliasResolver'; function deduplicateFeatures( allFeatures: FrontendFeature[], @@ -187,6 +188,7 @@ class RouteResolutionApiProxy implements RouteResolutionApi { routeInfo.routeObjects, this.routeBindings, this.appBasePath, + routeInfo.routeAliasResolver, ); this.#routeObjects = routeInfo.routeObjects; @@ -286,7 +288,10 @@ export function createSpecializedApp(options?: { mergeExtensionFactoryMiddleware(options?.extensionFactoryMiddleware), ); - const routeInfo = extractRouteInfoFromAppNode(tree.root, routeRefsById); + const routeInfo = extractRouteInfoFromAppNode( + tree.root, + createRouteAliasResolver(routeRefsById), + ); routeResolutionApi.initialize(routeInfo); appTreeApi.initialize(routeInfo); diff --git a/packages/frontend-app-api/src/wiring/types.ts b/packages/frontend-app-api/src/wiring/types.ts index 2bf08f327f..5192ef4139 100644 --- a/packages/frontend-app-api/src/wiring/types.ts +++ b/packages/frontend-app-api/src/wiring/types.ts @@ -16,6 +16,7 @@ import { RouteRef } from '@backstage/frontend-plugin-api'; import { FrontendFeature as PluginApiFrontendFeature } from '@backstage/frontend-plugin-api'; import { BackstageRouteObject } from '../routing/types'; +import { RouteAliasResolver } from '../routing/RouteAliasResolver'; /** @public * @deprecated Use {@link @backstage/frontend-plugin-api#FrontendFeature} instead. @@ -27,4 +28,5 @@ export type RouteInfo = { routePaths: Map; routeParents: Map; routeObjects: BackstageRouteObject[]; + routeAliasResolver: RouteAliasResolver; };