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
@@ -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;