diff --git a/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts b/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts index cec00de025..54d54f9cf8 100644 --- a/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts +++ b/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts @@ -14,24 +14,29 @@ * limitations under the License. */ -import { AnyParams, SubRouteRef } from './types'; -import { createSubRouteRef } from './SubRouteRef'; +import { AnyRouteParams } from './types'; +import { + SubRouteRef, + createSubRouteRef, + toInternalSubRouteRef, +} from './SubRouteRef'; import { createRouteRef } from './RouteRef'; -const parent = createRouteRef({ id: 'parent' }); -const parentX = createRouteRef({ id: 'parent-x', params: ['x'] }); +const parent = createRouteRef(); +const parentX = createRouteRef({ params: ['x'] }); describe('SubRouteRef', () => { it('should be created', () => { - const routeRef: SubRouteRef = createSubRouteRef({ + const routeRef: SubRouteRef = createSubRouteRef({ parent, id: 'my-route-ref', path: '/foo', }); - expect(routeRef.path).toBe('/foo'); - expect(routeRef.parent).toBe(parent); - expect(routeRef.params).toEqual([]); - expect(String(routeRef)).toBe('routeRef{type=sub,id=my-route-ref}'); + const internal = toInternalSubRouteRef(routeRef); + expect(internal.path).toBe('/foo'); + expect(internal.getParent()).toBe(parent); + expect(internal.getParams()).toEqual([]); + expect(String(internal)).toBe('SubRouteRef{}'); }); it('should be created with params', () => { @@ -40,9 +45,10 @@ describe('SubRouteRef', () => { id: 'my-other-route-ref', path: '/foo/:bar', }); - expect(routeRef.path).toBe('/foo/:bar'); - expect(routeRef.parent).toBe(parent); - expect(routeRef.params).toEqual(['bar']); + const internal = toInternalSubRouteRef(routeRef); + expect(internal.path).toBe('/foo/:bar'); + expect(internal.getParent()).toBe(parent); + expect(internal.getParams()).toEqual(['bar']); }); it('should be created with merged params', () => { @@ -55,9 +61,10 @@ describe('SubRouteRef', () => { id: 'my-other-route-ref', path: '/foo/:y/:z', }); - expect(routeRef.path).toBe('/foo/:y/:z'); - expect(routeRef.parent).toBe(parentX); - expect(routeRef.params).toEqual(['x', 'y', 'z']); + const internal = toInternalSubRouteRef(routeRef); + expect(internal.path).toBe('/foo/:y/:z'); + expect(internal.getParent()).toBe(parentX); + expect(internal.getParams()).toEqual(['x', 'y', 'z']); }); it('should be created with params from parent', () => { @@ -66,9 +73,10 @@ describe('SubRouteRef', () => { id: 'my-other-route-ref', path: '/foo/bar', }); - expect(routeRef.path).toBe('/foo/bar'); - expect(routeRef.parent).toBe(parentX); - expect(routeRef.params).toEqual(['x']); + const internal = toInternalSubRouteRef(routeRef); + expect(internal.path).toBe('/foo/bar'); + expect(internal.getParent()).toBe(parentX); + expect(internal.getParams()).toEqual(['x']); }); it.each([ @@ -88,39 +96,42 @@ describe('SubRouteRef', () => { }); it('should properly infer and parse path parameters', () => { - function validateType(_ref: SubRouteRef) {} + function checkSubRouteRef( + _ref: SubRouteRef, + _params: T extends undefined ? undefined : T, + ) {} const _1 = createSubRouteRef({ id: '1', parent, path: '/foo/bar' }); // @ts-expect-error - validateType<{ x: string }>(_1); - validateType(_1); + checkSubRouteRef(_1, { x: '' }); + checkSubRouteRef(_1, undefined); const _2 = createSubRouteRef({ id: '2', parent, path: '/foo/:x/:y' }); // @ts-expect-error - validateType(_2); + checkSubRouteRef(_2, undefined); // @ts-expect-error - validateType<{ x: string; z: string }>(_2); + checkSubRouteRef(_2, { x: '', z: '' }); // @ts-expect-error - validateType<{ y: string }>(_2); - // extra z, we validate this at runtime instead - validateType<{ x: string; y: string; z: string }>(_2); - validateType<{ x: string; y: string }>(_2); + checkSubRouteRef(_2, { y: '' }); + // @ts-expect-error + checkSubRouteRef(_2, { x: '', y: '', z: '' }); + checkSubRouteRef(_2, { x: '', y: '' }); const _3 = createSubRouteRef({ id: '3', parent: parentX, path: '/foo' }); // @ts-expect-error - validateType(_3); + checkSubRouteRef(_3, undefined); // @ts-expect-error - validateType<{ y: string }>(_3); - validateType<{ x: string }>(_3); + checkSubRouteRef(_3, { y: '' }); + checkSubRouteRef(_3, { x: '' }); const _4 = createSubRouteRef({ id: '4', parent: parentX, path: '/foo/:y' }); // @ts-expect-error - validateType(_4); + checkSubRouteRef(_4, undefined); // @ts-expect-error - validateType<{ x: string; z: string }>(_4); + checkSubRouteRef(_4, { x: '', z: '' }); // @ts-expect-error - validateType<{ y: string }>(_4); - validateType<{ x: string; y: string }>(_4); + checkSubRouteRef(_4, { y: '' }); + checkSubRouteRef(_4, { x: '', y: '' }); // To avoid complains about missing expectations and unused vars expect([_1, _2, _3, _4].join('')).toEqual(expect.any(String)); diff --git a/packages/frontend-plugin-api/src/routing/SubRouteRef.ts b/packages/frontend-plugin-api/src/routing/SubRouteRef.ts index 14a614c2ba..ed03c17ada 100644 --- a/packages/frontend-plugin-api/src/routing/SubRouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/SubRouteRef.ts @@ -14,43 +14,93 @@ * limitations under the License. */ -import { - AnyParams, - OptionalParams, - ParamKeys, - RouteRef, - routeRefType, - SubRouteRef, -} from './types'; +import { RouteRef, toInternalRouteRef } from './RouteRef'; +import { AnyRouteParams } from './types'; // Should match the pattern in react-router const PARAM_PATTERN = /^\w+$/; /** - * @internal + * Descriptor of a route relative to an absolute {@link RouteRef}. + * + * @remarks + * + * See {@link https://backstage.io/docs/plugins/composability#routing-system}. + * + * @public */ -export class SubRouteRefImpl - implements SubRouteRef -{ - // The marker is used for type checking while the symbol is used at runtime. - declare $$routeRefType: 'sub'; - readonly [routeRefType] = 'sub'; +export interface SubRouteRef { + readonly $$type: '@backstage/SubRouteRef'; - constructor( - private readonly id: string, - readonly path: string, - readonly parent: RouteRef, - readonly params: ParamKeys, - ) {} + readonly T: TParams; + + readonly path: string; +} + +/** @internal */ +export interface InternalSubRouteRef< + TParams extends AnyRouteParams = AnyRouteParams, +> extends SubRouteRef { + readonly version: 'v1'; + + getParams(): string[]; + getParent(): RouteRef; +} + +/** @internal */ +export function toInternalSubRouteRef< + TParams extends AnyRouteParams = AnyRouteParams, +>(resource: SubRouteRef): InternalSubRouteRef { + const r = resource as InternalSubRouteRef; + if (r.$$type !== '@backstage/SubRouteRef') { + throw new Error(`Invalid SubRouteRef, bad type '${r.$$type}'`); + } + + return r; +} + +/** @internal */ +export function isSubRouteRef(opaque: { + $$type: string; +}): opaque is SubRouteRef { + return opaque.$$type === '@backstage/SubRouteRef'; +} + +/** @internal */ +export class SubRouteRefImpl + implements SubRouteRef +{ + readonly $$type = '@backstage/SubRouteRef'; + readonly version = 'v1'; + + #params: string[]; + #parent: RouteRef; + + constructor(readonly path: string, params: string[], parent: RouteRef) { + this.#params = params; + this.#parent = parent; + } + + get T(): never { + throw new Error(`tried to read RouteRef.T of ${this}`); + } + + getParams(): string[] { + return this.#params; + } + + getParent(): RouteRef { + return this.#parent; + } toString() { - return `routeRef{type=sub,id=${this.id}}`; + return `SubRouteRef{}`; } } /** * Used in {@link PathParams} type declaration. - * @public + * @ignore */ export type ParamPart = S extends `:${infer Param}` ? Param @@ -58,7 +108,7 @@ export type ParamPart = S extends `:${infer Param}` /** * Used in {@link PathParams} type declaration. - * @public + * @ignore */ export type ParamNames = S extends `${infer Part}/${infer Rest}` @@ -67,30 +117,37 @@ export type ParamNames = /** * This utility type helps us infer a Param object type from a string path * For example, `/foo/:bar/:baz` inferred to `{ bar: string, baz: string }` - * @public + * @ignore */ export type PathParams = { [name in ParamNames]: string }; /** * Merges a param object type with an optional params type into a params object. - * @public + * @ignore */ export type MergeParams< P1 extends { [param in string]: string }, - P2 extends AnyParams, + P2 extends AnyRouteParams, > = (P1[keyof P1] extends never ? {} : P1) & (P2 extends undefined ? {} : P2); +/** + * Convert empty params to undefined. + * @ignore + */ +export type TrimEmptyParams = + keyof Params extends never ? undefined : Params; + /** * Creates a SubRouteRef type given the desired parameters and parent route parameters. * The parameters types are merged together while ensuring that there is no overlap between the two. * - * @public + * @ignore */ export type MakeSubRouteRef< Params extends { [param in string]: string }, - ParentParams extends AnyParams, + ParentParams extends AnyRouteParams, > = keyof Params & keyof ParentParams extends never - ? SubRouteRef>> + ? SubRouteRef>> : never; /** @@ -101,23 +158,26 @@ export type MakeSubRouteRef< */ export function createSubRouteRef< Path extends string, - ParentParams extends AnyParams = never, + ParentParams extends AnyRouteParams = never, >(config: { id: string; path: Path; parent: RouteRef; }): MakeSubRouteRef, ParentParams> { - const { id, path, parent } = config; + const { path, parent } = config; type Params = PathParams; + const internalParent = toInternalRouteRef(parent); + const parentParams = internalParent.getParams(); + // Collect runtime parameters from the path, e.g. ['bar', 'baz'] from '/foo/:bar/:baz' const pathParams = path .split('/') .filter(p => p.startsWith(':')) .map(p => p.substring(1)); - const params = [...parent.params, ...pathParams]; + const params = [...parentParams, ...pathParams]; - if (parent.params.some(p => pathParams.includes(p as string))) { + if (parentParams.some(p => pathParams.includes(p as string))) { throw new Error( 'SubRouteRef may not have params that overlap with its parent', ); @@ -136,11 +196,10 @@ export function createSubRouteRef< // We ensure that the type of the return type is sane here const subRouteRef = new SubRouteRefImpl( - id, path, + params as string[], parent, - params as ParamKeys>, - ) as SubRouteRef>>; + ) as SubRouteRef>>; // But skip type checking of the return value itself, because the conditional // type checking of the parent parameter overlap is tricky to express. diff --git a/packages/frontend-plugin-api/src/routing/types.ts b/packages/frontend-plugin-api/src/routing/types.ts index b9893c1fdf..d5afc9213c 100644 --- a/packages/frontend-plugin-api/src/routing/types.ts +++ b/packages/frontend-plugin-api/src/routing/types.ts @@ -70,25 +70,6 @@ export type OptionalParams = export type ParamKeys = keyof TParams extends never ? [] : (keyof TParams)[]; -/** - * Descriptor of a route relative to an absolute {@link RouteRef}. - * - * @remarks - * - * See {@link https://backstage.io/docs/plugins/composability#routing-system}. - * - * @public - */ -export type SubRouteRef = { - $$routeRefType: 'sub'; // See routeRefType above - - parent: RouteRef; - - path: string; - - params: ParamKeys; -}; - /** * Route descriptor, to be later bound to a concrete route by the app. Used to implement cross-plugin route references. *