frontend-app-api: rework route aliases to work for resolution and restrict to same plugin

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-07-31 10:54:08 +02:00
parent 2cd4afea20
commit 230bfb58ed
7 changed files with 197 additions and 47 deletions
@@ -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<RouteRef, RouteRef | undefined>,
): RouteAliasResolver {
const resolver = (routeRef?: RouteRef) => {
if (routeRef && routeAliases.has(routeRef)) {
return routeAliases.get(routeRef);
}
return routeRef;
};
return resolver as RouteAliasResolver;
}
@@ -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<RouteRef, string>([[ref1, 'my-route']]),
new Map(),
[
{
routeRefs: new Set([ref1]),
path: 'my-route',
...rest,
children: [],
},
],
new Map<ExternalRouteRef, RouteRef | SubRouteRef>(),
'',
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<RouteRef, string>([
@@ -167,6 +208,7 @@ describe('RouteResolver', () => {
],
new Map<ExternalRouteRef, RouteRef | SubRouteRef>(),
'',
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(
@@ -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<TParams extends AnyRouteRefParams>(
@@ -200,7 +202,9 @@ export class RouteResolver implements RouteResolutionApi {
): RouteFunc<TParams> | 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,
);
@@ -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<T>(map: Map<RouteRef, T>): [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' });
@@ -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<RouteRef, string>;
routeParents: Map<RouteRef, RouteRef | undefined>;
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<RouteRef, string>();
@@ -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<BackstageRouteObject>();
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<RouteRef, RouteRef | undefined>();
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),
};
}
@@ -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);
@@ -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<RouteRef, string>;
routeParents: Map<RouteRef, RouteRef | undefined>;
routeObjects: BackstageRouteObject[];
routeAliasResolver: RouteAliasResolver;
};