api-ref: move opaque helper to frontend-internal

Share the internal ApiRef opaque helper through frontend-internal and fail fast when ApiRef-shaped values have an unsupported opaque version.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-17 11:05:00 +01:00
parent 90c3a9d1c0
commit cc0693ec40
6 changed files with 87 additions and 64 deletions
@@ -423,7 +423,7 @@ describe('createSpecializedApp', () => {
expect(app.apis.get(testApiRef)).toEqual({ value: 'owner' });
});
it('should ignore plugin ownership metadata from unsupported opaque ApiRefs', () => {
it('should reject unsupported opaque ApiRef versions', () => {
const testApiRef = {
$$type: '@backstage/ApiRef',
version: 'v0',
@@ -439,46 +439,39 @@ describe('createSpecializedApp', () => {
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(() =>
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.apis.get(testApiRef)).toEqual({ value: 'other' });
).toThrow("Invalid opaque type instance, got version 'v0', expected 'v1'");
});
it('should not infer app ownership from core-prefixed API ids', () => {
@@ -43,6 +43,7 @@ import {
import { ApiFactoryRegistry, ApiResolver } from '@backstage/core-app-api';
import {
createExtensionDataContainer,
OpaqueApiRef,
OpaqueFrontendPlugin,
} from '@internal/frontend';
@@ -51,8 +52,6 @@ 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,
@@ -459,13 +458,9 @@ function createApiFactories(options: {
// might need to wait for some future update for API factories.
function getApiOwnerId(apiRef: { id: string }): string {
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 { pluginId } = OpaqueApiRef.toInternal(apiRef);
if (pluginId) {
return pluginId;
}
}
@@ -0,0 +1,31 @@
/*
* Copyright 2024 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 type { ApiRef } from '@backstage/frontend-plugin-api';
import { OpaqueType } from '@internal/opaque';
export const OpaqueApiRef = OpaqueType.create<{
public: ApiRef<unknown> & {
readonly $$type: '@backstage/ApiRef';
};
versions: {
readonly version: 'v1';
readonly pluginId?: string;
};
}>({
type: '@backstage/ApiRef',
versions: ['v1'],
});
@@ -0,0 +1,17 @@
/*
* Copyright 2024 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 * from './OpaqueApiRef';
+1
View File
@@ -15,4 +15,5 @@
*/
export * from './routing';
export * from './apis';
export * from './wiring';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { OpaqueType } from '@internal/opaque';
import { OpaqueApiRef } from '@internal/frontend';
import type { ApiRef } from './types';
/**
@@ -31,20 +31,6 @@ type ApiRefBuilderConfig<TId extends string> = {
pluginId?: string;
};
/** @internal */
export const OpaqueApiRef = OpaqueType.create<{
public: ApiRef<unknown> & {
readonly $$type: '@backstage/ApiRef';
};
versions: {
readonly version: 'v1';
readonly pluginId?: string;
};
}>({
type: '@backstage/ApiRef',
versions: ['v1'],
});
function validateId(id: string): void {
const valid = id
.split('.')