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:
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user