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:
@@ -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';
|
||||
@@ -14,5 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './apis';
|
||||
export * from './routing';
|
||||
export * from './wiring';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user