diff --git a/.changeset/opaque-api-ref-type.md b/.changeset/opaque-api-ref-type.md new file mode 100644 index 0000000000..b1d7caf063 --- /dev/null +++ b/.changeset/opaque-api-ref-type.md @@ -0,0 +1,7 @@ +--- +'@backstage/frontend-plugin-api': minor +--- + +**BREAKING**: The `ApiRef` type is now an opaque type with a `$$type` discriminator field and `readonly` properties. This means that `ApiRef` instances can no longer be created as plain object literals. Use `createApiRef` to create API references. + +Added a new builder pattern for creating API references: `createApiRef().with({ id: 'plugin.my.api' })`. The existing `createApiRef({ id: 'plugin.my.api' })` pattern continues to work. diff --git a/packages/core-plugin-api/src/apis/system/ApiRef.test.ts b/packages/core-plugin-api/src/apis/system/ApiRef.test.ts index dab872236f..994cde44c6 100644 --- a/packages/core-plugin-api/src/apis/system/ApiRef.test.ts +++ b/packages/core-plugin-api/src/apis/system/ApiRef.test.ts @@ -19,6 +19,7 @@ import { createApiRef } from './ApiRef'; describe('ApiRef', () => { it('should be created', () => { const ref = createApiRef({ id: 'abc' }); + expect(ref.$$type).toBe('@backstage/ApiRef'); expect(ref.id).toBe('abc'); expect(String(ref)).toBe('apiRef{abc}'); expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}'); diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index 51c31aca25..e07e83677b 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -166,10 +166,11 @@ describe('createSpecializedApp', () => { "factories": Map { "core.featureflags" => { "factory": { - "api": ApiRefImpl { - "config": { - "id": "core.featureflags", - }, + "api": { + "$$type": "@backstage/ApiRef", + "id": "core.featureflags", + "toString": [Function], + "version": "v1", }, "deps": {}, "factory": [Function], @@ -178,10 +179,11 @@ describe('createSpecializedApp', () => { }, "core.app-tree" => { "factory": { - "api": ApiRefImpl { - "config": { - "id": "core.app-tree", - }, + "api": { + "$$type": "@backstage/ApiRef", + "id": "core.app-tree", + "toString": [Function], + "version": "v1", }, "deps": {}, "factory": [Function], @@ -190,10 +192,11 @@ describe('createSpecializedApp', () => { }, "core.config" => { "factory": { - "api": ApiRefImpl { - "config": { - "id": "core.config", - }, + "api": { + "$$type": "@backstage/ApiRef", + "id": "core.config", + "toString": [Function], + "version": "v1", }, "deps": {}, "factory": [Function], @@ -202,10 +205,11 @@ describe('createSpecializedApp', () => { }, "core.route-resolution" => { "factory": { - "api": ApiRefImpl { - "config": { - "id": "core.route-resolution", - }, + "api": { + "$$type": "@backstage/ApiRef", + "id": "core.route-resolution", + "toString": [Function], + "version": "v1", }, "deps": {}, "factory": [Function], @@ -214,10 +218,11 @@ describe('createSpecializedApp', () => { }, "core.identity" => { "factory": { - "api": ApiRefImpl { - "config": { - "id": "core.identity", - }, + "api": { + "$$type": "@backstage/ApiRef", + "id": "core.identity", + "toString": [Function], + "version": "v1", }, "deps": {}, "factory": [Function], diff --git a/packages/frontend-internal/src/apis/OpaqueApiRef.ts b/packages/frontend-internal/src/apis/OpaqueApiRef.ts new file mode 100644 index 0000000000..5e054a4bc5 --- /dev/null +++ b/packages/frontend-internal/src/apis/OpaqueApiRef.ts @@ -0,0 +1,28 @@ +/* + * 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 { ApiRef } from '@backstage/frontend-plugin-api'; +import { OpaqueType } from '@internal/opaque'; + +export const OpaqueApiRef = OpaqueType.create<{ + public: ApiRef; + versions: { + readonly version: 'v1'; + }; +}>({ + type: '@backstage/ApiRef', + versions: ['v1'], +}); diff --git a/packages/frontend-internal/src/apis/index.ts b/packages/frontend-internal/src/apis/index.ts new file mode 100644 index 0000000000..8476e86409 --- /dev/null +++ b/packages/frontend-internal/src/apis/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { OpaqueApiRef } from './OpaqueApiRef'; diff --git a/packages/frontend-internal/src/index.ts b/packages/frontend-internal/src/index.ts index 38bfdc53f8..4bd0348345 100644 --- a/packages/frontend-internal/src/index.ts +++ b/packages/frontend-internal/src/index.ts @@ -14,5 +14,6 @@ * limitations under the License. */ +export * from './apis'; export * from './routing'; export * from './wiring'; diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 768f26c0eb..bf71922386 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -188,8 +188,9 @@ export type ApiHolder = { // @public export type ApiRef = { - id: string; - T: T; + readonly $$type: '@backstage/ApiRef'; + readonly id: string; + readonly T: T; }; // @public @@ -418,6 +419,11 @@ export function createApiFactory( // @public export function createApiRef(config: ApiRefConfig): ApiRef; +// @public +export function createApiRef(): { + with(config: ApiRefConfig): ApiRef; +}; + // @public export function createExtension< UOutput extends ExtensionDataRef, diff --git a/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts b/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts index dab872236f..556fc3e477 100644 --- a/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts +++ b/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts @@ -17,8 +17,17 @@ import { createApiRef } from './ApiRef'; describe('ApiRef', () => { - it('should be created', () => { + it('should be created with config', () => { const ref = createApiRef({ id: 'abc' }); + expect(ref.$$type).toBe('@backstage/ApiRef'); + expect(ref.id).toBe('abc'); + expect(String(ref)).toBe('apiRef{abc}'); + expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}'); + }); + + it('should be created with builder pattern', () => { + const ref = createApiRef().with({ id: 'abc' }); + expect(ref.$$type).toBe('@backstage/ApiRef'); expect(ref.id).toBe('abc'); expect(String(ref)).toBe('apiRef{abc}'); expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}'); @@ -47,4 +56,10 @@ describe('ApiRef', () => { ); } }); + + it('should reject invalid ids with builder pattern', () => { + expect(() => createApiRef().with({ id: '123' })).toThrow( + `API id must only contain period separated lowercase alphanum tokens with dashes, got '123'`, + ); + }); }); diff --git a/packages/frontend-plugin-api/src/apis/system/ApiRef.ts b/packages/frontend-plugin-api/src/apis/system/ApiRef.ts index 0db3d89bd5..0dc181591c 100644 --- a/packages/frontend-plugin-api/src/apis/system/ApiRef.ts +++ b/packages/frontend-plugin-api/src/apis/system/ApiRef.ts @@ -25,48 +25,85 @@ export type ApiRefConfig = { id: string; }; -class ApiRefImpl implements ApiRef { - constructor(private readonly config: ApiRefConfig) { - const valid = config.id - .split('.') - .flatMap(part => part.split('-')) - .every(part => part.match(/^[a-z][a-z0-9]*$/)); - if (!valid) { - throw new Error( - `API id must only contain period separated lowercase alphanum tokens with dashes, got '${config.id}'`, - ); - } +function validateId(id: string): void { + const valid = id + .split('.') + .flatMap(part => part.split('-')) + .every(part => part.match(/^[a-z][a-z0-9]*$/)); + if (!valid) { + throw new Error( + `API id must only contain period separated lowercase alphanum tokens with dashes, got '${id}'`, + ); } +} - get id(): string { - return this.config.id; - } - - // Utility for getting type of an api, using `typeof apiRef.T` - get T(): T { - throw new Error(`tried to read ApiRef.T of ${this}`); - } - - toString() { - return `apiRef{${this.config.id}}`; - } +function makeApiRef(id: string): ApiRef { + const ref = { + $$type: '@backstage/ApiRef' as const, + version: 'v1', + id, + toString() { + return `apiRef{${id}}`; + }, + }; + Object.defineProperty(ref, 'T', { + get(): T { + throw new Error(`tried to read ApiRef.T of ${this}`); + }, + enumerable: false, + }); + return ref as unknown as ApiRef; } /** - * Creates a reference to an API. The provided `id` is a stable identifier for - * the API implementation. + * Creates a reference to an API. * * @remarks * - * The frontend system infers the owning plugin for an API from the `id`. The - * recommended pattern is `plugin..*` (for example, + * The `id` is a stable identifier for the API implementation. The frontend + * system infers the owning plugin for an API from the `id`. The recommended + * pattern is `plugin..*` (for example, * `plugin.catalog.entity-presentation`). This ensures that other plugins can't * mistakenly override your API implementation. * - * @param config - The descriptor of the API to reference. - * @returns An API reference. + * The recommended way to create an API reference is: + * + * ```ts + * const myApiRef = createApiRef().with({ id: 'plugin.my.api' }); + * ``` + * + * For backwards compatibility, you can also pass the config directly: + * + * ```ts + * const myApiRef = createApiRef({ id: 'plugin.my.api' }); + * ``` + * * @public */ -export function createApiRef(config: ApiRefConfig): ApiRef { - return new ApiRefImpl(config); +export function createApiRef(config: ApiRefConfig): ApiRef; +/** + * Creates a reference to an API. + * + * @remarks + * + * Returns a builder with a `.with()` method for providing the `id`. + * + * @public + */ +export function createApiRef(): { + with(config: ApiRefConfig): ApiRef; +}; +export function createApiRef( + config?: ApiRefConfig, +): ApiRef | { with(config: ApiRefConfig): ApiRef } { + if (config) { + validateId(config.id); + return makeApiRef(config.id); + } + return { + with(withConfig: ApiRefConfig): ApiRef { + validateId(withConfig.id); + return makeApiRef(withConfig.id); + }, + }; } diff --git a/packages/frontend-plugin-api/src/apis/system/types.ts b/packages/frontend-plugin-api/src/apis/system/types.ts index 96614c320c..570e9a4e25 100644 --- a/packages/frontend-plugin-api/src/apis/system/types.ts +++ b/packages/frontend-plugin-api/src/apis/system/types.ts @@ -20,8 +20,9 @@ * @public */ export type ApiRef = { - id: string; - T: T; + readonly $$type: '@backstage/ApiRef'; + readonly id: string; + readonly T: T; }; /**