frontend-plugin-api: convert ApiRef to an opaque type

Convert the ApiRef type in the new frontend system to an opaque type
with a $$type discriminator, matching the pattern used by route refs
and extension data refs. Add a builder-pattern creation overload
(createApiRef<T>().with({ id })) alongside the existing direct-config
form. Create OpaqueApiRef in frontend-internal for internal type
validation.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-16 11:09:17 +01:00
parent 05594087b9
commit cc459f73a8
10 changed files with 174 additions and 56 deletions
+7
View File
@@ -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<MyApi>().with({ id: 'plugin.my.api' })`. The existing `createApiRef<MyApi>({ id: 'plugin.my.api' })` pattern continues to work.
@@ -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}');
@@ -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],
@@ -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<unknown>;
versions: {
readonly version: 'v1';
};
}>({
type: '@backstage/ApiRef',
versions: ['v1'],
});
@@ -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';
+1
View File
@@ -14,5 +14,6 @@
* limitations under the License.
*/
export * from './apis';
export * from './routing';
export * from './wiring';
+8 -2
View File
@@ -188,8 +188,9 @@ export type ApiHolder = {
// @public
export type ApiRef<T> = {
id: string;
T: T;
readonly $$type: '@backstage/ApiRef';
readonly id: string;
readonly T: T;
};
// @public
@@ -418,6 +419,11 @@ export function createApiFactory<Api, Impl extends Api>(
// @public
export function createApiRef<T>(config: ApiRefConfig): ApiRef<T>;
// @public
export function createApiRef<T>(): {
with(config: ApiRefConfig): ApiRef<T>;
};
// @public
export function createExtension<
UOutput extends ExtensionDataRef,
@@ -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<string>().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'`,
);
});
});
@@ -25,48 +25,85 @@ export type ApiRefConfig = {
id: string;
};
class ApiRefImpl<T> implements ApiRef<T> {
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<T>(id: string): ApiRef<T> {
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<T>;
}
/**
* 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.<plugin-id>.*` (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.<plugin-id>.*` (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<MyApi>().with({ id: 'plugin.my.api' });
* ```
*
* For backwards compatibility, you can also pass the config directly:
*
* ```ts
* const myApiRef = createApiRef<MyApi>({ id: 'plugin.my.api' });
* ```
*
* @public
*/
export function createApiRef<T>(config: ApiRefConfig): ApiRef<T> {
return new ApiRefImpl<T>(config);
export function createApiRef<T>(config: ApiRefConfig): ApiRef<T>;
/**
* Creates a reference to an API.
*
* @remarks
*
* Returns a builder with a `.with()` method for providing the `id`.
*
* @public
*/
export function createApiRef<T>(): {
with(config: ApiRefConfig): ApiRef<T>;
};
export function createApiRef<T>(
config?: ApiRefConfig,
): ApiRef<T> | { with(config: ApiRefConfig): ApiRef<T> } {
if (config) {
validateId(config.id);
return makeApiRef<T>(config.id);
}
return {
with(withConfig: ApiRefConfig): ApiRef<T> {
validateId(withConfig.id);
return makeApiRef<T>(withConfig.id);
},
};
}
@@ -20,8 +20,9 @@
* @public
*/
export type ApiRef<T> = {
id: string;
T: T;
readonly $$type: '@backstage/ApiRef';
readonly id: string;
readonly T: T;
};
/**