frontend-app-api: add support for plugin info resolution and overrides

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-05-15 09:05:33 +02:00
parent 18c64e9bd4
commit c38c9e8169
8 changed files with 750 additions and 3 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
Implemented support for the `plugin.info()` method in specialized apps with a default resolved for `package.json` and `catalog-info.yaml`. The default resolution logic can be overriden via the `pluginInfoResolver` option to `createSpecializedApp`, and plugin-specific overrides can be applied via the new `app.pluginOverrides` key in static configuration.
+66
View File
@@ -51,5 +51,71 @@ export interface Config {
};
}
>;
/**
* This section enables you to override certain properties of specific or
* groups of plugins.
*
* @remarks
* All matching entries will be applied to each plugin, with the later
* entries taking precedence.
*
* This configuration is intended to be used primarily to apply overrides
* for third-party plugins.
*
* @deepVisibility frontend
*/
pluginOverrides?: Array<{
/**
* The criteria for matching plugins to override.
*
* @remarks
* If no match criteria are provided, the override will be applied to
* all plugins.
*/
match?: {
/**
* A pattern that is matched against the plugin ID.
*
* @remarks
* By default the string is interpreted as a glob pattern, but if the
* string is surrounded by '/' it is interpreted as a regex.
*/
pluginId?: string;
/**
* A pattern that is matched against the package name.
*
* @remarks
* By default the string is interpreted as a glob pattern, but if the
* string is surrounded by '/' it is interpreted as a regex.
*
* Note that this will only work for plugins that provide a
* `package.json` info loader.
*/
packageName?: string;
};
/**
* Overrides individual top-level fields of the plugin info.
*/
info: {
/**
* Override the description of the plugin.
*/
description?: string;
/**
* Override the owner entity references of the plugin.
*
* @remarks
* The provided values are interpreted as entity references defaulting
* to Group entities in the default namespace.
*/
ownerEntityRefs?: string[];
/**
* Override the links of the plugin.
*/
links?: Array<{ title: string; url: string }>;
};
}>;
};
}
+18 -1
View File
@@ -9,6 +9,8 @@ import { ConfigApi } from '@backstage/core-plugin-api';
import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { FrontendFeature as FrontendFeature_2 } from '@backstage/frontend-plugin-api';
import { FrontendPluginInfo } from '@backstage/frontend-plugin-api';
import { JsonObject } from '@backstage/types';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { SubRouteRef } from '@backstage/frontend-plugin-api';
@@ -27,7 +29,7 @@ export type CreateAppRouteBinder = <
// @public
export function createSpecializedApp(options?: {
features?: FrontendFeature[];
features?: FrontendFeature_2[];
config?: ConfigApi;
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
apis?: ApiHolder;
@@ -37,6 +39,7 @@ export function createSpecializedApp(options?: {
flags?: {
allowUnknownExtensionConfig?: boolean;
};
pluginInfoResolver?: FrontendPluginInfoResolver;
}): {
apis: ApiHolder;
tree: AppTree;
@@ -44,4 +47,18 @@ export function createSpecializedApp(options?: {
// @public @deprecated (undocumented)
export type FrontendFeature = FrontendFeature_2;
// @public
export type FrontendPluginInfoResolver = (ctx: {
packageJson(): Promise<JsonObject | undefined>;
manifest(): Promise<JsonObject | undefined>;
defaultResolver(sources: {
packageJson: JsonObject | undefined;
manifest: JsonObject | undefined;
}): Promise<{
info: FrontendPluginInfo;
}>;
}) => Promise<{
info: FrontendPluginInfo;
}>;
```
@@ -0,0 +1,284 @@
/*
* Copyright 2025 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 { mockApis } from '@backstage/test-utils';
import { createPluginInfoAttacher } from './createPluginInfoAttacher';
import { OpaqueFrontendPlugin } from '@internal/frontend';
import {
createFrontendPlugin,
FrontendFeature,
} from '@backstage/frontend-plugin-api';
function getInfo(plugin: FrontendFeature) {
return OpaqueFrontendPlugin.toInternal(plugin).info();
}
describe('createPluginInfoAttacher', () => {
const mockConfig = mockApis.config({
data: {
app: {
pluginOverrides: [
{
match: {
pluginId: '/^.*-tester$/',
},
info: {
description: 'Overridden description',
},
},
{
match: {
pluginId: '/^not-.*-tester$/',
},
info: {
ownerEntityRefs: ['test-group'],
},
},
{
match: {
packageName: '@test/package',
},
info: {
description: 'Package name matched',
},
},
{
match: {
pluginId: 'info-tester',
},
info: {
links: [{ title: 'Custom Link', url: 'https://example.com' }],
},
},
],
},
},
});
describe('with default resolver', () => {
const attacher = createPluginInfoAttacher(mockConfig);
it('should return a new plugin instance', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
});
const newPlugin = attacher(plugin);
expect(newPlugin).not.toBe(plugin);
await expect(getInfo(newPlugin)).resolves.toEqual({});
});
it('should return non-plugin features unchanged', () => {
const nonPluginFeature = {
type: 'not-a-plugin',
} as unknown as FrontendFeature;
expect(attacher(nonPluginFeature)).toBe(nonPluginFeature);
});
it('should resolve plugin info from package.json and config overrides', async () => {
await expect(
getInfo(
attacher(
createFrontendPlugin({
pluginId: 'other-tester',
info: {
packageJson: async () => ({
name: '@test/package',
version: '1.0.0',
description: 'Original description',
homepage: 'https://homepage.com',
repository: {
url: 'https://github.com/test/project',
directory: 'packages/test',
},
}),
},
}),
),
),
).resolves.toEqual({
packageName: '@test/package',
version: '1.0.0',
description: 'Package name matched',
links: [
{
title: 'Homepage',
url: 'https://homepage.com',
},
{
title: 'Repository',
url: 'https://github.com/test/project/tree/master/packages/test',
},
],
});
await expect(
getInfo(
attacher(
createFrontendPlugin({
pluginId: 'info-tester',
info: {
packageJson: async () => ({
name: '@other/package',
description: 'Original description',
homepage: 'https://homepage.com',
}),
},
}),
),
),
).resolves.toEqual({
packageName: '@other/package',
description: 'Overridden description',
links: [
{
title: 'Custom Link',
url: 'https://example.com',
},
],
});
await expect(
getInfo(
attacher(
createFrontendPlugin({
pluginId: 'not-info-tester',
info: {
packageJson: async () => ({
name: '@other/package',
description: 'Original description',
repository: {
url: 'http://example.com',
directory: 'packages/test',
},
}),
},
}),
),
),
).resolves.toEqual({
packageName: '@other/package',
description: 'Overridden description',
ownerEntityRefs: ['group:default/test-group'],
links: [
{
title: 'Repository',
url: 'http://example.com/',
},
],
});
});
});
describe('with custom resolver', () => {
const plugin = createFrontendPlugin({
pluginId: 'custom-resolver',
info: {
packageJson: async () => ({
name: '@test/resolver',
version: '1.0.0',
}),
manifest: async () => ({
metadata: {
links: [{ title: 'Metadata link', url: 'https://example.com' }],
},
}),
},
});
it('should use the default resolver', async () => {
const attacher = createPluginInfoAttacher(mockConfig, async ctx =>
ctx.defaultResolver({
packageJson: await ctx.packageJson(),
manifest: await ctx.manifest(),
}),
);
await expect(getInfo(attacher(plugin))).resolves.toEqual({
packageName: '@test/resolver',
version: '1.0.0',
links: [
{
title: 'Metadata link',
url: 'https://example.com',
},
],
});
});
it('should override info sources passed to default resolver', async () => {
const attacher = createPluginInfoAttacher(mockConfig, ctx =>
ctx.defaultResolver({
packageJson: {
name: '@test/resolver-other',
version: '2.0.0',
},
manifest: {
metadata: {
links: [{ title: 'Other link', url: 'https://example.com' }],
},
spec: {
owner: 'test-group',
},
},
}),
);
await expect(getInfo(attacher(plugin))).resolves.toEqual({
packageName: '@test/resolver-other',
version: '2.0.0',
links: [
{
title: 'Other link',
url: 'https://example.com',
},
],
ownerEntityRefs: ['group:default/test-group'],
});
});
it('should use a completely custom resolver', async () => {
const attacher = createPluginInfoAttacher(mockConfig, async () => ({
info: { version: '0.1.0' },
}));
await expect(getInfo(attacher(plugin))).resolves.toEqual({
version: '0.1.0',
});
});
it('should handle unexpected input from the default resolver', async () => {
const attacher = createPluginInfoAttacher(mockConfig, ctx =>
ctx.defaultResolver({
packageJson: {
name: null,
version: {},
},
manifest: {
metadata: {
links: 'not an array',
},
spec: [],
},
}),
);
await expect(getInfo(attacher(plugin))).resolves.toEqual({
version: '[object Object]',
});
});
});
});
@@ -0,0 +1,247 @@
/*
* Copyright 2025 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 { ConfigApi } from '@backstage/core-plugin-api';
import {
FrontendFeature,
FrontendPluginInfo,
} from '@backstage/frontend-plugin-api';
import { OpaqueFrontendPlugin } from '@internal/frontend';
import { JsonObject, JsonValue } from '@backstage/types';
import once from 'lodash/once';
// Avoid full dependency on catalog-model
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import {
parseEntityRef,
stringifyEntityRef,
} from '../../../catalog-model/src/entity/ref';
/**
* A function that resolves plugin info from a plugin manifest and package.json.
*
* @public
*/
export type FrontendPluginInfoResolver = (ctx: {
packageJson(): Promise<JsonObject | undefined>;
manifest(): Promise<JsonObject | undefined>;
defaultResolver(sources: {
packageJson: JsonObject | undefined;
manifest: JsonObject | undefined;
}): Promise<{ info: FrontendPluginInfo }>;
}) => Promise<{ info: FrontendPluginInfo }>;
export function createPluginInfoAttacher(
config: ConfigApi,
infoResolver: FrontendPluginInfoResolver = async ctx =>
ctx.defaultResolver({
packageJson: await ctx.packageJson(),
manifest: await ctx.manifest(),
}),
): (feature: FrontendFeature) => FrontendFeature {
const applyInfoOverrides = createPluginInfoOverrider(config);
return (feature: FrontendFeature) => {
if (!OpaqueFrontendPlugin.isType(feature)) {
return feature;
}
const plugin = OpaqueFrontendPlugin.toInternal(feature);
return {
...plugin,
info: once(async () => {
const manifestLoader = plugin.infoOptions?.manifest;
const packageJsonLoader = plugin.infoOptions?.packageJson;
const { info: resolvedInfo } = await infoResolver({
manifest: async () => manifestLoader?.(),
packageJson: async () => packageJsonLoader?.(),
defaultResolver: async sources => ({
info: {
...resolvePackageInfo(sources.packageJson),
...resolveManifestInfo(sources.manifest),
},
}),
});
const infoWithOverrides = applyInfoOverrides(plugin.id, resolvedInfo);
return normalizePluginInfo(infoWithOverrides);
}),
};
};
}
function normalizePluginInfo(info: FrontendPluginInfo) {
return {
...info,
ownerEntityRefs: info.ownerEntityRefs?.map(ref =>
stringifyEntityRef(
parseEntityRef(ref, {
defaultKind: 'group',
}),
),
),
};
}
function createPluginInfoOverrider(config: ConfigApi) {
const overrideConfigs =
config.getOptionalConfigArray('app.pluginOverrides') ?? [];
const overrideMatchers = overrideConfigs.map(overrideConfig => {
const pluginIdMatcher = makeStringMatcher(
overrideConfig.getOptionalString('match.pluginId'),
);
const packageNameMatcher = makeStringMatcher(
overrideConfig.getOptionalString('match.packageName'),
);
const description = overrideConfig.getOptionalString('info.description');
const ownerEntityRefs = overrideConfig.getOptionalStringArray(
'info.ownerEntityRefs',
);
const links = overrideConfig
.getOptionalConfigArray('info.links')
?.map(linkConfig => ({
title: linkConfig.getString('title'),
url: linkConfig.getString('url'),
}));
return {
test(pluginId: string, packageName?: string) {
return packageNameMatcher(packageName) && pluginIdMatcher(pluginId);
},
info: {
description,
ownerEntityRefs,
links,
},
};
});
return (pluginId: string, info: FrontendPluginInfo) => {
const { packageName } = info;
for (const matcher of overrideMatchers) {
if (matcher.test(pluginId, packageName)) {
if (matcher.info.description) {
info.description = matcher.info.description;
}
if (matcher.info.ownerEntityRefs) {
info.ownerEntityRefs = matcher.info.ownerEntityRefs;
}
if (matcher.info.links) {
info.links = matcher.info.links;
}
}
}
return info;
};
}
function resolveManifestInfo(manifest?: JsonValue) {
if (!isJsonObject(manifest) || !isJsonObject(manifest.metadata)) {
return undefined;
}
const info: FrontendPluginInfo = {};
if (isJsonObject(manifest.spec) && typeof manifest.spec.owner === 'string') {
info.ownerEntityRefs = [
stringifyEntityRef(
parseEntityRef(manifest.spec.owner, {
defaultKind: 'group',
defaultNamespace: manifest.metadata.namespace?.toString(),
}),
),
];
}
if (Array.isArray(manifest.metadata.links)) {
info.links = manifest.metadata.links.filter(isJsonObject).map(link => ({
title: String(link.title),
url: String(link.url),
}));
}
return info;
}
function resolvePackageInfo(packageJson?: JsonObject) {
if (!packageJson) {
return undefined;
}
const info: FrontendPluginInfo = {
packageName: packageJson?.name?.toString(),
version: packageJson?.version?.toString(),
description: packageJson?.description?.toString(),
};
const links: { title: string; url: string }[] = [];
if (typeof packageJson.homepage === 'string') {
links.push({
title: 'Homepage',
url: packageJson.homepage,
});
}
if (
isJsonObject(packageJson.repository) &&
typeof packageJson.repository?.url === 'string'
) {
try {
const url = new URL(packageJson.repository?.url);
if (url.protocol === 'http:' || url.protocol === 'https:') {
// TODO(Rugvip): Support more variants
if (
url.hostname === 'github.com' &&
typeof packageJson.repository.directory === 'string'
) {
const path = `${url.pathname}/tree/master/${packageJson.repository.directory}`;
url.pathname = path.replaceAll('//', '/');
}
links.push({
title: 'Repository',
url: url.toString(),
});
}
} catch {
/* ignored */
}
}
if (links.length > 0) {
info.links = links;
}
return info;
}
function makeStringMatcher(pattern: string | undefined) {
if (!pattern) {
return () => true;
}
if (pattern.startsWith('/') && pattern.endsWith('/') && pattern.length > 2) {
const regex = new RegExp(pattern.slice(1, -1));
return (str?: string) => (str ? regex.test(str) : false);
}
return (str?: string) => str === pattern;
}
function isJsonObject(value?: JsonValue): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@@ -671,4 +671,123 @@ describe('createSpecializedApp', () => {
expect(render(root).container.textContent).toBe('1-2-test-1-2');
});
describe('plugin info', () => {
const testExtension = createExtension({
attachTo: { id: 'root', input: 'app' },
output: [coreExtensionData.reactElement],
factory: () => [coreExtensionData.reactElement(<div>Test</div>)],
});
it('should throw unless accessed via an app', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
extensions: [testExtension],
});
const errorMsg =
"Attempted to load plugin info for plugin 'test', but the plugin instance is not installed in an app";
await expect(plugin.info()).rejects.toThrow(errorMsg);
const app = createSpecializedApp({ features: [plugin] });
await expect(plugin.info()).rejects.toThrow(errorMsg);
const installedPlugin = app.tree.nodes.get('test')?.spec.source;
expect(installedPlugin).toBeDefined();
const info = await installedPlugin?.info();
expect(info).toEqual({});
});
it('should forward plugin info', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
info: {
packageJson: () => import('../../package.json'),
},
extensions: [testExtension],
});
const app = createSpecializedApp({ features: [plugin] });
const info = await app.tree.nodes.get('test')?.spec.source?.info();
expect(info).toMatchObject({
packageName: '@backstage/frontend-app-api',
});
});
it('should allow overriding plugin info per plugin', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
info: {
packageJson: () => import('../../package.json'),
},
extensions: [testExtension],
});
const overriddenPlugin = plugin.withOverrides({
extensions: [],
info: {
packageJson: () => Promise.resolve({ name: 'test-override' }),
},
});
const app = createSpecializedApp({ features: [overriddenPlugin] });
const info = await app.tree.nodes.get('test')?.spec.source?.info();
expect(info).toMatchObject({
packageName: 'test-override',
});
});
it('should merge with plugin info from manifest', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
info: {
packageJson: () => import('../../package.json'),
manifest: async () => ({
metadata: {
links: [{ title: 'Example', url: 'https://example.com' }],
},
spec: {
owner: 'cubic-belugas',
},
}),
},
extensions: [testExtension],
});
const app = createSpecializedApp({ features: [plugin] });
const info = await app.tree.nodes.get('test')?.spec.source?.info();
expect(info).toEqual({
packageName: '@backstage/frontend-app-api',
version: expect.any(String),
links: [{ title: 'Example', url: 'https://example.com' }],
ownerEntityRefs: ['group:default/cubic-belugas'],
});
});
it('should allow overriding of the plugin info resolver', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
info: {
packageJson: () => import('../../package.json'),
},
extensions: [testExtension],
});
const app = createSpecializedApp({
features: [plugin],
async pluginInfoResolver(ctx) {
const { info } = await ctx.defaultResolver({
packageJson: await ctx.packageJson(),
manifest: await ctx.manifest(),
});
return { info: { packageName: `decorated:${info.packageName}` } };
},
});
const info = await app.tree.nodes.get('test')?.spec.source?.info();
expect(info).toEqual({
packageName: 'decorated:@backstage/frontend-app-api',
});
});
});
});
@@ -31,6 +31,7 @@ import {
routeResolutionApiRef,
AppNode,
ExtensionFactoryMiddleware,
FrontendFeature,
} from '@backstage/frontend-plugin-api';
import {
AnyApiFactory,
@@ -74,8 +75,12 @@ import { ApiRegistry } from '../../../core-app-api/src/apis/system/ApiRegistry';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
import { BackstageRouteObject } from '../routing/types';
import { FrontendFeature, RouteInfo } from './types';
import { RouteInfo } from './types';
import { matchRoutes } from 'react-router-dom';
import {
createPluginInfoAttacher,
FrontendPluginInfoResolver,
} from './createPluginInfoAttacher';
function deduplicateFeatures(
allFeatures: FrontendFeature[],
@@ -209,9 +214,12 @@ export function createSpecializedApp(options?: {
| ExtensionFactoryMiddleware
| ExtensionFactoryMiddleware[];
flags?: { allowUnknownExtensionConfig?: boolean };
pluginInfoResolver?: FrontendPluginInfoResolver;
}): { apis: ApiHolder; tree: AppTree } {
const config = options?.config ?? new ConfigReader({}, 'empty-config');
const features = deduplicateFeatures(options?.features ?? []);
const features = deduplicateFeatures(options?.features ?? []).map(
createPluginInfoAttacher(config, options?.pluginInfoResolver),
);
const tree = resolveAppTree(
'root',
@@ -15,4 +15,5 @@
*/
export { createSpecializedApp } from './createSpecializedApp';
export { type FrontendPluginInfoResolver } from './createPluginInfoAttacher';
export * from './types';