api-ref: use opaque metadata for owner lookup

Read ApiRef plugin ownership through the internal opaque type helper and gracefully fall back to legacy ID inference for unsupported ref shapes.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-17 10:30:59 +01:00
parent ccc6b25f87
commit 4690f20880
3 changed files with 73 additions and 4 deletions
@@ -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' });
@@ -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;
@@ -31,12 +31,14 @@ type ApiRefBuilderConfig<TId extends string> = {
pluginId?: string;
};
const OpaqueApiRef = OpaqueType.create<{
/** @internal */
export const OpaqueApiRef = OpaqueType.create<{
public: ApiRef<unknown> & {
readonly $$type: '@backstage/ApiRef';
};
versions: {
readonly version: 'v1';
readonly pluginId?: string;
};
}>({
type: '@backstage/ApiRef',