frontend-app-api: make it possible to bind routes through config

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-10-12 17:26:21 +02:00
parent 659651bfcc
commit 2307fb1747
4 changed files with 140 additions and 9 deletions
+7
View File
@@ -24,6 +24,13 @@ export interface Config {
packages?: 'all' | { include?: string[]; exclude?: string[] };
};
routes?: {
/**
* @deepVisibility frontend
*/
bindings?: { [externalRouteRefId: string]: string };
};
/**
* @deepVisibility frontend
*/
@@ -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",
);
});
});
@@ -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<ExternalRouteRef, RouteRef | SubRouteRef> {
const result = new Map<ExternalRouteRef, RouteRef | SubRouteRef>();
@@ -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;
}
@@ -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 = () => (
<ApiProvider apis={createApiHolder(coreInstance, config)}>
<AppContextProvider appContext={appContext}>
<AppThemeProvider>
<RoutingProvider
// TODO(Rugvip): Move over routing app API to new system to avoid these casts
{...extractRouteInfoFromInstanceTree(coreInstance)}
routeBindings={resolveRouteBindings(options.bindRoutes)}
routeBindings={resolveRouteBindings(
options.bindRoutes,
config,
routeIds,
)}
>
{/* TODO: set base path using the logic from AppRouter */}
<BrowserRouter>