frontend-plugin-api: refactor ExternalRouteRef to modern structure

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-10-12 00:52:41 +02:00
parent 08bf1db3fe
commit 720b6cda3d
3 changed files with 138 additions and 114 deletions
@@ -14,97 +14,94 @@
* limitations under the License.
*/
import { AnyParams, ExternalRouteRef } from './types';
import { createExternalRouteRef } from './ExternalRouteRef';
import {
ExternalRouteRef,
createExternalRouteRef,
toInternalExternalRouteRef,
} from './ExternalRouteRef';
import { AnyRouteParams } from './types';
describe('ExternalRouteRef', () => {
it('should be created', () => {
const routeRef: ExternalRouteRef<undefined> = createExternalRouteRef({
id: 'my-route-ref',
});
expect(routeRef.params).toEqual([]);
expect(routeRef.optional).toBe(false);
expect(String(routeRef)).toBe('routeRef{type=external,id=my-route-ref}');
const routeRef: ExternalRouteRef<undefined> = createExternalRouteRef();
const internal = toInternalExternalRouteRef(routeRef);
expect(internal.getParams()).toEqual([]);
expect(internal.optional).toBe(false);
expect(String(internal)).toBe('routeRef{type=external,id=my-route-ref}');
});
it('should be created as optional', () => {
const routeRef: ExternalRouteRef<{
x: string;
y: string;
}> = createExternalRouteRef({
id: 'my-other-route-ref',
const routeRef: ExternalRouteRef<undefined, true> = createExternalRouteRef({
params: [],
optional: true,
});
expect(routeRef.params).toEqual([]);
expect(routeRef.optional).toEqual(true);
const internal = toInternalExternalRouteRef(routeRef);
expect(internal.getParams()).toEqual([]);
expect(internal.optional).toEqual(true);
});
it('should be created with params', () => {
const routeRef: ExternalRouteRef<{
x: string;
y: string;
}> = createExternalRouteRef({
id: 'my-other-route-ref',
params: ['x', 'y'],
});
expect(routeRef.params).toEqual(['x', 'y']);
expect(routeRef.optional).toEqual(false);
}> = createExternalRouteRef({ params: ['x', 'y'] });
const internal = toInternalExternalRouteRef(routeRef);
expect(internal.getParams()).toEqual(['x', 'y']);
expect(internal.optional).toEqual(false);
});
it('should be created as optional with params', () => {
const routeRef: ExternalRouteRef<{
x: string;
y: string;
}> = createExternalRouteRef({
id: 'my-other-route-ref',
params: ['x', 'y'],
optional: true,
});
expect(routeRef.params).toEqual(['x', 'y']);
expect(routeRef.optional).toEqual(true);
}> = createExternalRouteRef({ params: ['x', 'y'], optional: true });
const internal = toInternalExternalRouteRef(routeRef);
expect(internal.getParams()).toEqual(['x', 'y']);
expect(internal.optional).toEqual(true);
});
it('should properly infer and validate parameter types and assignments', () => {
function validateType<T extends AnyParams, O extends boolean>(
_ref: ExternalRouteRef<T, O>,
function checkRouteRef<
T extends AnyRouteParams,
TOptional extends boolean,
TCheck extends TOptional,
>(
_ref: ExternalRouteRef<T, TOptional>,
_params: T extends undefined ? undefined : T,
_optional: TCheck,
) {}
const _1 = createExternalRouteRef({ id: '1', params: ['notX'] });
const _1 = createExternalRouteRef({ params: ['notX'] });
checkRouteRef(_1, { notX: '' }, false);
// @ts-expect-error
validateType<{ x: string }, any>(_1);
validateType<{ notX: string }, any>(_1);
checkRouteRef(_1, { x: '' }, false);
const _2 = createExternalRouteRef({
id: '2',
params: ['x'],
optional: true,
});
const _2 = createExternalRouteRef({ params: ['x'], optional: true });
checkRouteRef(_2, { x: '' }, true);
// @ts-expect-error
validateType<undefined, any>(_2);
validateType<{ x: string }, true>(_2);
checkRouteRef(_2, undefined, false);
const _3 = createExternalRouteRef({ id: '3', params: ['x', 'y'] });
const _3 = createExternalRouteRef({ params: ['x', 'y'] });
checkRouteRef(_3, { x: '', y: '' }, false);
// @ts-expect-error
validateType<{ x: string }, any>(_3);
// extra z, we validate this at runtime instead
validateType<{ x: string; y: string; z: string }, any>(_3);
validateType<{ x: string; y: string }, false>(_3);
checkRouteRef(_3, { x: '' }, false);
// @ts-expect-error
checkRouteRef(_3, { x: '', y: '', z: '' }, false);
const _4 = createExternalRouteRef({ id: '4', params: [] });
const _4 = createExternalRouteRef({ params: [] });
checkRouteRef(_4, undefined, false);
// @ts-expect-error
validateType<{ x: string }, any>(_4);
validateType<undefined, false>(_4);
checkRouteRef<any>(_4, { x: '' });
const _5 = createExternalRouteRef({ id: '5' });
const _5 = createExternalRouteRef();
checkRouteRef(_5, undefined, false);
// @ts-expect-error
validateType<{ x: string }, any>(_5);
validateType<undefined, false>(_5);
checkRouteRef<any>(_5, { x: '' });
const _6 = createExternalRouteRef({ id: '6', optional: true });
const _6 = createExternalRouteRef({ optional: true });
checkRouteRef(_6, undefined, true);
// @ts-expect-error
validateType<undefined, false>(_6);
validateType<undefined, true>(_6);
checkRouteRef(_6, undefined, false);
// To avoid complains about missing expectations and unused vars
expect([_1, _2, _3, _4, _5, _6].join('')).toEqual(expect.any(String));
@@ -14,34 +14,78 @@
* limitations under the License.
*/
import {
ExternalRouteRef,
routeRefType,
AnyParams,
ParamKeys,
OptionalParams,
} from './types';
import { AnyRouteParams } from './types';
/**
* @internal
* Route descriptor, to be later bound to a concrete route by the app. Used to implement cross-plugin route references.
*
* @remarks
*
* See {@link https://backstage.io/docs/plugins/composability#routing-system}.
*
* @public
*/
export class ExternalRouteRefImpl<
Params extends AnyParams,
Optional extends boolean,
> implements ExternalRouteRef<Params, Optional>
{
// The marker is used for type checking while the symbol is used at runtime.
declare $$routeRefType: 'external';
readonly [routeRefType] = 'external';
export interface ExternalRouteRef<
TParams extends AnyRouteParams = AnyRouteParams,
TOptional extends boolean = boolean,
> {
readonly $$type: '@backstage/ExternalRouteRef';
readonly T: TParams;
readonly optional: TOptional;
}
constructor(
private readonly id: string,
readonly params: ParamKeys<Params>,
readonly optional: Optional,
) {}
/** @internal */
export interface InternalExternalRouteRef<
TParams extends AnyRouteParams = AnyRouteParams,
TOptional extends boolean = boolean,
> extends ExternalRouteRef<TParams, TOptional> {
readonly version: 'v1';
getParams(): string[];
}
toString() {
return `routeRef{type=external,id=${this.id}}`;
/** @internal */
export function toInternalExternalRouteRef<
TParams extends AnyRouteParams = AnyRouteParams,
TOptional extends boolean = boolean,
>(
resource: ExternalRouteRef<TParams, TOptional>,
): InternalExternalRouteRef<TParams, TOptional> {
const r = resource as InternalExternalRouteRef<TParams, TOptional>;
if (r.$$type !== '@backstage/ExternalRouteRef') {
throw new Error(`Invalid ExternalRouteRef, bad type '${r.$$type}'`);
}
return r;
}
/** @internal */
export function isExternalRouteRef(opaque: {
$$type: string;
}): opaque is ExternalRouteRef {
return opaque.$$type === '@backstage/ExternalRouteRef';
}
/** @internal */
export class ExternalRouteRefImpl implements InternalExternalRouteRef {
readonly $$type = '@backstage/ExternalRouteRef';
readonly version = 'v1';
#params: string[];
constructor(readonly optional: boolean, readonly params: string[] = []) {
this.#params = params;
}
get T(): never {
throw new Error(`tried to read ExternalRouteRef.T of ${this}`);
}
getParams(): string[] {
return this.#params;
}
toString(): string {
return `ExternalRouteRef{}`;
}
}
@@ -56,31 +100,34 @@ export class ExternalRouteRefImpl<
* @public
*/
export function createExternalRouteRef<
Params extends { [param in ParamKey]: string },
Optional extends boolean = false,
ParamKey extends string = never,
>(options: {
/**
* An identifier for this route, used to identify it in error messages
*/
id: string;
TParams extends { [param in TParamKeys]: string } | undefined = undefined,
TOptional extends boolean = false,
TParamKeys extends string = string,
>(options?: {
/**
* The parameters that will be provided to the external route reference.
*/
params?: ParamKey[];
readonly params?: string extends TParamKeys
? (keyof TParams)[]
: TParamKeys[];
/**
* Whether or not this route is optional, defaults to false.
*
* Optional external routes are not required to be bound in the app, and
* if they aren't, `useRouteRef` will return `undefined`.
* if they aren't, `useExternalRouteRef` will return `undefined`.
*/
optional?: Optional;
}): ExternalRouteRef<OptionalParams<Params>, Optional> {
optional?: TOptional;
}): ExternalRouteRef<
keyof TParams extends never
? undefined
: string extends TParamKeys
? TParams
: { [param in TParamKeys]: string },
TOptional
> {
return new ExternalRouteRefImpl(
options.id,
(options.params ?? []) as ParamKeys<OptionalParams<Params>>,
Boolean(options.optional) as Optional,
);
Boolean(options?.optional),
options?.params as string[] | undefined,
) as ExternalRouteRef<any, any>;
}
@@ -70,26 +70,6 @@ export type OptionalParams<Params extends { [param in string]: string }> =
export type ParamKeys<TParams extends AnyRouteParams> =
keyof TParams extends never ? [] : (keyof TParams)[];
/**
* Route descriptor, to be later bound to a concrete route by the app. Used to implement cross-plugin route references.
*
* @remarks
*
* See {@link https://backstage.io/docs/plugins/composability#routing-system}.
*
* @public
*/
export type ExternalRouteRef<
Params extends AnyParams = any,
Optional extends boolean = any,
> = {
$$routeRefType: 'external'; // See routeRefType above
params: ParamKeys<Params>;
optional?: Optional;
};
/**
* A duplicate of the react-router RouteObject, but with routeRef added
* @internal