diff --git a/.changeset/opaque-api-ref-type.md b/.changeset/opaque-api-ref-type.md index 81f365b578..a2862e30bc 100644 --- a/.changeset/opaque-api-ref-type.md +++ b/.changeset/opaque-api-ref-type.md @@ -2,6 +2,6 @@ '@backstage/frontend-plugin-api': patch --- -Added a builder form for `createApiRef` in the new frontend system and deprecated the direct `createApiRef({ ... })` call in favor of `createApiRef().with({ ... })`. +Added a builder form for `createApiRef` in the new frontend system and deprecated the direct `createApiRef({ ... })` call in favor of `createApiRef().with({ ... })`. The builder form now also preserves literal API ref IDs in the resulting `ApiRef` type. -`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. +`ApiRef` now also supports an explicit `pluginId`, and the `createApiRef().with({ ... })` form can use it to declare API ownership without encoding the plugin ID into the API ref ID. 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 994cde44c6..5bfb83ce3e 100644 --- a/packages/core-plugin-api/src/apis/system/ApiRef.test.ts +++ b/packages/core-plugin-api/src/apis/system/ApiRef.test.ts @@ -22,7 +22,12 @@ describe('ApiRef', () => { 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}'); + expect(ref.T).toBeNull(); + }); + + it('should not accept pluginId in the core createApiRef config', () => { + // @ts-expect-error pluginId is not supported in core-plugin-api + createApiRef({ id: 'abc', pluginId: 'test' }); }); it('should reject invalid ids', () => { diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index d01bc4fe9c..36f6ff250d 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -417,6 +417,51 @@ describe('createSpecializedApp', () => { expect(app.apis.get(testApiRef)).toEqual({ value: 'owner' }); }); + it('should not infer app ownership from core-prefixed API ids', () => { + const testApiRef = createApiRef<{ value: string }>({ id: 'core.shared' }); + + const app = createSpecializedApp({ + features: [ + makeAppPlugin(), + createFrontendPlugin({ + pluginId: 'other-before', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: testApiRef, + deps: {}, + factory: () => ({ value: 'other' }), + }), + }), + ], + }), + createFrontendModule({ + pluginId: 'app', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: testApiRef, + deps: {}, + factory: () => ({ value: 'app' }), + }), + }), + ], + }), + ], + }); + + expect(app.errors).toEqual([ + expect.objectContaining({ + code: 'API_FACTORY_CONFLICT', + message: expect.stringContaining("API 'core.shared'"), + }), + ]); + + expect(app.apis.get(testApiRef)).toEqual({ value: 'other' }); + }); + it('should allow API overrides within the same plugin', () => { const testApiRef = createApiRef<{ value: string }>({ id: 'test.api' }); diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index 7ab36f2fad..da77e31103 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -407,8 +407,8 @@ function createApiFactories(options: { // This allows modules to override factories provided by the plugin, but // it rejects API overrides from other plugins. In the event of a - // conflict, the owning plugin is attempted to be inferred from the API - // reference ID. + // conflict, the owning plugin is inferred from the explicit pluginId or + // legacy plugin-prefixed API reference ID. if (existingFactory && existingFactory.pluginId !== pluginId) { const shouldReplace = ownerId === pluginId && existingFactory.pluginId !== ownerId; @@ -465,9 +465,6 @@ function getApiOwnerId(apiRef: { id: string; pluginId?: string }): string { if (!prefix) { return apiRefId; } - if (prefix === 'core') { - return 'app'; - } if (prefix === 'plugin' && rest[0]) { return rest[0]; } diff --git a/packages/frontend-plugin-api/report-alpha.api.md b/packages/frontend-plugin-api/report-alpha.api.md index c1a3c71a21..921ad1039c 100644 --- a/packages/frontend-plugin-api/report-alpha.api.md +++ b/packages/frontend-plugin-api/report-alpha.api.md @@ -24,7 +24,10 @@ export type PluginWrapperApi = { }; // @public -export const pluginWrapperApiRef: ApiRef & { +export const pluginWrapperApiRef: ApiRef< + PluginWrapperApi, + 'core.plugin-wrapper' +> & { readonly $$type: '@backstage/ApiRef'; }; diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 52ee10a6ac..2534d83eed 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -31,7 +31,7 @@ export type AlertApi = { }; // @public -export const alertApiRef: ApiRef_2 & { +export const alertApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -48,7 +48,7 @@ export type AnalyticsApi = { }; // @public -export const analyticsApiRef: ApiRef_2 & { +export const analyticsApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -191,9 +191,9 @@ export type ApiHolder = { }; // @public -export type ApiRef = { +export type ApiRef = { readonly $$type?: '@backstage/ApiRef'; - readonly id: string; + readonly id: TId; readonly pluginId?: string; readonly T: T; }; @@ -201,7 +201,6 @@ export type ApiRef = { // @public export type ApiRefConfig = { id: string; - pluginId?: string; }; // @public (undocumented) @@ -219,7 +218,7 @@ export type AppLanguageApi = { }; // @public (undocumented) -export const appLanguageApiRef: ApiRef_2 & { +export const appLanguageApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -294,7 +293,7 @@ export type AppThemeApi = { }; // @public -export const appThemeApiRef: ApiRef_2 & { +export const appThemeApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -316,13 +315,14 @@ export interface AppTreeApi { } // @public -export const appTreeApiRef: ApiRef_2 & { +export const appTreeApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; // @public export const atlassianAuthApiRef: ApiRef_2< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi, + 'core.auth.atlassian' > & { readonly $$type: '@backstage/ApiRef'; }; @@ -364,14 +364,16 @@ export type BackstageUserIdentity = { // @public export const bitbucketAuthApiRef: ApiRef_2< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi, + 'core.auth.bitbucket' > & { readonly $$type: '@backstage/ApiRef'; }; // @public export const bitbucketServerAuthApiRef: ApiRef_2< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi, + 'core.auth.bitbucket-server' > & { readonly $$type: '@backstage/ApiRef'; }; @@ -380,7 +382,7 @@ export const bitbucketServerAuthApiRef: ApiRef_2< export type ConfigApi = Config; // @public -export const configApiRef: ApiRef_2 & { +export const configApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -443,7 +445,12 @@ export function createApiRef(config: ApiRefConfig): ApiRef & { // @public export function createApiRef(): { - with(config: ApiRefConfig): ApiRef & { + with( + config: ApiRefConfig & { + id: TId; + pluginId?: string; + }, + ): ApiRef & { readonly $$type: '@backstage/ApiRef'; }; }; @@ -899,7 +906,7 @@ export interface DialogApiDialog { } // @public -export const dialogApiRef: ApiRef_2 & { +export const dialogApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -909,7 +916,7 @@ export type DiscoveryApi = { }; // @public -export const discoveryApiRef: ApiRef_2 & { +export const discoveryApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -935,7 +942,7 @@ export type ErrorApiErrorContext = { }; // @public -export const errorApiRef: ApiRef_2 & { +export const errorApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -1321,7 +1328,10 @@ export interface FeatureFlagsApi { } // @public -export const featureFlagsApiRef: ApiRef_2 & { +export const featureFlagsApiRef: ApiRef_2< + FeatureFlagsApi, + 'core.featureflags' +> & { readonly $$type: '@backstage/ApiRef'; }; @@ -1355,7 +1365,7 @@ export type FetchApi = { }; // @public -export const fetchApiRef: ApiRef_2 & { +export const fetchApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -1431,7 +1441,8 @@ export type FrontendPluginInfoOptions = { // @public export const githubAuthApiRef: ApiRef_2< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi, + 'core.auth.github' > & { readonly $$type: '@backstage/ApiRef'; }; @@ -1442,7 +1453,8 @@ export const gitlabAuthApiRef: ApiRef_2< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & - SessionApi + SessionApi, + 'core.auth.gitlab' > & { readonly $$type: '@backstage/ApiRef'; }; @@ -1453,7 +1465,8 @@ export const googleAuthApiRef: ApiRef_2< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & - SessionApi + SessionApi, + 'core.auth.google' > & { readonly $$type: '@backstage/ApiRef'; }; @@ -1476,7 +1489,7 @@ export interface IconsApi { } // @public -export const iconsApiRef: ApiRef_2 & { +export const iconsApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -1491,7 +1504,7 @@ export type IdentityApi = { }; // @public -export const identityApiRef: ApiRef_2 & { +export const identityApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -1501,7 +1514,8 @@ export const microsoftAuthApiRef: ApiRef_2< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & - SessionApi + SessionApi, + 'core.auth.microsoft' > & { readonly $$type: '@backstage/ApiRef'; }; @@ -1567,7 +1581,10 @@ export type OAuthRequestApi = { }; // @public -export const oauthRequestApiRef: ApiRef_2 & { +export const oauthRequestApiRef: ApiRef_2< + OAuthRequestApi, + 'core.oauthrequest' +> & { readonly $$type: '@backstage/ApiRef'; }; @@ -1591,7 +1608,8 @@ export const oktaAuthApiRef: ApiRef_2< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & - SessionApi + SessionApi, + 'core.auth.okta' > & { readonly $$type: '@backstage/ApiRef'; }; @@ -1602,7 +1620,8 @@ export const oneloginAuthApiRef: ApiRef_2< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & - SessionApi + SessionApi, + 'core.auth.onelogin' > & { readonly $$type: '@backstage/ApiRef'; }; @@ -1614,7 +1633,8 @@ export type OpenIdConnectApi = { // @public export const openshiftAuthApiRef: ApiRef_2< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi, + 'core.auth.openshift' > & { readonly $$type: '@backstage/ApiRef'; }; @@ -1905,7 +1925,10 @@ export type PluginHeaderActionsApi = { }; // @public -export const pluginHeaderActionsApiRef: ApiRef_2 & { +export const pluginHeaderActionsApiRef: ApiRef_2< + PluginHeaderActionsApi, + 'core.plugin-header-actions' +> & { readonly $$type: '@backstage/ApiRef'; }; @@ -1949,7 +1972,10 @@ export type PluginWrapperApi = { }; // @public -export const pluginWrapperApiRef: ApiRef_2 & { +export const pluginWrapperApiRef: ApiRef_2< + PluginWrapperApi, + 'core.plugin-wrapper' +> & { readonly $$type: '@backstage/ApiRef'; }; @@ -2057,7 +2083,10 @@ export interface RouteResolutionApi { } // @public -export const routeResolutionApiRef: ApiRef_2 & { +export const routeResolutionApiRef: ApiRef_2< + RouteResolutionApi, + 'core.route-resolution' +> & { readonly $$type: '@backstage/ApiRef'; }; @@ -2097,7 +2126,7 @@ export interface StorageApi { } // @public -export const storageApiRef: ApiRef_2 & { +export const storageApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -2189,7 +2218,10 @@ export interface SwappableComponentsApi { } // @public -export const swappableComponentsApiRef: ApiRef_2 & { +export const swappableComponentsApiRef: ApiRef_2< + SwappableComponentsApi, + 'core.swappable-components' +> & { readonly $$type: '@backstage/ApiRef'; }; @@ -2212,7 +2244,7 @@ export type TranslationApi = { }; // @public (undocumented) -export const translationApiRef: ApiRef_2 & { +export const translationApiRef: ApiRef_2 & { readonly $$type: '@backstage/ApiRef'; }; @@ -2409,7 +2441,8 @@ export const vmwareCloudAuthApiRef: ApiRef_2< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & - SessionApi + SessionApi, + 'core.auth.vmware-cloud' > & { readonly $$type: '@backstage/ApiRef'; }; 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 b20134cc2a..ff4b83b978 100644 --- a/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts +++ b/packages/frontend-plugin-api/src/apis/system/ApiRef.test.ts @@ -15,6 +15,7 @@ */ import { createApiRef } from './ApiRef'; +import type { ApiRef as ApiRefType } from './types'; describe('ApiRef', () => { it('should be created with config', () => { @@ -22,7 +23,22 @@ describe('ApiRef', () => { 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}'); + expect(ref.T).toBeNull(); + }); + + it('should not accept pluginId with deprecated config form', () => { + // @ts-expect-error pluginId is only supported through .with(...) + createApiRef({ id: 'abc', pluginId: 'test' }); + }); + + it('should keep the deprecated config form id wide', () => { + const ref = createApiRef({ id: 'abc' }); + const wideRef: ApiRefType = ref; + expect(wideRef.id).toBe('abc'); + + // @ts-expect-error deprecated config form should not infer literal ids + const literalRef: ApiRefType = ref; + expect(literalRef.id).toBe('abc'); }); it('should be created with builder pattern', () => { @@ -31,7 +47,17 @@ describe('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}'); + expect(ref.T).toBeNull(); + }); + + it('should infer literal ids with builder pattern', () => { + const ref = createApiRef().with({ id: 'abc', pluginId: 'test' }); + const literalRef: ApiRefType = ref; + expect(literalRef.id).toBe('abc'); + + // @ts-expect-error builder pattern should preserve literal ids + const wrongLiteralRef: ApiRefType = ref; + expect(wrongLiteralRef.id).toBe('abc'); }); it('should reject invalid ids', () => { diff --git a/packages/frontend-plugin-api/src/apis/system/ApiRef.ts b/packages/frontend-plugin-api/src/apis/system/ApiRef.ts index 3cc7fc6649..15f10874fc 100644 --- a/packages/frontend-plugin-api/src/apis/system/ApiRef.ts +++ b/packages/frontend-plugin-api/src/apis/system/ApiRef.ts @@ -24,6 +24,10 @@ import type { ApiRef } from './types'; */ export type ApiRefConfig = { id: string; +}; + +type ApiRefBuilderConfig = { + id: TId; pluginId?: string; }; @@ -51,24 +55,17 @@ function validateId(id: string): void { } } -function makeApiRef( - config: ApiRefConfig, -): ApiRef & { readonly $$type: '@backstage/ApiRef' } { - const ref = OpaqueApiRef.createInstance('v1', { +function makeApiRef( + config: ApiRefBuilderConfig, +): ApiRef & { readonly $$type: '@backstage/ApiRef' } { + return OpaqueApiRef.createInstance('v1', { id: config.id, ...(config.pluginId ? { pluginId: config.pluginId } : {}), - T: undefined as T, + T: null as unknown as T, toString() { return `apiRef{${config.id}}`; }, - }) as ApiRef & { 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 ApiRef & { readonly $$type: '@backstage/ApiRef' }; } /** @@ -77,9 +74,9 @@ function makeApiRef( * @remarks * * The `id` is a stable identifier for the API implementation. The frontend - * system infers the owning plugin for an API from the `id`, unless you provide - * a `pluginId` explicitly. The recommended pattern is `plugin..*` - * (for example, + * system infers the owning plugin for an API from the `id`. When using the + * builder form, you can instead provide a `pluginId` explicitly. The + * recommended pattern is `plugin..*` (for example, * `plugin.catalog.entity-presentation`). This ensures that other plugins can't * mistakenly override your API implementation. * @@ -120,27 +117,31 @@ export function createApiRef( * @public */ export function createApiRef(): { - with(config: ApiRefConfig): ApiRef & { + with( + config: ApiRefConfig & { id: TId; pluginId?: string }, + ): ApiRef & { readonly $$type: '@backstage/ApiRef'; }; }; export function createApiRef(config?: ApiRefConfig): | (ApiRef & { readonly $$type: '@backstage/ApiRef' }) | { - with(config: ApiRefConfig): ApiRef & { + with( + config: ApiRefConfig & { id: TId; pluginId?: string }, + ): ApiRef & { readonly $$type: '@backstage/ApiRef'; }; } { if (config) { validateId(config.id); - return makeApiRef(config); + return makeApiRef(config); } return { - with(withConfig: ApiRefConfig): ApiRef & { - readonly $$type: '@backstage/ApiRef'; - } { + with( + withConfig: ApiRefConfig & { id: TId; pluginId?: string }, + ): ApiRef & { readonly $$type: '@backstage/ApiRef' } { validateId(withConfig.id); - return makeApiRef(withConfig); + return makeApiRef(withConfig); }, }; } diff --git a/packages/frontend-plugin-api/src/apis/system/types.ts b/packages/frontend-plugin-api/src/apis/system/types.ts index 90e7365164..50911ded1e 100644 --- a/packages/frontend-plugin-api/src/apis/system/types.ts +++ b/packages/frontend-plugin-api/src/apis/system/types.ts @@ -19,9 +19,9 @@ * * @public */ -export type ApiRef = { +export type ApiRef = { readonly $$type?: '@backstage/ApiRef'; - readonly id: string; + readonly id: TId; readonly pluginId?: string; readonly T: T; };