From 4d03f08d19f6a7d046b5e8dc4b7775557e37e984 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 17 Nov 2025 10:35:21 +0100 Subject: [PATCH] frontend-plugin-api: refactor to use opaque type for route refs Signed-off-by: Patrik Oldsberg --- .changeset/busy-goats-kiss.md | 7 + packages/core-compat-api/package.json | 4 +- .../src/convertLegacyRouteRef.test.ts | 26 ++-- .../src/convertLegacyRouteRef.ts | 24 ++-- .../src/routing/RouteAliasResolver.ts | 5 +- .../src/routing/RouteResolver.ts | 25 ++-- .../src/routing/collectRouteIds.ts | 22 ++- .../src/routing/resolveRouteBindings.ts | 5 +- packages/frontend-internal/src/index.ts | 1 + .../src/routing/OpaqueExternalRouteRef.ts | 34 +++++ .../src/routing/OpaqueRouteRef.ts | 35 +++++ .../src/routing/OpaqueSubRouteRef.ts | 32 +++++ .../frontend-internal/src/routing/index.ts | 19 +++ .../src/routing/ExternalRouteRef.test.ts | 11 +- .../src/routing/ExternalRouteRef.ts | 74 +++++------ .../src/routing/RouteRef.test.ts | 7 +- .../src/routing/RouteRef.ts | 125 +++++------------- .../src/routing/SubRouteRef.test.ts | 19 ++- .../src/routing/SubRouteRef.ts | 85 +++--------- 19 files changed, 282 insertions(+), 278 deletions(-) create mode 100644 .changeset/busy-goats-kiss.md create mode 100644 packages/frontend-internal/src/routing/OpaqueExternalRouteRef.ts create mode 100644 packages/frontend-internal/src/routing/OpaqueRouteRef.ts create mode 100644 packages/frontend-internal/src/routing/OpaqueSubRouteRef.ts create mode 100644 packages/frontend-internal/src/routing/index.ts diff --git a/.changeset/busy-goats-kiss.md b/.changeset/busy-goats-kiss.md new file mode 100644 index 0000000000..558642be94 --- /dev/null +++ b/.changeset/busy-goats-kiss.md @@ -0,0 +1,7 @@ +--- +'@backstage/frontend-plugin-api': patch +'@backstage/frontend-app-api': patch +'@backstage/core-compat-api': patch +--- + +Internal refactor of route reference implementations with minor updates to the `toString` implementations. diff --git a/packages/core-compat-api/package.json b/packages/core-compat-api/package.json index cedf7e661e..7b3c5ff9b9 100644 --- a/packages/core-compat-api/package.json +++ b/packages/core-compat-api/package.json @@ -34,8 +34,10 @@ "@backstage/core-plugin-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", + "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "zod": "^3.22.4" }, "devDependencies": { "@backstage/cli": "workspace:^", diff --git a/packages/core-compat-api/src/convertLegacyRouteRef.test.ts b/packages/core-compat-api/src/convertLegacyRouteRef.test.ts index f09060212c..b61ba3a114 100644 --- a/packages/core-compat-api/src/convertLegacyRouteRef.test.ts +++ b/packages/core-compat-api/src/convertLegacyRouteRef.test.ts @@ -31,13 +31,11 @@ import { createExternalRouteRef as createNewExternalRouteRef, } from '@backstage/frontend-plugin-api'; import { convertLegacyRouteRef } from './convertLegacyRouteRef'; - -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { toInternalRouteRef as toInternalNewRouteRef } from '../../frontend-plugin-api/src/routing/RouteRef'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { toInternalSubRouteRef as toInternalNewSubRouteRef } from '../../frontend-plugin-api/src/routing/SubRouteRef'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { toInternalExternalRouteRef as toInternalNewExternalRouteRef } from '../../frontend-plugin-api/src/routing/ExternalRouteRef'; +import { + OpaqueExternalRouteRef, + OpaqueRouteRef, + OpaqueSubRouteRef, +} from '@internal/frontend'; describe('convertLegacyRouteRef', () => { it('converts old to new', () => { @@ -85,13 +83,13 @@ describe('convertLegacyRouteRef', () => { expect(ref3).toBe(ref3Converted); expect(ref4).toBe(ref4Converted); - const ref1Internal = toInternalNewRouteRef(ref1Converted); - const ref2Internal = toInternalNewRouteRef(ref2Converted); - const ref1sub1Internal = toInternalNewSubRouteRef(ref1sub1Converted); - const ref1sub2Internal = toInternalNewSubRouteRef(ref1sub2Converted); - const ref2sub1Internal = toInternalNewSubRouteRef(ref2sub1Converted); - const ref3Internal = toInternalNewExternalRouteRef(ref3Converted); - const ref4Internal = toInternalNewExternalRouteRef(ref4Converted); + const ref1Internal = OpaqueRouteRef.toInternal(ref1Converted); + const ref2Internal = OpaqueRouteRef.toInternal(ref2Converted); + const ref1sub1Internal = OpaqueSubRouteRef.toInternal(ref1sub1Converted); + const ref1sub2Internal = OpaqueSubRouteRef.toInternal(ref1sub2Converted); + const ref2sub1Internal = OpaqueSubRouteRef.toInternal(ref2sub1Converted); + const ref3Internal = OpaqueExternalRouteRef.toInternal(ref3Converted); + const ref4Internal = OpaqueExternalRouteRef.toInternal(ref4Converted); expect(ref1Internal.getDescription()).toBe( 'routeRef{type=absolute,id=ref1}', diff --git a/packages/core-compat-api/src/convertLegacyRouteRef.ts b/packages/core-compat-api/src/convertLegacyRouteRef.ts index 40a95a81ff..a8dfd8193e 100644 --- a/packages/core-compat-api/src/convertLegacyRouteRef.ts +++ b/packages/core-compat-api/src/convertLegacyRouteRef.ts @@ -32,13 +32,11 @@ import { createSubRouteRef, createExternalRouteRef, } from '@backstage/frontend-plugin-api'; - -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { toInternalRouteRef } from '../../frontend-plugin-api/src/routing/RouteRef'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { toInternalSubRouteRef } from '../../frontend-plugin-api/src/routing/SubRouteRef'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { toInternalExternalRouteRef } from '../../frontend-plugin-api/src/routing/ExternalRouteRef'; +import { + OpaqueRouteRef, + OpaqueSubRouteRef, + OpaqueExternalRouteRef, +} from '@internal/frontend'; /** * Converts a legacy route ref type to the new system. @@ -184,7 +182,7 @@ function convertNewToOld( ref: RouteRef | SubRouteRef | ExternalRouteRef, ): LegacyRouteRef | LegacySubRouteRef | LegacyExternalRouteRef { if (ref.$$type === '@backstage/RouteRef') { - const newRef = toInternalRouteRef(ref); + const newRef = OpaqueRouteRef.toInternal(ref); return Object.assign(ref, { [routeRefType]: 'absolute', params: newRef.getParams(), @@ -192,7 +190,7 @@ function convertNewToOld( } as Omit) as unknown as LegacyRouteRef; } if (ref.$$type === '@backstage/SubRouteRef') { - const newRef = toInternalSubRouteRef(ref); + const newRef = OpaqueSubRouteRef.toInternal(ref); return Object.assign(ref, { [routeRefType]: 'sub', parent: convertLegacyRouteRef(newRef.getParent()), @@ -200,7 +198,7 @@ function convertNewToOld( } as Omit) as unknown as LegacySubRouteRef; } if (ref.$$type === '@backstage/ExternalRouteRef') { - const newRef = toInternalExternalRouteRef(ref); + const newRef = OpaqueExternalRouteRef.toInternal(ref); return Object.assign(ref, { [routeRefType]: 'external', optional: true, @@ -221,7 +219,7 @@ function convertOldToNew( if (type === 'absolute') { const legacyRef = ref as LegacyRouteRef; const legacyRefStr = String(legacyRef); - const newRef = toInternalRouteRef( + const newRef = OpaqueRouteRef.toInternal( createRouteRef<{ [key in string]: string }>({ params: legacyRef.params as string[], }), @@ -247,7 +245,7 @@ function convertOldToNew( if (type === 'sub') { const legacyRef = ref as LegacySubRouteRef; const legacyRefStr = String(legacyRef); - const newRef = toInternalSubRouteRef( + const newRef = OpaqueSubRouteRef.toInternal( createSubRouteRef({ path: legacyRef.path, parent: convertLegacyRouteRef(legacyRef.parent), @@ -274,7 +272,7 @@ function convertOldToNew( if (type === 'external') { const legacyRef = ref as LegacyExternalRouteRef; const legacyRefStr = String(legacyRef); - const newRef = toInternalExternalRouteRef( + const newRef = OpaqueExternalRouteRef.toInternal( createExternalRouteRef<{ [key in string]: string }>({ params: legacyRef.params as string[], defaultTarget: diff --git a/packages/frontend-app-api/src/routing/RouteAliasResolver.ts b/packages/frontend-app-api/src/routing/RouteAliasResolver.ts index 64e5eb7ce8..badf27f964 100644 --- a/packages/frontend-app-api/src/routing/RouteAliasResolver.ts +++ b/packages/frontend-app-api/src/routing/RouteAliasResolver.ts @@ -16,8 +16,7 @@ import { RouteRef } from '@backstage/frontend-plugin-api'; import { RouteRefsById } from './collectRouteIds'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { toInternalRouteRef } from '../../../frontend-plugin-api/src/routing/RouteRef'; +import { OpaqueRouteRef } from '@internal/frontend'; /** * @internal @@ -41,7 +40,7 @@ export function createRouteAliasResolver( let currentRef = routeRef; for (let i = 0; i < 100; i++) { - const alias = toInternalRouteRef(currentRef).alias; + const alias = OpaqueRouteRef.toInternal(currentRef).alias; if (alias) { if (pluginId) { const [aliasPluginId] = alias.split('.'); diff --git a/packages/frontend-app-api/src/routing/RouteResolver.ts b/packages/frontend-app-api/src/routing/RouteResolver.ts index 4c0ad0e3fa..7fd7ef934c 100644 --- a/packages/frontend-app-api/src/routing/RouteResolver.ts +++ b/packages/frontend-app-api/src/routing/RouteResolver.ts @@ -25,18 +25,11 @@ import { } from '@backstage/frontend-plugin-api'; import mapValues from 'lodash/mapValues'; import { AnyRouteRef, BackstageRouteObject } from './types'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { isRouteRef } from '../../../frontend-plugin-api/src/routing/RouteRef'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports import { - isSubRouteRef, - toInternalSubRouteRef, -} from '../../../frontend-plugin-api/src/routing/SubRouteRef'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { - isExternalRouteRef, - toInternalExternalRouteRef, -} from '../../../frontend-plugin-api/src/routing/ExternalRouteRef'; + OpaqueRouteRef, + OpaqueExternalRouteRef, + OpaqueSubRouteRef, +} from '@internal/frontend'; import { RouteAliasResolver } from './RouteAliasResolver'; // Joins a list of paths together, avoiding trailing and duplicate slashes @@ -65,10 +58,10 @@ function resolveTargetRef( let ref: AnyRouteRef = targetRouteRef; let path = ''; - if (isExternalRouteRef(ref)) { + if (OpaqueExternalRouteRef.isType(ref)) { let resolvedRoute = routeBindings.get(ref); if (!resolvedRoute) { - const internal = toInternalExternalRouteRef(ref); + const internal = OpaqueExternalRouteRef.toInternal(ref); const defaultTarget = internal.getDefaultTarget(); if (defaultTarget) { resolvedRoute = routeRefsById.get(defaultTarget); @@ -80,13 +73,13 @@ function resolveTargetRef( ref = resolvedRoute; } - if (isSubRouteRef(ref)) { - const internal = toInternalSubRouteRef(ref); + if (OpaqueSubRouteRef.isType(ref)) { + const internal = OpaqueSubRouteRef.toInternal(ref); path = ref.path; ref = internal.getParent(); } - if (!isRouteRef(ref)) { + if (!OpaqueRouteRef.isType(ref)) { throw new Error( `Unexpectedly resolved ${targetRouteRef} to a non-route ref ${ref}`, ); diff --git a/packages/frontend-app-api/src/routing/collectRouteIds.ts b/packages/frontend-app-api/src/routing/collectRouteIds.ts index 79afb07928..c99c28ac1a 100644 --- a/packages/frontend-app-api/src/routing/collectRouteIds.ts +++ b/packages/frontend-app-api/src/routing/collectRouteIds.ts @@ -20,16 +20,12 @@ import { ExternalRouteRef, FrontendFeature, } from '@backstage/frontend-plugin-api'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports import { - isRouteRef, - toInternalRouteRef, -} from '../../../frontend-plugin-api/src/routing/RouteRef'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { toInternalExternalRouteRef } from '../../../frontend-plugin-api/src/routing/ExternalRouteRef'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { toInternalSubRouteRef } from '../../../frontend-plugin-api/src/routing/SubRouteRef'; -import { OpaqueFrontendPlugin } from '@internal/frontend'; + OpaqueRouteRef, + OpaqueSubRouteRef, + OpaqueExternalRouteRef, + OpaqueFrontendPlugin, +} from '@internal/frontend'; import { ErrorCollector } from '../wiring/createErrorCollector'; /** @internal */ @@ -62,12 +58,12 @@ export function collectRouteIds( continue; } - if (isRouteRef(ref)) { - const internalRef = toInternalRouteRef(ref); + if (OpaqueRouteRef.isType(ref)) { + const internalRef = OpaqueRouteRef.toInternal(ref); internalRef.setId(refId); routesById.set(refId, ref); } else { - const internalRef = toInternalSubRouteRef(ref); + const internalRef = OpaqueSubRouteRef.toInternal(ref); routesById.set(refId, internalRef); } } @@ -82,7 +78,7 @@ export function collectRouteIds( continue; } - const internalRef = toInternalExternalRouteRef(ref); + const internalRef = OpaqueExternalRouteRef.toInternal(ref); internalRef.setId(refId); externalRoutesById.set(refId, ref); } diff --git a/packages/frontend-app-api/src/routing/resolveRouteBindings.ts b/packages/frontend-app-api/src/routing/resolveRouteBindings.ts index f9836d7107..44fe4ecbf5 100644 --- a/packages/frontend-app-api/src/routing/resolveRouteBindings.ts +++ b/packages/frontend-app-api/src/routing/resolveRouteBindings.ts @@ -23,8 +23,7 @@ import { RouteRefsById } from './collectRouteIds'; import { ErrorCollector } from '../wiring/createErrorCollector'; import { Config } from '@backstage/config'; import { JsonObject } from '@backstage/types'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { toInternalExternalRouteRef } from '../../../frontend-plugin-api/src/routing/ExternalRouteRef'; +import { OpaqueExternalRouteRef } from '@internal/frontend'; /** * Extracts a union of the keys in a map whose value extends the given type @@ -166,7 +165,7 @@ export function resolveRouteBindings( for (const externalRef of routesById.externalRoutes.values()) { if (!result.has(externalRef) && !disabledExternalRefs.has(externalRef)) { const defaultRefId = - toInternalExternalRouteRef(externalRef).getDefaultTarget(); + OpaqueExternalRouteRef.toInternal(externalRef).getDefaultTarget(); if (defaultRefId) { const defaultRef = routesById.routes.get(defaultRefId); if (defaultRef) { diff --git a/packages/frontend-internal/src/index.ts b/packages/frontend-internal/src/index.ts index a5728f2ff6..38bfdc53f8 100644 --- a/packages/frontend-internal/src/index.ts +++ b/packages/frontend-internal/src/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ +export * from './routing'; export * from './wiring'; diff --git a/packages/frontend-internal/src/routing/OpaqueExternalRouteRef.ts b/packages/frontend-internal/src/routing/OpaqueExternalRouteRef.ts new file mode 100644 index 0000000000..9e70dc496a --- /dev/null +++ b/packages/frontend-internal/src/routing/OpaqueExternalRouteRef.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ExternalRouteRef } from '@backstage/frontend-plugin-api'; +import { OpaqueType } from '@internal/opaque'; + +export const OpaqueExternalRouteRef = OpaqueType.create<{ + public: ExternalRouteRef; + versions: { + readonly version: 'v1'; + + getParams(): string[]; + getDescription(): string; + getDefaultTarget(): string | undefined; + + setId(id: string): void; + }; +}>({ + type: '@backstage/ExternalRouteRef', + versions: ['v1'], +}); diff --git a/packages/frontend-internal/src/routing/OpaqueRouteRef.ts b/packages/frontend-internal/src/routing/OpaqueRouteRef.ts new file mode 100644 index 0000000000..83126de8e2 --- /dev/null +++ b/packages/frontend-internal/src/routing/OpaqueRouteRef.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RouteRef } from '@backstage/frontend-plugin-api'; +import { OpaqueType } from '@internal/opaque'; + +export const OpaqueRouteRef = OpaqueType.create<{ + public: RouteRef; + versions: { + readonly version: 'v1'; + + getParams(): string[]; + getDescription(): string; + + alias: string | undefined; + + setId(id: string): void; + }; +}>({ + type: '@backstage/RouteRef', + versions: ['v1'], +}); diff --git a/packages/frontend-internal/src/routing/OpaqueSubRouteRef.ts b/packages/frontend-internal/src/routing/OpaqueSubRouteRef.ts new file mode 100644 index 0000000000..a8559f76a2 --- /dev/null +++ b/packages/frontend-internal/src/routing/OpaqueSubRouteRef.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RouteRef, SubRouteRef } from '@backstage/frontend-plugin-api'; +import { OpaqueType } from '@internal/opaque'; + +export const OpaqueSubRouteRef = OpaqueType.create<{ + public: SubRouteRef; + versions: { + readonly version: 'v1'; + + getParams(): string[]; + getParent(): RouteRef; + getDescription(): string; + }; +}>({ + type: '@backstage/SubRouteRef', + versions: ['v1'], +}); diff --git a/packages/frontend-internal/src/routing/index.ts b/packages/frontend-internal/src/routing/index.ts new file mode 100644 index 0000000000..cc8c0ee5b8 --- /dev/null +++ b/packages/frontend-internal/src/routing/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { OpaqueRouteRef } from './OpaqueRouteRef'; +export { OpaqueSubRouteRef } from './OpaqueSubRouteRef'; +export { OpaqueExternalRouteRef } from './OpaqueExternalRouteRef'; diff --git a/packages/frontend-plugin-api/src/routing/ExternalRouteRef.test.ts b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.test.ts index 97d3dd2264..93b5ed727c 100644 --- a/packages/frontend-plugin-api/src/routing/ExternalRouteRef.test.ts +++ b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.test.ts @@ -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 = 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']); }); diff --git a/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts index 3de276f85c..cd2f98f504 100644 --- a/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/ExternalRouteRef.ts @@ -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}'}`; + }, + }); } diff --git a/packages/frontend-plugin-api/src/routing/RouteRef.test.ts b/packages/frontend-plugin-api/src/routing/RouteRef.test.ts index 570723df9a..f7b8e1b450 100644 --- a/packages/frontend-plugin-api/src/routing/RouteRef.test.ts +++ b/packages/frontend-plugin-api/src/routing/RouteRef.test.ts @@ -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 = 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/); }); diff --git a/packages/frontend-plugin-api/src/routing/RouteRef.ts b/packages/frontend-plugin-api/src/routing/RouteRef.ts index 46076e6258..62f29adb86 100644 --- a/packages/frontend-plugin-api/src/routing/RouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/RouteRef.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 { - readonly version: 'v1'; - getParams(): string[]; - getDescription(): string; - - alias: string | undefined; - - setId(id: string): void; -} - -/** @internal */ -export function toInternalRouteRef< - TParams extends AnyRouteRefParams = AnyRouteRefParams, ->(resource: RouteRef): InternalRouteRef { - const r = resource as InternalRouteRef; - 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; + 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}'}`; + }, + }); } diff --git a/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts b/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts index b04728c49b..cd0b033d98 100644 --- a/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts +++ b/packages/frontend-plugin-api/src/routing/SubRouteRef.test.ts @@ -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']); diff --git a/packages/frontend-plugin-api/src/routing/SubRouteRef.ts b/packages/frontend-plugin-api/src/routing/SubRouteRef.ts index 74996ab4e7..4fe11ff6fb 100644 --- a/packages/frontend-plugin-api/src/routing/SubRouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/SubRouteRef.ts @@ -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): 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'; - 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; - 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 + >, path, - params as string[], - parent, - ) 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. - return subRouteRef as any; + getParams() { + return params; + }, + getParent() { + return parent; + }, + getDescription() { + return `at ${path} with parent ${parentDescription}`; + }, + toString() { + return `subRouteRef{path='${path}',parent=${parent}}`; + }, + }); }