diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index f954ba5750..8a4e2b5c9b 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -16,6 +16,7 @@ import { AppTreeApi, + type ApiRef, appTreeApiRef, coreExtensionData, createExtension, @@ -422,6 +423,64 @@ describe('createSpecializedApp', () => { expect(app.apis.get(testApiRef)).toEqual({ value: 'owner' }); }); + it('should ignore plugin ownership metadata from unsupported opaque ApiRefs', () => { + const testApiRef = { + $$type: '@backstage/ApiRef', + version: 'v0', + id: 'shared.api', + pluginId: 'owner', + T: null as unknown as { value: string }, + toString() { + return 'apiRef{shared.api}'; + }, + } as ApiRef<{ value: string }, 'shared.api'> & { + readonly $$type: '@backstage/ApiRef'; + readonly version: 'v0'; + readonly 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: 'other' }); + }); + it('should not infer app ownership from core-prefixed API ids', () => { const testApiRef = createApiRef<{ value: string }>({ id: 'core.shared' }); diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index fd009db479..20c00c8929 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -51,6 +51,8 @@ import { resolveExtensionDefinition, toInternalExtension, } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { OpaqueApiRef } from '../../../frontend-plugin-api/src/apis/system/ApiRef'; import { extractRouteInfoFromAppNode, @@ -456,9 +458,15 @@ 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(apiRef: { id: string }): string { - const pluginId = (apiRef as { pluginId?: string }).pluginId; - if (pluginId) { - return pluginId; + if (OpaqueApiRef.isType(apiRef)) { + try { + const { pluginId } = OpaqueApiRef.toInternal(apiRef); + if (pluginId) { + return pluginId; + } + } catch { + // Fall back to legacy ID inference for unsupported opaque ApiRef versions. + } } const apiRefId = apiRef.id; diff --git a/packages/frontend-plugin-api/src/apis/system/ApiRef.ts b/packages/frontend-plugin-api/src/apis/system/ApiRef.ts index 15f10874fc..2d86f0a8ad 100644 --- a/packages/frontend-plugin-api/src/apis/system/ApiRef.ts +++ b/packages/frontend-plugin-api/src/apis/system/ApiRef.ts @@ -31,12 +31,14 @@ type ApiRefBuilderConfig = { pluginId?: string; }; -const OpaqueApiRef = OpaqueType.create<{ +/** @internal */ +export const OpaqueApiRef = OpaqueType.create<{ public: ApiRef & { readonly $$type: '@backstage/ApiRef'; }; versions: { readonly version: 'v1'; + readonly pluginId?: string; }; }>({ type: '@backstage/ApiRef',