frontend-plugin-api: add explicit ApiRef plugin ownership

Add the new frontend ApiRef builder form while preserving compatibility with existing refs, and let frontend apps resolve API ownership through an explicit pluginId when provided.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-16 16:40:44 +01:00
parent cc459f73a8
commit d911b72811
42 changed files with 355 additions and 155 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
Frontend apps now respect an explicit `pluginId` on `ApiRef`s when deciding which plugin owns an API factory.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-plugin-api': patch
---
Updated `createApiRef` to preserve the direct config call without deprecation warnings while staying compatible with the new frontend API ref typing.
+3 -5
View File
@@ -1,7 +1,5 @@
---
'@backstage/frontend-plugin-api': minor
---
## '@backstage/frontend-plugin-api': patch
**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 builder form for `createApiRef` in the new frontend system and deprecated the direct `createApiRef({ ... })` call in favor of `createApiRef().with({ ... })`.
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.
`ApiRef` and `ApiRefConfig` now also support an explicit `pluginId`, making it possible to declare API ownership without encoding the plugin ID into the API ref ID.
+2 -2
View File
@@ -28,7 +28,6 @@ import { ComponentType } from 'react';
import { ConfigApi } from '@backstage/frontend-plugin-api';
import { configApiRef } from '@backstage/frontend-plugin-api';
import { createApiFactory } from '@backstage/frontend-plugin-api';
import { createApiRef } from '@backstage/frontend-plugin-api';
import { DiscoveryApi } from '@backstage/frontend-plugin-api';
import { discoveryApiRef } from '@backstage/frontend-plugin-api';
import { ErrorApi } from '@backstage/frontend-plugin-api';
@@ -256,7 +255,8 @@ export { configApiRef };
export { createApiFactory };
export { createApiRef };
// @public
export function createApiRef<T>(config: ApiRefConfig): ApiRef<T>;
// @public
export function createComponentExtension<
@@ -14,5 +14,23 @@
* limitations under the License.
*/
export { createApiRef } from '@backstage/frontend-plugin-api';
export type { ApiRefConfig } from '@backstage/frontend-plugin-api';
import {
createApiRef as createFrontendApiRef,
type ApiRef,
type ApiRefConfig,
} from '@backstage/frontend-plugin-api';
const createFrontendApiRefCompat = createFrontendApiRef as <T>(
config: ApiRefConfig,
) => ApiRef<T>;
/**
* Creates a reference to an API.
*
* @public
*/
export function createApiRef<T>(config: ApiRefConfig): ApiRef<T> {
return createFrontendApiRefCompat<T>(config);
}
export type { ApiRefConfig };
@@ -169,6 +169,7 @@ describe('createSpecializedApp', () => {
"api": {
"$$type": "@backstage/ApiRef",
"id": "core.featureflags",
"pluginId": "app",
"toString": [Function],
"version": "v1",
},
@@ -182,6 +183,7 @@ describe('createSpecializedApp', () => {
"api": {
"$$type": "@backstage/ApiRef",
"id": "core.app-tree",
"pluginId": "app",
"toString": [Function],
"version": "v1",
},
@@ -195,6 +197,7 @@ describe('createSpecializedApp', () => {
"api": {
"$$type": "@backstage/ApiRef",
"id": "core.config",
"pluginId": "app",
"toString": [Function],
"version": "v1",
},
@@ -208,6 +211,7 @@ describe('createSpecializedApp', () => {
"api": {
"$$type": "@backstage/ApiRef",
"id": "core.route-resolution",
"pluginId": "app",
"toString": [Function],
"version": "v1",
},
@@ -221,6 +225,7 @@ describe('createSpecializedApp', () => {
"api": {
"$$type": "@backstage/ApiRef",
"id": "core.identity",
"pluginId": "app",
"toString": [Function],
"version": "v1",
},
@@ -364,6 +369,54 @@ describe('createSpecializedApp', () => {
expect(app.apis.get(testApiRef)).toEqual({ value: 'owner' });
});
it('should select the API factory from an explicitly owned plugin on conflict', () => {
const testApiRef = createApiRef<{ value: string }>().with({
id: 'shared.api',
pluginId: 'owner',
});
const app = createSpecializedApp({
features: [
makeAppPlugin(),
createFrontendPlugin({
pluginId: 'other-before',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: testApiRef,
deps: {},
factory: () => ({ value: 'other' }),
}),
}),
],
}),
createFrontendPlugin({
pluginId: 'owner',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: testApiRef,
deps: {},
factory: () => ({ value: 'owner' }),
}),
}),
],
}),
],
});
expect(app.errors).toEqual([
expect.objectContaining({
code: 'API_FACTORY_CONFLICT',
message: expect.stringContaining("API 'shared.api'"),
}),
]);
expect(app.apis.get(testApiRef)).toEqual({ value: 'owner' });
});
it('should allow API overrides within the same plugin', () => {
const testApiRef = createApiRef<{ value: string }>({ id: 'test.api' });
@@ -401,7 +401,7 @@ function createApiFactories(options: {
const apiFactory = apiNode.instance?.getData(ApiBlueprint.dataRefs.factory);
if (apiFactory) {
const apiRefId = apiFactory.api.id;
const ownerId = getApiOwnerId(apiRefId);
const ownerId = getApiOwnerId(apiFactory.api);
const pluginId = apiNode.spec.plugin.pluginId ?? 'app';
const existingFactory = factoriesById.get(apiRefId);
@@ -455,7 +455,12 @@ function createApiFactories(options: {
// TODO(Rugvip): It would be good if this was more explicit, but I think that
// might need to wait for some future update for API factories.
function getApiOwnerId(apiRefId: string): string {
function getApiOwnerId(apiRef: { id: string; pluginId?: string }): string {
if (apiRef.pluginId) {
return apiRef.pluginId;
}
const apiRefId = apiRef.id;
const [prefix, ...rest] = apiRefId.split('.');
if (!prefix) {
return apiRefId;
@@ -1,28 +0,0 @@
/*
* 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'],
});
@@ -1,17 +0,0 @@
/*
* 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,6 +14,5 @@
* limitations under the License.
*/
export * from './apis';
export * from './routing';
export * from './wiring';
@@ -24,7 +24,9 @@ export type PluginWrapperApi = {
};
// @public
export const pluginWrapperApiRef: ApiRef<PluginWrapperApi>;
export const pluginWrapperApiRef: ApiRef<PluginWrapperApi> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export const PluginWrapperBlueprint: ExtensionBlueprint<{
+28 -10
View File
@@ -188,14 +188,16 @@ export type ApiHolder = {
// @public
export type ApiRef<T> = {
readonly $$type: '@backstage/ApiRef';
readonly $$type?: '@backstage/ApiRef';
readonly id: string;
readonly pluginId?: string;
readonly T: T;
};
// @public
export type ApiRefConfig = {
id: string;
pluginId?: string;
};
// @public (undocumented)
@@ -306,7 +308,9 @@ export interface AppTreeApi {
}
// @public
export const appTreeApiRef: ApiRef_2<AppTreeApi>;
export const appTreeApiRef: ApiRef_2<AppTreeApi> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export const atlassianAuthApiRef: ApiRef<
@@ -416,12 +420,16 @@ export function createApiFactory<Api, Impl extends Api>(
instance: Impl,
): ApiFactory<Api, Impl, {}>;
// @public
export function createApiRef<T>(config: ApiRefConfig): ApiRef<T>;
// @public @deprecated
export function createApiRef<T>(config: ApiRefConfig): ApiRef<T> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export function createApiRef<T>(): {
with(config: ApiRefConfig): ApiRef<T>;
with(config: ApiRefConfig): ApiRef<T> & {
readonly $$type: '@backstage/ApiRef';
};
};
// @public
@@ -875,7 +883,9 @@ export interface DialogApiDialog<TResult = void> {
}
// @public
export const dialogApiRef: ApiRef_2<DialogApi>;
export const dialogApiRef: ApiRef_2<DialogApi> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export type DiscoveryApi = {
@@ -1436,7 +1446,9 @@ export interface IconsApi {
}
// @public
export const iconsApiRef: ApiRef_2<IconsApi>;
export const iconsApiRef: ApiRef_2<IconsApi> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export type IdentityApi = {
@@ -1851,7 +1863,9 @@ export type PluginHeaderActionsApi = {
};
// @public
export const pluginHeaderActionsApiRef: ApiRef_2<PluginHeaderActionsApi>;
export const pluginHeaderActionsApiRef: ApiRef_2<PluginHeaderActionsApi> & {
readonly $$type: '@backstage/ApiRef';
};
// @public (undocumented)
export interface PluginOptions<
@@ -1999,7 +2013,9 @@ export interface RouteResolutionApi {
}
// @public
export const routeResolutionApiRef: ApiRef_2<RouteResolutionApi>;
export const routeResolutionApiRef: ApiRef_2<RouteResolutionApi> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export type SessionApi = {
@@ -2127,7 +2143,9 @@ export interface SwappableComponentsApi {
}
// @public
export const swappableComponentsApiRef: ApiRef_2<SwappableComponentsApi>;
export const swappableComponentsApiRef: ApiRef_2<SwappableComponentsApi> & {
readonly $$type: '@backstage/ApiRef';
};
// @public (undocumented)
export type TranslationApi = {
@@ -51,6 +51,7 @@ export type AlertApi = {
*
* @public
*/
export const alertApiRef: ApiRef<AlertApi> = createApiRef({
export const alertApiRef: ApiRef<AlertApi> = createApiRef<AlertApi>().with({
id: 'core.alert',
pluginId: 'app',
});
@@ -151,6 +151,8 @@ export type AnalyticsApi = {
*
* @public
*/
export const analyticsApiRef: ApiRef<AnalyticsApi> = createApiRef({
id: 'core.analytics',
});
export const analyticsApiRef: ApiRef<AnalyticsApi> =
createApiRef<AnalyticsApi>().with({
id: 'core.analytics',
pluginId: 'app',
});
@@ -31,6 +31,8 @@ export type AppLanguageApi = {
/**
* @public
*/
export const appLanguageApiRef: ApiRef<AppLanguageApi> = createApiRef({
id: 'core.applanguage',
});
export const appLanguageApiRef: ApiRef<AppLanguageApi> =
createApiRef<AppLanguageApi>().with({
id: 'core.applanguage',
pluginId: 'app',
});
@@ -82,6 +82,8 @@ export type AppThemeApi = {
*
* @public
*/
export const appThemeApiRef: ApiRef<AppThemeApi> = createApiRef({
id: 'core.apptheme',
});
export const appThemeApiRef: ApiRef<AppThemeApi> =
createApiRef<AppThemeApi>().with({
id: 'core.apptheme',
pluginId: 'app',
});
@@ -117,4 +117,7 @@ export interface AppTreeApi {
*
* @public
*/
export const appTreeApiRef = createApiRef<AppTreeApi>({ id: 'core.app-tree' });
export const appTreeApiRef = createApiRef<AppTreeApi>().with({
id: 'core.app-tree',
pluginId: 'app',
});
@@ -29,6 +29,7 @@ export type ConfigApi = Config;
*
* @public
*/
export const configApiRef: ApiRef<ConfigApi> = createApiRef({
export const configApiRef: ApiRef<ConfigApi> = createApiRef<ConfigApi>().with({
id: 'core.config',
pluginId: 'app',
});
@@ -173,6 +173,7 @@ export interface DialogApi {
*
* @public
*/
export const dialogApiRef = createApiRef<DialogApi>({
export const dialogApiRef = createApiRef<DialogApi>().with({
id: 'core.dialog',
pluginId: 'app',
});
@@ -50,6 +50,8 @@ export type DiscoveryApi = {
*
* @public
*/
export const discoveryApiRef: ApiRef<DiscoveryApi> = createApiRef({
id: 'core.discovery',
});
export const discoveryApiRef: ApiRef<DiscoveryApi> =
createApiRef<DiscoveryApi>().with({
id: 'core.discovery',
pluginId: 'app',
});
@@ -86,6 +86,7 @@ export type ErrorApi = {
*
* @public
*/
export const errorApiRef: ApiRef<ErrorApi> = createApiRef({
export const errorApiRef: ApiRef<ErrorApi> = createApiRef<ErrorApi>().with({
id: 'core.error',
pluginId: 'app',
});
@@ -121,6 +121,8 @@ export interface FeatureFlagsApi {
*
* @public
*/
export const featureFlagsApiRef: ApiRef<FeatureFlagsApi> = createApiRef({
id: 'core.featureflags',
});
export const featureFlagsApiRef: ApiRef<FeatureFlagsApi> =
createApiRef<FeatureFlagsApi>().with({
id: 'core.featureflags',
pluginId: 'app',
});
@@ -46,6 +46,7 @@ export type FetchApi = {
*
* @public
*/
export const fetchApiRef: ApiRef<FetchApi> = createApiRef({
export const fetchApiRef: ApiRef<FetchApi> = createApiRef<FetchApi>().with({
id: 'core.fetch',
pluginId: 'app',
});
@@ -41,6 +41,7 @@ export interface IconsApi {
*
* @public
*/
export const iconsApiRef = createApiRef<IconsApi>({
export const iconsApiRef = createApiRef<IconsApi>().with({
id: 'core.icons',
pluginId: 'app',
});
@@ -51,6 +51,8 @@ export type IdentityApi = {
*
* @public
*/
export const identityApiRef: ApiRef<IdentityApi> = createApiRef({
id: 'core.identity',
});
export const identityApiRef: ApiRef<IdentityApi> =
createApiRef<IdentityApi>().with({
id: 'core.identity',
pluginId: 'app',
});
@@ -126,6 +126,8 @@ export type OAuthRequestApi = {
*
* @public
*/
export const oauthRequestApiRef: ApiRef<OAuthRequestApi> = createApiRef({
id: 'core.oauthrequest',
});
export const oauthRequestApiRef: ApiRef<OAuthRequestApi> =
createApiRef<OAuthRequestApi>().with({
id: 'core.oauthrequest',
pluginId: 'app',
});
@@ -40,6 +40,8 @@ export type PluginHeaderActionsApi = {
*
* @public
*/
export const pluginHeaderActionsApiRef = createApiRef<PluginHeaderActionsApi>({
id: 'core.plugin-header-actions',
});
export const pluginHeaderActionsApiRef =
createApiRef<PluginHeaderActionsApi>().with({
id: 'core.plugin-header-actions',
pluginId: 'app',
});
@@ -47,6 +47,7 @@ export type PluginWrapperApi = {
*
* @public
*/
export const pluginWrapperApiRef = createApiRef<PluginWrapperApi>({
export const pluginWrapperApiRef = createApiRef<PluginWrapperApi>().with({
id: 'core.plugin-wrapper',
pluginId: 'app',
});
@@ -65,6 +65,7 @@ export interface RouteResolutionApi {
*
* @public
*/
export const routeResolutionApiRef = createApiRef<RouteResolutionApi>({
export const routeResolutionApiRef = createApiRef<RouteResolutionApi>().with({
id: 'core.route-resolution',
pluginId: 'app',
});
@@ -105,6 +105,8 @@ export interface StorageApi {
*
* @public
*/
export const storageApiRef: ApiRef<StorageApi> = createApiRef({
id: 'core.storage',
});
export const storageApiRef: ApiRef<StorageApi> =
createApiRef<StorageApi>().with({
id: 'core.storage',
pluginId: 'app',
});
@@ -36,6 +36,8 @@ export interface SwappableComponentsApi {
*
* @public
*/
export const swappableComponentsApiRef = createApiRef<SwappableComponentsApi>({
id: 'core.swappable-components',
});
export const swappableComponentsApiRef =
createApiRef<SwappableComponentsApi>().with({
id: 'core.swappable-components',
pluginId: 'app',
});
@@ -358,6 +358,8 @@ export type TranslationApi = {
/**
* @public
*/
export const translationApiRef: ApiRef<TranslationApi> = createApiRef({
id: 'core.translation',
});
export const translationApiRef: ApiRef<TranslationApi> =
createApiRef<TranslationApi>().with({
id: 'core.translation',
pluginId: 'app',
});
@@ -28,7 +28,10 @@ import { Observable } from '@backstage/types';
* For example, a Google OAuth provider that supports OAuth 2 and OpenID Connect,
* would be declared as follows:
*
* const googleAuthApiRef = createApiRef<OAuthApi & OpenIDConnectApi>({ ... })
* const googleAuthApiRef = createApiRef<OAuthApi & OpenIDConnectApi>().with({
* id: 'core.auth.google',
* pluginId: 'app',
* })
*/
/**
@@ -339,8 +342,15 @@ export const googleAuthApiRef: ApiRef<
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
> = createApiRef({
> = createApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
>().with({
id: 'core.auth.google',
pluginId: 'app',
});
/**
@@ -354,8 +364,11 @@ export const googleAuthApiRef: ApiRef<
*/
export const githubAuthApiRef: ApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
> = createApiRef({
> = createApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
>().with({
id: 'core.auth.github',
pluginId: 'app',
});
/**
@@ -373,8 +386,15 @@ export const oktaAuthApiRef: ApiRef<
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
> = createApiRef({
> = createApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
>().with({
id: 'core.auth.okta',
pluginId: 'app',
});
/**
@@ -392,8 +412,15 @@ export const gitlabAuthApiRef: ApiRef<
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
> = createApiRef({
> = createApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
>().with({
id: 'core.auth.gitlab',
pluginId: 'app',
});
/**
@@ -412,8 +439,15 @@ export const microsoftAuthApiRef: ApiRef<
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
> = createApiRef({
> = createApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
>().with({
id: 'core.auth.microsoft',
pluginId: 'app',
});
/**
@@ -427,8 +461,15 @@ export const oneloginAuthApiRef: ApiRef<
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
> = createApiRef({
> = createApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
>().with({
id: 'core.auth.onelogin',
pluginId: 'app',
});
/**
@@ -442,8 +483,11 @@ export const oneloginAuthApiRef: ApiRef<
*/
export const bitbucketAuthApiRef: ApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
> = createApiRef({
> = createApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
>().with({
id: 'core.auth.bitbucket',
pluginId: 'app',
});
/**
@@ -457,8 +501,11 @@ export const bitbucketAuthApiRef: ApiRef<
*/
export const bitbucketServerAuthApiRef: ApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
> = createApiRef({
> = createApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
>().with({
id: 'core.auth.bitbucket-server',
pluginId: 'app',
});
/**
@@ -472,8 +519,11 @@ export const bitbucketServerAuthApiRef: ApiRef<
*/
export const atlassianAuthApiRef: ApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
> = createApiRef({
> = createApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
>().with({
id: 'core.auth.atlassian',
pluginId: 'app',
});
/**
@@ -491,8 +541,15 @@ export const vmwareCloudAuthApiRef: ApiRef<
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
> = createApiRef({
> = createApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
>().with({
id: 'core.auth.vmware-cloud',
pluginId: 'app',
});
/**
@@ -508,6 +565,9 @@ export const vmwareCloudAuthApiRef: ApiRef<
*/
export const openshiftAuthApiRef: ApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
> = createApiRef({
> = createApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
>().with({
id: 'core.auth.openshift',
pluginId: 'app',
});
@@ -26,9 +26,10 @@ describe('ApiRef', () => {
});
it('should be created with builder pattern', () => {
const ref = createApiRef<string>().with({ id: 'abc' });
const ref = createApiRef<string>().with({ id: 'abc', pluginId: 'test' });
expect(ref.$$type).toBe('@backstage/ApiRef');
expect(ref.id).toBe('abc');
expect(ref.pluginId).toBe('test');
expect(String(ref)).toBe('apiRef{abc}');
expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}');
});
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { OpaqueType } from '@internal/opaque';
import type { ApiRef } from './types';
/**
@@ -23,8 +24,21 @@ import type { ApiRef } from './types';
*/
export type ApiRefConfig = {
id: string;
pluginId?: string;
};
const OpaqueApiRef = OpaqueType.create<{
public: ApiRef<unknown> & {
readonly $$type: '@backstage/ApiRef';
};
versions: {
readonly version: 'v1';
};
}>({
type: '@backstage/ApiRef',
versions: ['v1'],
});
function validateId(id: string): void {
const valid = id
.split('.')
@@ -37,22 +51,24 @@ function validateId(id: string): void {
}
}
function makeApiRef<T>(id: string): ApiRef<T> {
const ref = {
$$type: '@backstage/ApiRef' as const,
version: 'v1',
id,
function makeApiRef<T>(
config: ApiRefConfig,
): ApiRef<T> & { readonly $$type: '@backstage/ApiRef' } {
const ref = OpaqueApiRef.createInstance('v1', {
id: config.id,
...(config.pluginId ? { pluginId: config.pluginId } : {}),
T: undefined as T,
toString() {
return `apiRef{${id}}`;
return `apiRef{${config.id}}`;
},
};
}) as ApiRef<T> & { readonly $$type: '@backstage/ApiRef' };
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>;
return ref;
}
/**
@@ -61,18 +77,22 @@ function makeApiRef<T>(id: string): ApiRef<T> {
* @remarks
*
* 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,
* system infers the owning plugin for an API from the `id`, unless you provide
* a `pluginId` explicitly. 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.
*
* The recommended way to create an API reference is:
*
* ```ts
* const myApiRef = createApiRef<MyApi>().with({ id: 'plugin.my.api' });
* const myApiRef = createApiRef<MyApi>().with({
* id: 'my-api',
* pluginId: 'my-plugin',
* });
* ```
*
* For backwards compatibility, you can also pass the config directly:
* The legacy way to create an API reference is:
*
* ```ts
* const myApiRef = createApiRef<MyApi>({ id: 'plugin.my.api' });
@@ -80,30 +100,47 @@ function makeApiRef<T>(id: string): ApiRef<T> {
*
* @public
*/
export function createApiRef<T>(config: ApiRefConfig): ApiRef<T>;
/**
* Creates a reference to an API.
*
* @deprecated Use `createApiRef<T>().with(...)` instead.
* @public
*/
export function createApiRef<T>(
config: ApiRefConfig,
): ApiRef<T> & { readonly $$type: '@backstage/ApiRef' };
/**
* Creates a reference to an API.
*
* @remarks
*
* Returns a builder with a `.with()` method for providing the `id`.
* Returns a builder with a `.with()` method for providing the API reference
* configuration.
*
* @public
*/
export function createApiRef<T>(): {
with(config: ApiRefConfig): ApiRef<T>;
with(config: ApiRefConfig): ApiRef<T> & {
readonly $$type: '@backstage/ApiRef';
};
};
export function createApiRef<T>(
config?: ApiRefConfig,
): ApiRef<T> | { with(config: ApiRefConfig): ApiRef<T> } {
export function createApiRef<T>(config?: ApiRefConfig):
| (ApiRef<T> & { readonly $$type: '@backstage/ApiRef' })
| {
with(config: ApiRefConfig): ApiRef<T> & {
readonly $$type: '@backstage/ApiRef';
};
} {
if (config) {
validateId(config.id);
return makeApiRef<T>(config.id);
return makeApiRef<T>(config);
}
return {
with(withConfig: ApiRefConfig): ApiRef<T> {
with(withConfig: ApiRefConfig): ApiRef<T> & {
readonly $$type: '@backstage/ApiRef';
} {
validateId(withConfig.id);
return makeApiRef<T>(withConfig.id);
return makeApiRef<T>(withConfig);
},
};
}
@@ -20,8 +20,9 @@
* @public
*/
export type ApiRef<T> = {
readonly $$type: '@backstage/ApiRef';
readonly $$type?: '@backstage/ApiRef';
readonly id: string;
readonly pluginId?: string;
readonly T: T;
};
@@ -38,7 +38,9 @@ describe('useApiHolder', () => {
const renderedHook = renderHook(() => useApiHolder());
const holder = renderedHook.result.current;
expect(holder.get(createApiRef<string>({ id: 'x' }))).toBeUndefined();
expect(
holder.get(createApiRef<string>().with({ id: 'x' })),
).toBeUndefined();
});
});
@@ -53,7 +55,7 @@ describe('useApi', () => {
const get = jest.fn(() => 'my-api-impl');
context.set({ 1: { get } });
const apiRef = createApiRef<string>({ id: 'x' });
const apiRef = createApiRef<string>().with({ id: 'x' });
const renderedHook = renderHook(() => useApi(apiRef));
const value = renderedHook.result.current;
@@ -20,7 +20,7 @@ import { createApiRef } from '../apis/system';
describe('ApiBlueprint', () => {
it('should create an extension with sensible defaults', () => {
const api = createApiRef<{ foo: string }>({ id: 'test' });
const api = createApiRef<{ foo: string }>().with({ id: 'test' });
const extension = ApiBlueprint.make({
params: defineParams =>
@@ -57,8 +57,8 @@ describe('ApiBlueprint', () => {
});
it('should properly type the API factory', () => {
const fooApi = createApiRef<{ foo: string }>({ id: 'foo' });
const barApi = createApiRef<{ bar: string }>({ id: 'bar' });
const fooApi = createApiRef<{ foo: string }>().with({ id: 'foo' });
const barApi = createApiRef<{ bar: string }>().with({ id: 'bar' });
expect('test').not.toBe('failing without assertions');
@@ -152,7 +152,7 @@ describe('ApiBlueprint', () => {
});
it('should create an extension with custom factory', () => {
const api = createApiRef<{ foo: string }>({ id: 'test' });
const api = createApiRef<{ foo: string }>().with({ id: 'test' });
const factory = jest.fn(() => ({ foo: 'bar' }));
const extension = ApiBlueprint.makeWithOverrides({
+3 -1
View File
@@ -200,7 +200,9 @@ export type FormFieldExtensionData<
};
// @alpha (undocumented)
export const formFieldsApiRef: ApiRef<ScaffolderFormFieldsApi>;
export const formFieldsApiRef: ApiRef<ScaffolderFormFieldsApi> & {
readonly $$type: '@backstage/ApiRef';
};
// @alpha (undocumented)
export type FormValidation = {
+3 -1
View File
@@ -208,7 +208,9 @@ export type ReviewStepProps = {
export type ScaffolderApi = ScaffolderApi_2;
// @public (undocumented)
export const scaffolderApiRef: ApiRef<ScaffolderApi_2>;
export const scaffolderApiRef: ApiRef<ScaffolderApi_2> & {
readonly $$type: '@backstage/ApiRef';
};
// @public @deprecated (undocumented)
export type ScaffolderDryRunOptions = ScaffolderDryRunOptions_2;
+3 -1
View File
@@ -479,7 +479,9 @@ export const formDecoratorsApi: OverridableExtensionDefinition<{
}>;
// @alpha (undocumented)
export const formDecoratorsApiRef: ApiRef<ScaffolderFormDecoratorsApi>;
export const formDecoratorsApiRef: ApiRef<ScaffolderFormDecoratorsApi> & {
readonly $$type: '@backstage/ApiRef';
};
export { formFieldsApiRef };
+3 -1
View File
@@ -528,7 +528,9 @@ export type RouterProps = {
export type ScaffolderApi = ScaffolderApi_2;
// @public @deprecated (undocumented)
export const scaffolderApiRef: ApiRef<ScaffolderApi_2>;
export const scaffolderApiRef: ApiRef<ScaffolderApi_2> & {
readonly $$type: '@backstage/ApiRef';
};
// @public @deprecated
export class ScaffolderClient extends ScaffolderClient_2 {}