From 873e42497a4c89883b9c921d543d8793a27183ce Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 11 Sep 2024 11:51:40 +0200 Subject: [PATCH] frontend-internal: add OpaqueType helper Signed-off-by: Patrik Oldsberg --- .changeset/funny-rocks-train.md | 6 + .../src/wiring/InternalExtensionDefinition.ts | 52 +++--- .../src/wiring/OpaqueType.ts | 164 ++++++++++++++++++ .../frontend-internal/src/wiring/index.ts | 5 +- .../src/wiring/createExtension.ts | 48 ++--- .../wiring/createExtensionBlueprint.test.tsx | 6 +- .../src/wiring/createFrontendModule.ts | 9 +- .../src/wiring/createFrontendPlugin.ts | 9 +- .../src/wiring/resolveExtensionDefinition.ts | 4 +- .../src/app/createExtensionTester.tsx | 6 +- 10 files changed, 230 insertions(+), 79 deletions(-) create mode 100644 .changeset/funny-rocks-train.md create mode 100644 packages/frontend-internal/src/wiring/OpaqueType.ts diff --git a/.changeset/funny-rocks-train.md b/.changeset/funny-rocks-train.md new file mode 100644 index 0000000000..f5b2fbba63 --- /dev/null +++ b/.changeset/funny-rocks-train.md @@ -0,0 +1,6 @@ +--- +'@backstage/frontend-plugin-api': patch +'@backstage/frontend-test-utils': patch +--- + +Internal refactor of usage of opaque types. diff --git a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts index 840e45ff4a..a77ffd485b 100644 --- a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts +++ b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts @@ -25,19 +25,19 @@ import { PortableSchema, ResolvedExtensionInputs, } from '@backstage/frontend-plugin-api'; +import { OpaqueType } from './OpaqueType'; -export type InternalExtensionDefinition< - T extends ExtensionDefinitionParameters = ExtensionDefinitionParameters, -> = ExtensionDefinition & { - readonly kind?: string; - readonly namespace?: string; - readonly name?: string; - readonly attachTo: { id: string; input: string }; - readonly disabled: boolean; - readonly configSchema?: PortableSchema; -} & ( +export const OpaqueExtensionDefinition = OpaqueType.create<{ + public: ExtensionDefinition; + versions: | { readonly version: 'v1'; + readonly kind?: string; + readonly namespace?: string; + readonly name?: string; + readonly attachTo: { id: string; input: string }; + readonly disabled: boolean; + readonly configSchema?: PortableSchema; readonly inputs: { [inputName in string]: { $$type: '@backstage/ExtensionInput'; @@ -63,6 +63,12 @@ export type InternalExtensionDefinition< } | { readonly version: 'v2'; + readonly kind?: string; + readonly namespace?: string; + readonly name?: string; + readonly attachTo: { id: string; input: string }; + readonly disabled: boolean; + readonly configSchema?: PortableSchema; readonly inputs: { [inputName in string]: ExtensionInput< AnyExtensionDataRef, @@ -81,24 +87,8 @@ export type InternalExtensionDefinition< >; }>; }): Iterable>; - } - ); - -/** @internal */ -export function toInternalExtensionDefinition< - T extends ExtensionDefinitionParameters, ->(overrides: ExtensionDefinition): InternalExtensionDefinition { - const internal = overrides as InternalExtensionDefinition; - if (internal.$$type !== '@backstage/ExtensionDefinition') { - throw new Error( - `Invalid extension definition instance, bad type '${internal.$$type}'`, - ); - } - const version = internal.version; - if (version !== 'v1' && version !== 'v2') { - throw new Error( - `Invalid extension definition instance, bad version '${version}'`, - ); - } - return internal; -} + }; +}>({ + type: '@backstage/ExtensionDefinition', + versions: ['v1', 'v2'], +}); diff --git a/packages/frontend-internal/src/wiring/OpaqueType.ts b/packages/frontend-internal/src/wiring/OpaqueType.ts new file mode 100644 index 0000000000..868bca3315 --- /dev/null +++ b/packages/frontend-internal/src/wiring/OpaqueType.ts @@ -0,0 +1,164 @@ +/* + * Copyright 2024 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. + */ + +// TODO(Rugvip): This lives here temporarily, but should be moved to a more +// central location. It's useful for backend packages too so we'll need to have +// it in a common package, but it might also be that we want to make it +// available publicly too in which case it would make sense to have this be part +// of @backstage/version-bridge. The problem with exporting it from there is +// that it would need to be very stable at that point, so it might be a bit +// early to put it there already. + +/** + * A helper for working with opaque types. + */ +export class OpaqueType< + T extends { + public: { $$type: string }; + versions: { version: string }; + }, +> { + /** + * Creates a new opaque type. + * + * @param options.type The type identifier of the opaque type + * @param options.versions The available versions of the opaque type + * @returns A new opaque type helper + */ + static create< + T extends { + public: { $$type: string }; + versions: { version: string }; + }, + >(options: { + type: T['public']['$$type']; + versions: Array; + }) { + return new OpaqueType(options.type, new Set(options.versions)); + } + + #type: string; + #versions: Set; + + private constructor(type: string, versions: Set) { + this.#type = type; + this.#versions = versions; + } + + /** + * The internal version of the opaque type, used like this: `typeof MyOpaqueType.TPublic` + * + * @remarks + * + * This property is only useful for type checking, its runtime value is `undefined`. + */ + TPublic: T['public'] = undefined as any; + + /** + * The internal version of the opaque type, used like this: `typeof MyOpaqueType.TInternal` + * + * @remarks + * + * This property is only useful for type checking, its runtime value is `undefined`. + */ + TInternal: T['public'] & T['versions'] = undefined as any; + + /** + * @param value Input value expected to be an instance of this opaque type + * @throws If the value is not an instance of this opaque type + * @returns The internal version of the opaque type + */ + toInternal(value: unknown): T['public'] & T['versions'] { + if (!this.#isThisType(value)) { + throw new Error( + `Invalid opaque type, expected '${ + this.#type + }', but got '${this.#stringifyUnknown(value)}'`, + ); + } + this.#throwIfInvalidVersion(value.version); + return value; + } + + /** + * @param value Input value expected to be an instance of this opaque type + * @returns True if the value matches this opaque type + */ + isInternal(value: unknown): value is T['public'] & T['versions'] { + if (!this.#isThisType(value)) { + return false; + } + this.#throwIfInvalidVersion(value.version); + return true; + } + + /** + * @param version The expected version of the opaque type + * @param value Input value expected to be an instance of this opaque type + * @returns True if the value matches this opaque type and is the expected version + */ + isVersion( + version: TVersion, + value: unknown, + ): value is T['public'] & + (T['versions'] extends infer UVersion + ? UVersion extends { version: TVersion } + ? UVersion + : never + : never) { + return this.#isThisType(value) && value.version === version; + } + + /** + * Creates an instance of the opaque type, returning the public public type. + * + * By providing a type argument you can narrow the return to specific type parameters. + */ + create( + value: T['public'] & T['versions'] & Object, // & Object to allow for object properties too, e.g. toString() + ): TBase { + return value as unknown as TBase; + } + + #throwIfInvalidVersion(version: string) { + if (!this.#versions.has(version)) { + const versionsStr = Array.from(this.#versions).join("', '"); + throw new Error( + `Invalid opaque type instance, bad version '${version}', expected one of '${versionsStr}'`, + ); + } + } + + #isThisType(value: unknown): value is T['public'] & T['versions'] { + if (value === null || typeof value !== 'object') { + return false; + } + return (value as T['public']).$$type === this.#type; + } + + #stringifyUnknown(value: unknown) { + if (typeof value !== 'object') { + return `<${typeof value}>`; + } + if (value === null) { + return ''; + } + if ('$$type' in value) { + return String(value.$$type); + } + return String(value); + } +} diff --git a/packages/frontend-internal/src/wiring/index.ts b/packages/frontend-internal/src/wiring/index.ts index d0aefbed64..86f1ed9ab7 100644 --- a/packages/frontend-internal/src/wiring/index.ts +++ b/packages/frontend-internal/src/wiring/index.ts @@ -14,7 +14,4 @@ * limitations under the License. */ -export { - toInternalExtensionDefinition, - type InternalExtensionDefinition, -} from './InternalExtensionDefinition'; +export { OpaqueExtensionDefinition } from './InternalExtensionDefinition'; diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 7aef3fc1b5..78e66dffe5 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -31,7 +31,7 @@ import { import { ExtensionInput } from './createExtensionInput'; import { z } from 'zod'; import { createSchemaFromZod } from '../schema/createSchemaFromZod'; -import { InternalExtensionDefinition } from '@internal/frontend'; +import { OpaqueExtensionDefinition } from '@internal/frontend'; /** * Convert a single extension input into a matching resolved input. @@ -366,26 +366,6 @@ export function createExtension< namespace: string | undefined extends TNamespace ? undefined : TNamespace; name: string | undefined extends TName ? undefined : TName; }> { - type T = { - config: string extends keyof TConfigSchema - ? {} - : { - [key in keyof TConfigSchema]: z.infer>; - }; - configInput: string extends keyof TConfigSchema - ? {} - : z.input< - z.ZodObject<{ - [key in keyof TConfigSchema]: ReturnType; - }> - >; - output: UOutput; - inputs: TInputs; - kind: string | undefined extends TKind ? undefined : TKind; - namespace: string | undefined extends TNamespace ? undefined : TNamespace; - name: string | undefined extends TName ? undefined : TName; - }; - const schemaDeclaration = options.config?.schema; const configSchema = schemaDeclaration && @@ -397,10 +377,30 @@ export function createExtension< ), ); - return { + return OpaqueExtensionDefinition.create({ $$type: '@backstage/ExtensionDefinition', version: 'v2', - T: undefined as unknown as T, + T: undefined as unknown as { + config: string extends keyof TConfigSchema + ? {} + : { + [key in keyof TConfigSchema]: z.infer< + ReturnType + >; + }; + configInput: string extends keyof TConfigSchema + ? {} + : z.input< + z.ZodObject<{ + [key in keyof TConfigSchema]: ReturnType; + }> + >; + output: UOutput; + inputs: TInputs; + kind: string | undefined extends TKind ? undefined : TKind; + namespace: string | undefined extends TNamespace ? undefined : TNamespace; + name: string | undefined extends TName ? undefined : TName; + }, kind: options.kind, namespace: options.namespace, name: options.name, @@ -512,5 +512,5 @@ export function createExtension< }, }) as ExtensionDefinition; }, - } as InternalExtensionDefinition; + }); } diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index 47ef7a75b4..16c763d725 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -29,12 +29,12 @@ import { createExtensionInput } from './createExtensionInput'; import { RouteRef } from '../routing'; import { ExtensionDefinition } from './createExtension'; import { createExtensionDataContainer } from './createExtensionDataContainer'; -import { toInternalExtensionDefinition } from '@internal/frontend'; +import { OpaqueExtensionDefinition } from '@internal/frontend'; function unused(..._any: any[]) {} function factoryOutput(ext: ExtensionDefinition, inputs: unknown = undefined) { - const int = toInternalExtensionDefinition(ext); + const int = OpaqueExtensionDefinition.toInternal(ext); if (int.version !== 'v2') { throw new Error('Expected v2 extension'); } @@ -680,7 +680,7 @@ describe('createExtensionBlueprint', () => { }, }); - const ext = toInternalExtensionDefinition( + const ext = OpaqueExtensionDefinition.toInternal( blueprint.makeWithOverrides({ output: [testDataRef2], factory(origFactory) { diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts b/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts index e3fa8b6682..bece1539fa 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts @@ -14,10 +14,7 @@ * limitations under the License. */ -import { - InternalExtensionDefinition, - toInternalExtensionDefinition, -} from '@internal/frontend'; +import { OpaqueExtensionDefinition } from '@internal/frontend'; import { ExtensionDefinition } from './createExtension'; import { Extension, @@ -58,11 +55,11 @@ export function createFrontendModule< const extensions = new Array>(); const extensionDefinitionsById = new Map< string, - InternalExtensionDefinition + typeof OpaqueExtensionDefinition.TInternal >(); for (const def of options.extensions ?? []) { - const internal = toInternalExtensionDefinition(def); + const internal = OpaqueExtensionDefinition.toInternal(def); const ext = resolveExtensionDefinition(def, { namespace: pluginId }); extensions.push(ext); extensionDefinitionsById.set(ext.id, { diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts index adcd2b0867..865eae6444 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts @@ -14,10 +14,7 @@ * limitations under the License. */ -import { - InternalExtensionDefinition, - toInternalExtensionDefinition, -} from '@internal/frontend'; +import { OpaqueExtensionDefinition } from '@internal/frontend'; import { ExtensionDefinition } from './createExtension'; import { Extension, @@ -96,11 +93,11 @@ export function createFrontendPlugin< const extensions = new Array>(); const extensionDefinitionsById = new Map< string, - InternalExtensionDefinition + typeof OpaqueExtensionDefinition.TInternal >(); for (const def of options.extensions ?? []) { - const internal = toInternalExtensionDefinition(def); + const internal = OpaqueExtensionDefinition.toInternal(def); const ext = resolveExtensionDefinition(def, { namespace: options.id }); extensions.push(ext); extensionDefinitionsById.set(ext.id, { diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts index a377cde2b9..53de20bff2 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts @@ -26,7 +26,7 @@ import { AnyExtensionDataRef, ExtensionDataValue, } from './createExtensionDataRef'; -import { toInternalExtensionDefinition } from '@internal/frontend'; +import { OpaqueExtensionDefinition } from '@internal/frontend'; /** @public */ export interface Extension { @@ -141,7 +141,7 @@ export function resolveExtensionDefinition< definition: ExtensionDefinition, context?: { namespace?: string }, ): Extension { - const internalDefinition = toInternalExtensionDefinition(definition); + const internalDefinition = OpaqueExtensionDefinition.toInternal(definition); const { name, kind, diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.tsx index 5eda2d6e11..37ec1d8fd9 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.tsx @@ -37,7 +37,7 @@ import { instantiateAppNodeTree } from '../../../frontend-app-api/src/tree/insta // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { readAppExtensionsConfig } from '../../../frontend-app-api/src/tree/readAppExtensionsConfig'; import { TestApiRegistry } from '@backstage/test-utils'; -import { toInternalExtensionDefinition } from '@internal/frontend'; +import { OpaqueExtensionDefinition } from '@internal/frontend'; /** @public */ export class ExtensionQuery { @@ -105,7 +105,7 @@ export class ExtensionTester { ); } - const { name, namespace } = toInternalExtensionDefinition(extension); + const { name, namespace } = OpaqueExtensionDefinition.toInternal(extension); const definition = { ...extension, @@ -143,7 +143,7 @@ export class ExtensionTester { const tree = this.#resolveTree(); // Same fallback logic as in .add - const { name, namespace } = toInternalExtensionDefinition(extension); + const { name, namespace } = OpaqueExtensionDefinition.toInternal(extension); const definition = { ...extension, name: !namespace && !name ? 'test' : name,