diff --git a/packages/frontend-app-api/config.d.ts b/packages/frontend-app-api/config.d.ts index 5f5f877d3e..9ec020fa56 100644 --- a/packages/frontend-app-api/config.d.ts +++ b/packages/frontend-app-api/config.d.ts @@ -24,6 +24,13 @@ export interface Config { packages?: 'all' | { include?: string[]; exclude?: string[] }; }; + routes?: { + /** + * @deepVisibility frontend + */ + bindings?: { [externalRouteRefId: string]: string }; + }; + /** * @deepVisibility frontend */ diff --git a/packages/frontend-app-api/src/routing/resolveRouteBindings.test.ts b/packages/frontend-app-api/src/routing/resolveRouteBindings.test.ts index 9ea288c196..d80a02e34d 100644 --- a/packages/frontend-app-api/src/routing/resolveRouteBindings.test.ts +++ b/packages/frontend-app-api/src/routing/resolveRouteBindings.test.ts @@ -19,14 +19,21 @@ import { createRouteRef, } from '@backstage/frontend-plugin-api'; import { resolveRouteBindings } from './resolveRouteBindings'; +import { ConfigReader } from '@backstage/config'; + +const emptyIds = { routes: new Map(), externalRoutes: new Map() }; describe('resolveRouteBindings', () => { it('runs happy path', () => { const external = { myRoute: createExternalRouteRef() }; const ref = createRouteRef(); - const result = resolveRouteBindings(({ bind }) => { - bind(external, { myRoute: ref }); - }); + const result = resolveRouteBindings( + ({ bind }) => { + bind(external, { myRoute: ref }); + }, + new ConfigReader({}), + emptyIds, + ); expect(result.get(external.myRoute)).toBe(ref); }); @@ -35,9 +42,79 @@ describe('resolveRouteBindings', () => { const external = { myRoute: createExternalRouteRef() }; const ref = createRouteRef(); expect(() => - resolveRouteBindings(({ bind }) => { - bind(external, { someOtherRoute: ref } as any); - }), + resolveRouteBindings( + ({ bind }) => { + bind(external, { someOtherRoute: ref } as any); + }, + new ConfigReader({}), + emptyIds, + ), ).toThrow('Key someOtherRoute is not an existing external route'); }); + + it('reads bindings from config', () => { + const mySource = createExternalRouteRef(); + const myTarget = createRouteRef(); + const result = resolveRouteBindings( + () => {}, + new ConfigReader({ + app: { routes: { bindings: { mySource: 'myTarget' } } }, + }), + { + routes: new Map([['myTarget', myTarget]]), + externalRoutes: new Map([['mySource', mySource]]), + }, + ); + + expect(result.get(mySource)).toBe(myTarget); + }); + + it('throws on invalid config', () => { + expect(() => + resolveRouteBindings( + () => {}, + new ConfigReader({ app: { routes: { bindings: 'derp' } } }), + emptyIds, + ), + ).toThrow( + "Invalid type in config for key 'app.routes.bindings' in 'mock-config', got string, wanted object", + ); + + expect(() => + resolveRouteBindings( + () => {}, + new ConfigReader({ app: { routes: { bindings: { mySource: true } } } }), + emptyIds, + ), + ).toThrow( + "Invalid config at app.routes.bindings['mySource'], value must be a non-empty string", + ); + + expect(() => + resolveRouteBindings( + () => {}, + new ConfigReader({ + app: { routes: { bindings: { mySource: 'myTarget' } } }, + }), + emptyIds, + ), + ).toThrow( + "Invalid config at app.routes.bindings, 'mySource' is not a valid external route", + ); + + expect(() => + resolveRouteBindings( + () => {}, + new ConfigReader({ + app: { routes: { bindings: { mySource: 'myTarget' } } }, + }), + { + ...emptyIds, + externalRoutes: new Map([['mySource', createExternalRouteRef()]]), + }, + ), + ).toThrow( + "Invalid config at app.routes.bindings['mySource'], 'myTarget' is not a valid route", + ); + }); }); diff --git a/packages/frontend-app-api/src/routing/resolveRouteBindings.ts b/packages/frontend-app-api/src/routing/resolveRouteBindings.ts index 968da005ec..c98e36a75b 100644 --- a/packages/frontend-app-api/src/routing/resolveRouteBindings.ts +++ b/packages/frontend-app-api/src/routing/resolveRouteBindings.ts @@ -19,6 +19,8 @@ import { SubRouteRef, ExternalRouteRef, } from '@backstage/frontend-plugin-api'; +import { RouteRefsById } from './collectRouteIds'; +import { Config } from '@backstage/config'; /** * Extracts a union of the keys in a map whose value extends the given type @@ -73,7 +75,9 @@ export type AppRouteBinder = < /** @internal */ export function resolveRouteBindings( - bindRoutes?: (context: { bind: AppRouteBinder }) => void, + bindRoutes: ((context: { bind: AppRouteBinder }) => void) | undefined, + config: Config, + routesById: RouteRefsById, ): Map { const result = new Map(); @@ -100,5 +104,42 @@ export function resolveRouteBindings( bindRoutes({ bind }); } + const bindingsConfig = config.getOptionalConfig('app.routes.bindings'); + if (!bindingsConfig) { + return result; + } + + const bindings = bindingsConfig.get(); + if (bindings === null || typeof bindings !== 'object') { + throw new Error('Invalid config at app.routes.bindings, must be an object'); + } + + 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`, + ); + } + // Route bindings defined in config have lower priority than those defined in code + 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); + } + return result; } diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index 4842383096..87b2042d34 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -95,6 +95,7 @@ import { import { AppRouteBinder } from '../routing'; import { RoutingProvider } from '../routing/RoutingProvider'; import { resolveRouteBindings } from '../routing/resolveRouteBindings'; +import { collectRouteIds } from '../routing/collectRouteIds'; /** @public */ export interface ExtensionTreeNode { @@ -305,14 +306,19 @@ export function createApp(options: { ), ); + const routeIds = collectRouteIds(allFeatures); + const App = () => ( {/* TODO: set base path using the logic from AppRouter */}