frontend-plugin-api: refactor to use opaque type for route refs

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-11-17 10:35:21 +01:00
parent 766215437e
commit 4d03f08d19
19 changed files with 282 additions and 278 deletions
@@ -14,17 +14,14 @@
* limitations under the License.
*/
import {
ExternalRouteRef,
createExternalRouteRef,
toInternalExternalRouteRef,
} from './ExternalRouteRef';
import { ExternalRouteRef, createExternalRouteRef } from './ExternalRouteRef';
import { OpaqueExternalRouteRef } from '@internal/frontend';
import { AnyRouteRefParams } from './types';
describe('ExternalRouteRef', () => {
it('should be created', () => {
const routeRef: ExternalRouteRef<undefined> = createExternalRouteRef();
const internal = toInternalExternalRouteRef(routeRef);
const internal = OpaqueExternalRouteRef.toInternal(routeRef);
expect(internal.getParams()).toEqual([]);
expect(String(internal)).toMatch(
@@ -39,7 +36,7 @@ describe('ExternalRouteRef', () => {
x: string;
y: string;
}> = createExternalRouteRef({ params: ['x', 'y'] });
const internal = toInternalExternalRouteRef(routeRef);
const internal = OpaqueExternalRouteRef.toInternal(routeRef);
expect(internal.getParams()).toEqual(['x', 'y']);
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { RouteRefImpl } from './RouteRef';
import { OpaqueExternalRouteRef } from '@internal/frontend';
import { describeParentCallSite } from './describeParentCallSite';
import { AnyRouteRefParams } from './types';
@@ -58,37 +58,6 @@ export function toInternalExternalRouteRef<
return r;
}
/** @internal */
export function isExternalRouteRef(opaque: {
$$type: string;
}): opaque is ExternalRouteRef {
return opaque.$$type === '@backstage/ExternalRouteRef';
}
/** @internal */
class ExternalRouteRefImpl
extends RouteRefImpl
implements InternalExternalRouteRef
{
readonly $$type = '@backstage/ExternalRouteRef' as any;
readonly params: string[];
readonly defaultTarget: string | undefined;
constructor(
params: string[] = [],
defaultTarget: string | undefined,
creationSite: string,
) {
super(params, creationSite);
this.params = params;
this.defaultTarget = defaultTarget;
}
getDefaultTarget() {
return this.defaultTarget;
}
}
/**
* Creates a route descriptor, to be later bound to a concrete route by the app. Used to implement cross-plugin route references.
*
@@ -102,7 +71,7 @@ class ExternalRouteRefImpl
export function createExternalRouteRef<
TParams extends { [param in TParamKeys]: string } | undefined = undefined,
TParamKeys extends string = string,
>(options?: {
>(config?: {
/**
* The parameters that will be provided to the external route reference.
*/
@@ -124,9 +93,38 @@ export function createExternalRouteRef<
? TParams
: { [param in TParamKeys]: string }
> {
return new ExternalRouteRefImpl(
options?.params as string[] | undefined,
options?.defaultTarget,
describeParentCallSite(),
);
const params = (config?.params ?? []) as string[];
const creationSite = describeParentCallSite();
let id: string | undefined = undefined;
return OpaqueExternalRouteRef.createInstance('v1', {
T: undefined as unknown as TParams,
getParams() {
return params;
},
getDescription() {
if (id) {
return id;
}
return `created at '${creationSite}'`;
},
getDefaultTarget() {
return config?.defaultTarget;
},
setId(newId: string) {
if (!newId) {
throw new Error(`ExternalRouteRef id must be a non-empty string`);
}
if (id && id !== newId) {
throw new Error(
`ExternalRouteRef was referenced twice as both '${id}' and '${newId}'`,
);
}
id = newId;
},
toString(): string {
return `externalRouteRef{id=${id},at='${creationSite}'}`;
},
});
}
@@ -15,12 +15,13 @@
*/
import { AnyRouteRefParams } from './types';
import { RouteRef, createRouteRef, toInternalRouteRef } from './RouteRef';
import { RouteRef, createRouteRef } from './RouteRef';
import { OpaqueRouteRef } from '@internal/frontend';
describe('RouteRef', () => {
it('should be created and have a mutable ID', () => {
const routeRef: RouteRef<undefined> = createRouteRef();
const internal = toInternalRouteRef(routeRef);
const internal = OpaqueRouteRef.toInternal(routeRef);
expect(internal.T).toBe(undefined);
expect(internal.getParams()).toEqual([]);
expect(internal.getDescription()).toMatch(/RouteRef\.test\.ts/);
@@ -49,7 +50,7 @@ describe('RouteRef', () => {
}> = createRouteRef({
params: ['x', 'y'],
});
const internal = toInternalRouteRef(routeRef);
const internal = OpaqueRouteRef.toInternal(routeRef);
expect(internal.getParams()).toEqual(['x', 'y']);
expect(internal.getDescription()).toMatch(/RouteRef\.test\.ts/);
});
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { OpaqueRouteRef } from '@internal/frontend';
import { describeParentCallSite } from './describeParentCallSite';
import { AnyRouteRefParams } from './types';
@@ -33,93 +34,6 @@ export interface RouteRef<
readonly T: TParams;
}
/** @internal */
export interface InternalRouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
> extends RouteRef<TParams> {
readonly version: 'v1';
getParams(): string[];
getDescription(): string;
alias: string | undefined;
setId(id: string): void;
}
/** @internal */
export function toInternalRouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
>(resource: RouteRef<TParams>): InternalRouteRef<TParams> {
const r = resource as InternalRouteRef<TParams>;
if (r.$$type !== '@backstage/RouteRef') {
throw new Error(`Invalid RouteRef, bad type '${r.$$type}'`);
}
return r;
}
/** @internal */
export function isRouteRef(opaque: { $$type: string }): opaque is RouteRef {
return opaque.$$type === '@backstage/RouteRef';
}
/** @internal */
export class RouteRefImpl implements InternalRouteRef {
readonly $$type = '@backstage/RouteRef';
readonly version = 'v1';
declare readonly T: never;
#id?: string;
readonly #params: string[];
readonly #creationSite: string;
readonly #alias?: string;
constructor(
readonly params: string[] = [],
creationSite: string,
alias?: string,
) {
this.#params = params;
this.#creationSite = creationSite;
this.#alias = alias;
}
getParams(): string[] {
return this.#params;
}
get alias(): string | undefined {
return this.#alias;
}
getDescription(): string {
if (this.#id) {
return this.#id;
}
return `created at '${this.#creationSite}'`;
}
get #name() {
return this.$$type.slice('@backstage/'.length);
}
setId(id: string): void {
if (!id) {
throw new Error(`${this.#name} id must be a non-empty string`);
}
if (this.#id && this.#id !== id) {
throw new Error(
`${this.#name} was referenced twice as both '${this.#id}' and '${id}'`,
);
}
this.#id = id;
}
toString(): string {
return `${this.#name}{${this.getDescription()}}`;
}
}
/**
* Create a {@link RouteRef} from a route descriptor.
*
@@ -145,9 +59,36 @@ export function createRouteRef<
? TParams
: { [param in TParamKeys]: string }
> {
return new RouteRefImpl(
config?.params as string[] | undefined,
describeParentCallSite(),
config?.aliasFor,
) as RouteRef<any>;
const params = (config?.params ?? []) as string[];
const creationSite = describeParentCallSite();
let id: string | undefined = undefined;
return OpaqueRouteRef.createInstance('v1', {
T: undefined as unknown as TParams,
getParams() {
return params;
},
getDescription() {
if (id) {
return id;
}
return `created at '${creationSite}'`;
},
alias: config?.aliasFor,
setId(newId: string) {
if (!newId) {
throw new Error(`RouteRef id must be a non-empty string`);
}
if (id && id !== newId) {
throw new Error(
`RouteRef was referenced twice as both '${id}' and '${newId}'`,
);
}
id = newId;
},
toString(): string {
return `routeRef{id=${id},at='${creationSite}'}`;
},
});
}
@@ -15,24 +15,21 @@
*/
import { AnyRouteRefParams } from './types';
import {
SubRouteRef,
createSubRouteRef,
toInternalSubRouteRef,
} from './SubRouteRef';
import { createRouteRef, toInternalRouteRef } from './RouteRef';
import { SubRouteRef, createSubRouteRef } from './SubRouteRef';
import { createRouteRef } from './RouteRef';
import { OpaqueRouteRef, OpaqueSubRouteRef } from '@internal/frontend';
const parent = createRouteRef();
const parentX = createRouteRef({ params: ['x'] });
describe('SubRouteRef', () => {
it('should be created', () => {
const internalParent = toInternalRouteRef(createRouteRef());
const internalParent = OpaqueRouteRef.toInternal(createRouteRef());
const routeRef: SubRouteRef = createSubRouteRef({
parent: internalParent,
path: '/foo',
});
const internal = toInternalSubRouteRef(routeRef);
const internal = OpaqueSubRouteRef.toInternal(routeRef);
expect(internal.path).toBe('/foo');
expect(internal.T).toBe(undefined);
expect(internal.getParent()).toBe(internalParent);
@@ -49,7 +46,7 @@ describe('SubRouteRef', () => {
parent,
path: '/foo/:bar',
});
const internal = toInternalSubRouteRef(routeRef);
const internal = OpaqueSubRouteRef.toInternal(routeRef);
expect(internal.path).toBe('/foo/:bar');
expect(internal.getParent()).toBe(parent);
expect(internal.getParams()).toEqual(['bar']);
@@ -64,7 +61,7 @@ describe('SubRouteRef', () => {
parent: parentX,
path: '/foo/:y/:z',
});
const internal = toInternalSubRouteRef(routeRef);
const internal = OpaqueSubRouteRef.toInternal(routeRef);
expect(internal.path).toBe('/foo/:y/:z');
expect(internal.getParent()).toBe(parentX);
expect(internal.getParams()).toEqual(['x', 'y', 'z']);
@@ -75,7 +72,7 @@ describe('SubRouteRef', () => {
parent: parentX,
path: '/foo/bar',
});
const internal = toInternalSubRouteRef(routeRef);
const internal = OpaqueSubRouteRef.toInternal(routeRef);
expect(internal.path).toBe('/foo/bar');
expect(internal.getParent()).toBe(parentX);
expect(internal.getParams()).toEqual(['x']);
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { RouteRef, toInternalRouteRef } from './RouteRef';
import { OpaqueRouteRef, OpaqueSubRouteRef } from '@internal/frontend';
import { RouteRef } from './RouteRef';
import { AnyRouteRefParams } from './types';
// Should match the pattern in react-router
@@ -50,59 +51,6 @@ export interface InternalSubRouteRef<
getDescription(): string;
}
/** @internal */
export function toInternalSubRouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
>(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 AnyRouteRefParams>
implements SubRouteRef<TParams>
{
readonly $$type = '@backstage/SubRouteRef';
readonly version = 'v1';
declare readonly T: never;
#params: string[];
#parent: RouteRef;
constructor(readonly path: string, params: string[], parent: RouteRef) {
this.#params = params;
this.#parent = parent;
}
getParams(): string[] {
return this.#params;
}
getParent(): RouteRef {
return this.#parent;
}
getDescription(): string {
const parent = toInternalRouteRef(this.#parent);
return `at ${this.path} with parent ${parent.getDescription()}`;
}
toString(): string {
return `SubRouteRef{${this.getDescription()}}`;
}
}
/**
* Used in {@link PathParams} type declaration.
* @ignore
@@ -168,8 +116,9 @@ export function createSubRouteRef<
const { path, parent } = config;
type Params = PathParams<Path>;
const internalParent = toInternalRouteRef(parent);
const internalParent = OpaqueRouteRef.toInternal(parent);
const parentParams = internalParent.getParams();
const parentDescription = internalParent.getDescription();
// Collect runtime parameters from the path, e.g. ['bar', 'baz'] from '/foo/:bar/:baz'
const pathParams = path
@@ -195,14 +144,22 @@ export function createSubRouteRef<
}
}
// We ensure that the type of the return type is sane here
const subRouteRef = new SubRouteRefImpl(
return OpaqueSubRouteRef.createInstance('v1', {
T: undefined as unknown as TrimEmptyParams<
MergeParams<Params, ParentParams>
>,
path,
params as string[],
parent,
) 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.
return subRouteRef as any;
getParams() {
return params;
},
getParent() {
return parent;
},
getDescription() {
return `at ${path} with parent ${parentDescription}`;
},
toString() {
return `subRouteRef{path='${path}',parent=${parent}}`;
},
});
}