core-*-api: add support for default targets for external route refs and route binding through config

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-05-08 17:13:39 +02:00
parent f5c298dd3d
commit 35fbe097ea
11 changed files with 278 additions and 60 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-compat-api': patch
---
Add support for forwarding default target from legacy external route refs.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-plugin-api': patch
---
Added a new `defaultTarget` option to `createExternalRouteRef`. I lets you specify a default target of the route by name, for example `'catalog.catalogIndex'`, which will be used if the target route is present in the app and there is no explicit route binding.
+29
View File
@@ -0,0 +1,29 @@
---
'@backstage/core-app-api': patch
---
Added support for configuration of route bindings through static configuration, and default targets for external route refs.
In addition to configuring route bindings through code, it is now also possible to configure route bindings under the `app.routes.bindings` key, for example:
```yaml
app:
routes:
bindings:
catalog.createComponent: catalog-import.importPage
```
Each key in the route binding object is of the form `<plugin-id>.<externalRouteName>`, where the route name is key used in the `externalRoutes` object passed to `createPlugin`. The value is of the same form, but with the name taken from the plugin `routes` option instead.
The equivalent of the above configuration in code is the following:
```ts
const app = createApp({
// ...
bindRoutes({ bind }) {
bind(catalogPlugin.externalRoutes, {
createComponent: catalogImportPlugin.routes.importPage,
});
},
});
```
@@ -20,7 +20,7 @@ import {
renderWithEffects,
withLogCollector,
} from '@backstage/test-utils';
import { render, screen, waitFor, act } from '@testing-library/react';
import { screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { PropsWithChildren, ReactNode } from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
@@ -624,7 +624,7 @@ describe('Integration Test', () => {
expect(capturedEvents).toHaveLength(2);
});
it('should throw some error when the route has duplicate params', () => {
it('should throw some error when the route has duplicate params', async () => {
const app = new AppManager({
apis: [],
defaultApis: [],
@@ -641,31 +641,43 @@ describe('Integration Test', () => {
},
});
const expectedMessage =
'Parameter :thing is duplicated in path test/:thing/some/:thing';
const Provider = app.getProvider();
const Router = app.getRouter();
const { error: errorLogs } = withLogCollector(() => {
render(
<Provider>
<Router>
<Routes>
<Route path="/test/:thing" element={<ExposedComponent />}>
<Route path="/some/:thing" element={<HiddenComponent />} />
</Route>
</Routes>
</Router>
</Provider>,
);
const { error: errorLogs } = await withLogCollector(async () => {
await expect(() =>
renderWithEffects(
<Provider>
<Router>
<Routes>
<Route path="/test/:thing" element={<ExposedComponent />}>
<Route path="/some/:thing" element={<HiddenComponent />} />
</Route>
</Routes>
</Router>
</Provider>,
),
).rejects.toThrow(expectedMessage);
});
expect(errorLogs).toEqual([
expect.objectContaining({
message: expect.stringContaining(
'Parameter :thing is duplicated in path test/:thing/some/:thing',
),
detail: new Error(expectedMessage),
type: 'unhandled exception',
}),
expect.objectContaining({
detail: new Error(expectedMessage),
type: 'unhandled exception',
}),
expect.stringContaining(
'The above error occurred in the <Provider> component:',
),
]);
});
it('should throw an error when required external plugin routes are not bound', () => {
it('should throw an error when required external plugin routes are not bound', async () => {
const app = new AppManager({
apis: [],
defaultApis: [],
@@ -676,25 +688,36 @@ describe('Integration Test', () => {
configLoader: async () => [],
});
const expectedMessage =
"External route 'extRouteRef1' of the 'blob' plugin must be bound to a target route. See https://backstage.io/link?bind-routes for details.";
const Provider = app.getProvider();
const Router = app.getRouter();
const { error: errorLogs } = withLogCollector(() => {
render(
<Provider>
<Router>
<Routes>
<Route path="/test/:thing" element={<ExposedComponent />} />
</Routes>
</Router>
</Provider>,
);
const { error: errorLogs } = await withLogCollector(async () => {
await expect(() =>
renderWithEffects(
<Provider>
<Router>
<Routes>
<Route path="/test/:thing" element={<ExposedComponent />} />
</Routes>
</Router>
</Provider>,
),
).rejects.toThrow(expectedMessage);
});
expect(errorLogs).toEqual([
expect.objectContaining({
message: expect.stringMatching(
/^External route 'extRouteRef1' of the 'blob' plugin must be bound to a target route/,
),
detail: new Error(expectedMessage),
type: 'unhandled exception',
}),
expect.objectContaining({
detail: new Error(expectedMessage),
type: 'unhandled exception',
}),
expect.stringContaining(
'The above error occurred in the <Provider> component:',
),
]);
});
+24 -16
View File
@@ -233,8 +233,10 @@ export class AppManager implements BackstageApp {
const appContext = new AppContextImpl(this);
// We only validate routes once
let routesHaveBeenValidated = false;
// We only bind and validate routes once
let routeBindings: ReturnType<typeof resolveRouteBindings>;
// Store and keep throwing the same error if we encounter one
let routeValidationError: Error | undefined = undefined;
const Provider = ({ children }: PropsWithChildren<{}>) => {
const needsFeatureFlagRegistrationRef = useRef(true);
@@ -243,7 +245,7 @@ export class AppManager implements BackstageApp {
[],
);
const { routing, featureFlags, routeBindings } = useMemo(() => {
const { routing, featureFlags } = useMemo(() => {
const usesReactRouterBeta = isReactRouterBeta();
if (usesReactRouterBeta) {
// eslint-disable-next-line no-console
@@ -275,21 +277,9 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be
// Initialize APIs once all plugins are available
this.getApiHolder();
return {
...result,
routeBindings: resolveRouteBindings(this.bindRoutes),
};
return result;
}, [children]);
if (!routesHaveBeenValidated) {
routesHaveBeenValidated = true;
validateRouteParameters(routing.paths, routing.parents);
validateRouteBindings(
routeBindings,
this.plugins as Iterable<BackstagePlugin>,
);
}
const loadedConfig = useConfigLoader(
this.configLoader,
this.components,
@@ -307,6 +297,24 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be
return loadedConfig.node;
}
if (!routeBindings) {
routeBindings = resolveRouteBindings(
this.bindRoutes,
loadedConfig.api,
this.plugins,
);
try {
validateRouteParameters(routing.paths, routing.parents);
validateRouteBindings(routeBindings, this.plugins);
} catch (error) {
routeValidationError = error;
throw error;
}
} else if (routeValidationError) {
throw routeValidationError;
}
// We can't register feature flags just after the element traversal, because the
// config API isn't available yet and implementations frequently depend on it.
// Instead we make it happen immediately, to make sure all flags are available
@@ -16,17 +16,23 @@
import {
createExternalRouteRef,
createPlugin,
createRouteRef,
} from '@backstage/core-plugin-api';
import { resolveRouteBindings } from './resolveRouteBindings';
import { collectRouteIds, resolveRouteBindings } from './resolveRouteBindings';
import { MockConfigApi } from '@backstage/test-utils';
describe('resolveRouteBindings', () => {
it('runs happy path', () => {
const external = { myRoute: createExternalRouteRef({ id: '1' }) };
const ref = createRouteRef({ id: 'ref-1' });
const result = resolveRouteBindings(({ bind }) => {
bind(external, { myRoute: ref });
});
const result = resolveRouteBindings(
({ bind }) => {
bind(external, { myRoute: ref });
},
new MockConfigApi({}),
[],
);
expect(result.get(external.myRoute)).toBe(ref);
});
@@ -35,9 +41,30 @@ describe('resolveRouteBindings', () => {
const external = { myRoute: createExternalRouteRef({ id: '2' }) };
const ref = createRouteRef({ id: 'ref-2' });
expect(() =>
resolveRouteBindings(({ bind }) => {
bind(external, { someOtherRoute: ref } as any);
}),
resolveRouteBindings(
({ bind }) => {
bind(external, { someOtherRoute: ref } as any);
},
new MockConfigApi({}),
[],
),
).toThrow('Key someOtherRoute is not an existing external route');
});
});
describe('collectRouteIds', () => {
it('should assign IDs to routes', () => {
const ref = createRouteRef({ id: 'ignored' });
const extRef = createExternalRouteRef({ id: 'ignored' });
const collected = collectRouteIds([
createPlugin({ id: 'test', routes: { ref }, externalRoutes: { extRef } }),
]);
expect(Object.fromEntries(collected.routes)).toEqual({
'test.ref': ref,
});
expect(Object.fromEntries(collected.externalRoutes)).toEqual({
'test.extRef': extRef,
});
});
});
@@ -18,12 +18,63 @@ import {
RouteRef,
SubRouteRef,
ExternalRouteRef,
BackstagePlugin,
AnyRoutes,
AnyExternalRoutes,
} from '@backstage/core-plugin-api';
import { AppOptions, AppRouteBinder } from './types';
import { Config } from '@backstage/config';
import { JsonObject } from '@backstage/types';
export function resolveRouteBindings(bindRoutes: AppOptions['bindRoutes']) {
/** @internal */
export function collectRouteIds(
plugins: Iterable<
Pick<
BackstagePlugin<AnyRoutes, AnyExternalRoutes>,
'getId' | 'routes' | 'externalRoutes'
>
>,
) {
const routesById = new Map<string, RouteRef | SubRouteRef>();
const externalRoutesById = new Map<string, ExternalRouteRef>();
for (const plugin of plugins) {
for (const [name, ref] of Object.entries(plugin.routes ?? {})) {
const refId = `${plugin.getId()}.${name}`;
if (routesById.has(refId)) {
throw new Error(`Unexpected duplicate route '${refId}'`);
}
routesById.set(refId, ref);
}
for (const [name, ref] of Object.entries(plugin.externalRoutes ?? {})) {
const refId = `${plugin.getId()}.${name}`;
if (externalRoutesById.has(refId)) {
throw new Error(`Unexpected duplicate external route '${refId}'`);
}
externalRoutesById.set(refId, ref);
}
}
return { routes: routesById, externalRoutes: externalRoutesById };
}
/** @internal */
export function resolveRouteBindings(
bindRoutes: AppOptions['bindRoutes'],
config: Config,
plugins: Iterable<
Pick<
BackstagePlugin<AnyRoutes, AnyExternalRoutes>,
'getId' | 'routes' | 'externalRoutes'
>
>,
) {
const routesById = collectRouteIds(plugins);
const result = new Map<ExternalRouteRef, RouteRef | SubRouteRef>();
// Perform callback bindings first with highest priority
if (bindRoutes) {
const bind: AppRouteBinder = (
externalRoutes,
@@ -47,5 +98,53 @@ export function resolveRouteBindings(bindRoutes: AppOptions['bindRoutes']) {
bindRoutes({ bind });
}
// Then perform config based bindings with lower priority
const bindings = config
.getOptionalConfig('app.routes.bindings')
?.get<JsonObject>();
if (bindings) {
for (const [externalRefId, targetRefId] of Object.entries(bindings)) {
if (typeof targetRefId !== 'string' || targetRefId === '') {
throw new Error(
`Invalid config at app.routes.bindings['${externalRefId}'], value must be a non-empty string`,
);
}
const externalRef = routesById.externalRoutes.get(externalRefId);
if (!externalRef) {
throw new Error(
`Invalid config at app.routes.bindings, '${externalRefId}' is not a valid external route`,
);
}
if (result.has(externalRef)) {
continue;
}
const targetRef = routesById.routes.get(targetRefId);
if (!targetRef) {
throw new Error(
`Invalid config at app.routes.bindings['${externalRefId}'], '${targetRefId}' is not a valid route`,
);
}
result.set(externalRef, targetRef);
}
}
// Finally fall back to attempting to map defaults, at lowest priority
for (const externalRef of routesById.externalRoutes.values()) {
if (!result.has(externalRef)) {
const defaultRefId =
'getDefaultTarget' in externalRef
? (externalRef.getDefaultTarget as () => string | undefined)()
: undefined;
if (defaultRefId) {
const defaultRef = routesById.routes.get(defaultRefId);
if (defaultRef) {
result.set(externalRef, defaultRef);
}
}
}
}
return result;
}
@@ -66,7 +66,12 @@ export function validateRouteParameters(
// Validates that all non-optional external routes have been bound
export function validateRouteBindings(
routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>,
plugins: Iterable<BackstagePlugin<{}, Record<string, ExternalRouteRef>>>,
plugins: Iterable<
Pick<
BackstagePlugin<{}, Record<string, ExternalRouteRef>>,
'getId' | 'externalRoutes'
>
>,
) {
for (const plugin of plugins) {
if (!plugin.externalRoutes) {
@@ -186,6 +186,10 @@ export function convertLegacyRouteRef(
createExternalRouteRef<{ [key in string]: string }>({
params: legacyRef.params as string[],
optional: legacyRef.optional,
defaultTarget:
'getDefaultTarget' in legacyRef
? (legacyRef.getDefaultTarget as () => string | undefined)()
: undefined,
}),
);
return Object.assign(legacyRef, {
@@ -199,10 +203,8 @@ export function convertLegacyRouteRef(
getDescription() {
return legacyRefStr;
},
getDefaultTarget() {
// TODO(freben): These are not yet supported in the old system; just returning undefined for now
return undefined;
},
// This might already be implemented in the legacy ref, but we override it just to be sure
getDefaultTarget: newRef.getDefaultTarget,
setId(id: string) {
newRef.setId(id);
},
+1
View File
@@ -314,6 +314,7 @@ export function createExternalRouteRef<
id: string;
params?: ParamKey[];
optional?: Optional;
defaultTarget?: string;
}): ExternalRouteRef<OptionalParams<Params>, Optional>;
// @public
@@ -38,11 +38,16 @@ export class ExternalRouteRefImpl<
private readonly id: string,
readonly params: ParamKeys<Params>,
readonly optional: Optional,
readonly defaultTarget: string | undefined,
) {}
toString() {
return `routeRef{type=external,id=${this.id}}`;
}
getDefaultTarget() {
return this.defaultTarget;
}
}
/**
@@ -77,10 +82,19 @@ export function createExternalRouteRef<
* if they aren't, `useRouteRef` will return `undefined`.
*/
optional?: Optional;
/**
* The route (typically in another plugin) that this should map to by default.
*
* The string is expected to be on the standard `<plugin id>.<route id>` form,
* for example `techdocs.docRoot`.
*/
defaultTarget?: string;
}): ExternalRouteRef<OptionalParams<Params>, Optional> {
return new ExternalRouteRefImpl(
options.id,
(options.params ?? []) as ParamKeys<OptionalParams<Params>>,
Boolean(options.optional) as Optional,
options?.defaultTarget,
);
}