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
+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;
};
/**