frontend-plugin-api: refactor SubRouteRef to modern structure

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-10-11 21:06:06 +02:00
parent 18882a9bbe
commit 08bf1db3fe
3 changed files with 141 additions and 90 deletions
@@ -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<undefined> = 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<T extends AnyParams>(_ref: SubRouteRef<T>) {}
function checkSubRouteRef<T extends AnyRouteParams>(
_ref: SubRouteRef<T>,
_params: T extends undefined ? undefined : T,
) {}
const _1 = createSubRouteRef({ id: '1', parent, path: '/foo/bar' });
// @ts-expect-error
validateType<{ x: string }>(_1);
validateType<undefined>(_1);
checkSubRouteRef(_1, { x: '' });
checkSubRouteRef(_1, undefined);
const _2 = createSubRouteRef({ id: '2', parent, path: '/foo/:x/:y' });
// @ts-expect-error
validateType<undefined>(_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<undefined>(_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<undefined>(_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));
@@ -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<Params extends AnyParams>
implements SubRouteRef<Params>
{
// The marker is used for type checking while the symbol is used at runtime.
declare $$routeRefType: 'sub';
readonly [routeRefType] = 'sub';
export interface SubRouteRef<TParams extends AnyRouteParams = AnyRouteParams> {
readonly $$type: '@backstage/SubRouteRef';
constructor(
private readonly id: string,
readonly path: string,
readonly parent: RouteRef,
readonly params: ParamKeys<Params>,
) {}
readonly T: TParams;
readonly path: string;
}
/** @internal */
export interface InternalSubRouteRef<
TParams extends AnyRouteParams = AnyRouteParams,
> extends SubRouteRef<TParams> {
readonly version: 'v1';
getParams(): string[];
getParent(): RouteRef;
}
/** @internal */
export function toInternalSubRouteRef<
TParams extends AnyRouteParams = AnyRouteParams,
>(resource: SubRouteRef<TParams>): InternalSubRouteRef<TParams> {
const r = resource as InternalSubRouteRef<TParams>;
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<TParams extends AnyRouteParams>
implements SubRouteRef<TParams>
{
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 string> = S extends `:${infer Param}`
? Param
@@ -58,7 +108,7 @@ export type ParamPart<S extends string> = S extends `:${infer Param}`
/**
* Used in {@link PathParams} type declaration.
* @public
* @ignore
*/
export type ParamNames<S extends string> =
S extends `${infer Part}/${infer Rest}`
@@ -67,30 +117,37 @@ export type ParamNames<S extends string> =
/**
* 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<S extends string> = { [name in ParamNames<S>]: 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<Params extends { [param in string]: string }> =
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<OptionalParams<MergeParams<Params, ParentParams>>>
? SubRouteRef<TrimEmptyParams<MergeParams<Params, ParentParams>>>
: 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<ParentParams>;
}): MakeSubRouteRef<PathParams<Path>, ParentParams> {
const { id, path, parent } = config;
const { path, parent } = config;
type Params = PathParams<Path>;
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<MergeParams<Params, ParentParams>>,
) as SubRouteRef<OptionalParams<MergeParams<Params, ParentParams>>>;
) as SubRouteRef<TrimEmptyParams<MergeParams<Params, ParentParams>>>;
// But skip type checking of the return value itself, because the conditional
// type checking of the parent parameter overlap is tricky to express.
@@ -70,25 +70,6 @@ export type OptionalParams<Params extends { [param in string]: string }> =
export type ParamKeys<TParams extends AnyRouteParams> =
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<Params extends AnyParams = any> = {
$$routeRefType: 'sub'; // See routeRefType above
parent: RouteRef;
path: string;
params: ParamKeys<Params>;
};
/**
* Route descriptor, to be later bound to a concrete route by the app. Used to implement cross-plugin route references.
*