From 9e3868fbb08edee84eafeba6bd9cddf42480d65b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 14 May 2025 11:51:55 +0200 Subject: [PATCH 01/10] frontend-plugin-api: add support for declaring plugin info loaders Signed-off-by: Patrik Oldsberg --- .changeset/petite-candies-tan.md | 29 +++++++ .../src/convertLegacyPlugin.test.tsx | 2 + .../src/wiring/InternalFrontendPlugin.ts | 5 ++ packages/frontend-plugin-api/report.api.md | 26 ++++++ .../src/wiring/createFrontendPlugin.test.ts | 54 ++++++++++++ .../src/wiring/createFrontendPlugin.ts | 86 +++++++++++++++++++ .../frontend-plugin-api/src/wiring/index.ts | 2 + 7 files changed, 204 insertions(+) create mode 100644 .changeset/petite-candies-tan.md diff --git a/.changeset/petite-candies-tan.md b/.changeset/petite-candies-tan.md new file mode 100644 index 0000000000..507e348f0e --- /dev/null +++ b/.changeset/petite-candies-tan.md @@ -0,0 +1,29 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Added a new optional `info` option to `createFrontendPlugin` that lets you provide a loaders for different sources of metadata information about the plugin. + +There are two available loaders. The first one is `info.packageJson`, which can be used to point to a `package.json` file for the plugin.This is recommended for any plugin that is defined within its own package, especially all plugins that are published to a package registry. Typical usage looks like this: + +```ts +export default createFrontendPlugin({ + pluginId: '...', + info: { + packageJson: () => import('../package.json'), + }, +}); +``` + +The second loader is `info.manifest`, which can be used to point to an opaque plugin manifest. This **MUST ONLY** be used by plugins that intended for use within a single organization. Plugins that are published to an open package registry should **NOT** use this loader. The loader is useful to add additional internal metadata associated with the plugin, and it is up to the Backstage app to decide how these manifests are parsed and used. The default manifest parser in an app created with `createApp` from `@backstage/frontend-defaults` is able to parse the default `catalog-info.yaml` format and built-in fields such as `spec.owner`. + +Typical usage looks like this: + +```ts +export default createFrontendPlugin({ + pluginId: '...', + info: { + manifest: () => import('../catalog-info.yaml'), + }, +}); +``` diff --git a/packages/core-compat-api/src/convertLegacyPlugin.test.tsx b/packages/core-compat-api/src/convertLegacyPlugin.test.tsx index 68bb7cac3f..3f85e07ca5 100644 --- a/packages/core-compat-api/src/convertLegacyPlugin.test.tsx +++ b/packages/core-compat-api/src/convertLegacyPlugin.test.tsx @@ -41,6 +41,8 @@ describe('convertLegacyPlugin', () => { "featureFlags": [], "getExtension": [Function], "id": "test", + "info": [Function], + "infoOptions": undefined, "routes": {}, "toString": [Function], "version": "v1", diff --git a/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts b/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts index a7be9614b6..af69370f76 100644 --- a/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts +++ b/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts @@ -19,6 +19,7 @@ import { FeatureFlagConfig, FrontendPlugin, } from '@backstage/frontend-plugin-api'; +import { JsonObject } from '@backstage/types'; import { OpaqueType } from '@internal/opaque'; export const OpaqueFrontendPlugin = OpaqueType.create<{ @@ -27,6 +28,10 @@ export const OpaqueFrontendPlugin = OpaqueType.create<{ readonly version: 'v1'; readonly extensions: Extension[]; readonly featureFlags: FeatureFlagConfig[]; + readonly infoOptions?: { + packageJson?: () => Promise; + manifest?: () => Promise; + }; }; }>({ type: '@backstage/FrontendPlugin', diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index cd5d89401d..05a12184a3 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -1322,14 +1322,38 @@ export interface FrontendPlugin< getExtension(id: TId): TExtensionMap[TId]; // (undocumented) readonly id: string; + info(): Promise; // (undocumented) readonly routes: TRoutes; // (undocumented) withOverrides(options: { extensions: Array; + info?: FrontendPluginInfoOptions; }): FrontendPlugin; } +// @public +export interface FrontendPluginInfo { + description?: string; + links?: Array<{ + title: string; + url: string; + }>; + ownerEntityRefs?: string[]; + packageName?: string; + version?: string; +} + +// @public +export type FrontendPluginInfoOptions = { + packageJson?: () => Promise< + { + name: string; + } & JsonObject + >; + manifest?: () => Promise; +}; + export { githubAuthApiRef }; export { gitlabAuthApiRef }; @@ -1514,6 +1538,8 @@ export interface PluginOptions< // (undocumented) featureFlags?: FeatureFlagConfig[]; // (undocumented) + info?: FrontendPluginInfoOptions; + // (undocumented) pluginId: TId; // (undocumented) routes?: TRoutes; diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts index 359f9da417..c97c60e89e 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts @@ -281,6 +281,60 @@ describe('createFrontendPlugin', () => { ).toThrow("Plugin 'test' provided duplicate extensions: test/2, test/3"); }); + describe('info', () => { + it('should support reading info from package.json', async () => { + const plugin = createFrontendPlugin({ + pluginId: 'test', + info: { packageJson: () => Promise.resolve({ name: '@test/test' }) }, + }); + + await expect((plugin as any).infoOptions?.packageJson()).resolves.toEqual( + { name: '@test/test' }, + ); + }); + + it('should support reading info from actual package.json', async () => { + const plugin = createFrontendPlugin({ + pluginId: 'test', + info: { packageJson: () => import('../../package.json') }, + }); + + await expect( + (plugin as any).infoOptions?.packageJson(), + ).resolves.toMatchObject({ name: '@backstage/frontend-plugin-api' }); + }); + + it('should support reading info from opaque manifest', async () => { + const plugin = createFrontendPlugin({ + pluginId: 'test', + info: { manifest: () => Promise.resolve({ owner: 'me' }) }, + }); + + await expect((plugin as any).infoOptions?.manifest()).resolves.toEqual({ + owner: 'me', + }); + }); + + it('should throw when trying to load info without installing in an app', async () => { + await expect( + createFrontendPlugin({ + pluginId: 'test', + }).info(), + ).rejects.toThrow( + "Attempted to load plugin info for plugin 'test', but the plugin instance is not installed in an app", + ); + + await expect( + createFrontendPlugin({ + pluginId: 'test', + info: { packageJson: () => Promise.resolve({ name: '@test/test' }) }, + }).info(), + ).rejects.toThrow( + "Attempted to load plugin info for plugin 'test', but the plugin instance is not installed in an app", + ); + }); + }); + describe('overrides', () => { it('should return a plugin instance with the correct namespace', () => { const plugin = createFrontendPlugin({ diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts index b7c530a0c0..13c39a37e9 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts @@ -25,6 +25,67 @@ import { } from './resolveExtensionDefinition'; import { AnyExternalRoutes, AnyRoutes, FeatureFlagConfig } from './types'; import { MakeSortedExtensionsMap } from './MakeSortedExtensionsMap'; +import { JsonObject } from '@backstage/types'; + +/** + * Information about the plugin. + * + * @public + * @remarks + * + * This interface is intended to be extended via [module + * augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) + * in order to add fields that are specific to each project. + * + * For example, one might add a `slackChannel` field that is read from the + * opaque manifest file. + * + * See the options for `createApp` for more information about how to + * customize the parsing of manifest files. + */ +export interface FrontendPluginInfo { + /** + * The name of the package that implements the plugin. + */ + packageName?: string; + + /** + * The version of the plugin, typically the version of the package.json file. + */ + version?: string; + + /** + * As short description of the plugin, typically the description field in + * package.json. + */ + description?: string; + + /** + * The owner entity references of the plugin. + */ + ownerEntityRefs?: string[]; + + /** + * Links related to the plugin. + */ + links?: Array<{ title: string; url: string }>; +} + +/** + * Options for providing information for a plugin. + * + * @public + */ +export type FrontendPluginInfoOptions = { + /** + * A loader function for the package.json file for the plugin. + */ + packageJson?: () => Promise<{ name: string } & JsonObject>; + /** + * A loader function for an opaque manifest file for the plugin. + */ + manifest?: () => Promise; +}; /** @public */ export interface FrontendPlugin< @@ -38,9 +99,21 @@ export interface FrontendPlugin< readonly id: string; readonly routes: TRoutes; readonly externalRoutes: TExternalRoutes; + + /** + * Loads the plugin info. + */ + info(): Promise; getExtension(id: TId): TExtensionMap[TId]; withOverrides(options: { extensions: Array; + + /** + * Overrides to merge with the original plugin info. The merging is done for + * each top-level field. Setting a field explicitly to `undefined` will + * remove it. + */ + info?: FrontendPluginInfoOptions; }): FrontendPlugin; } @@ -56,6 +129,7 @@ export interface PluginOptions< externalRoutes?: TExternalRoutes; extensions?: TExtensions; featureFlags?: FeatureFlagConfig[]; + info?: FrontendPluginInfoOptions; } /** @public */ @@ -150,6 +224,14 @@ export function createFrontendPlugin< externalRoutes: options.externalRoutes ?? ({} as TExternalRoutes), featureFlags: options.featureFlags ?? [], extensions: extensions, + infoOptions: options.info, + + // This method is overridden when the plugin instance is installed in an app + async info() { + throw new Error( + `Attempted to load plugin info for plugin '${pluginId}', but the plugin instance is not installed in an app`, + ); + }, getExtension(id) { const ext = extensionDefinitionsById.get(id); if (!ext) { @@ -178,6 +260,10 @@ export function createFrontendPlugin< ...options, pluginId, extensions: [...nonOverriddenExtensions, ...overrides.extensions], + info: { + ...options.info, + ...overrides.info, + }, }); }, }); diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index b584fc9769..24a4e1653f 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -40,6 +40,8 @@ export { createFrontendPlugin, type FrontendPlugin, type PluginOptions, + type FrontendPluginInfo, + type FrontendPluginInfoOptions, } from './createFrontendPlugin'; export { createFrontendModule, From 18c64e9bd4153308931defb77fa6c657cb08401d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 14 May 2025 15:03:48 +0200 Subject: [PATCH 02/10] plugins: add info.packageJson loader for all plugins Signed-off-by: Patrik Oldsberg --- .changeset/evil-cooks-watch.md | 21 +++++++++++++++++++ .changeset/petite-candies-tan.md | 2 +- plugins/api-docs/src/alpha.tsx | 1 + plugins/app-visualizer/src/plugin.tsx | 1 + plugins/app/src/plugin.ts | 1 + plugins/catalog-graph/src/alpha.tsx | 1 + plugins/catalog-import/src/alpha.tsx | 1 + .../src/alpha/plugin.tsx | 1 + plugins/catalog/src/alpha/plugin.tsx | 1 + plugins/devtools/src/alpha/plugin.tsx | 1 + plugins/home/src/alpha.tsx | 1 + plugins/kubernetes/src/alpha/plugin.tsx | 1 + plugins/notifications/src/alpha.tsx | 1 + plugins/org/src/alpha.tsx | 1 + plugins/scaffolder/src/alpha/plugin.tsx | 1 + plugins/search/src/alpha.tsx | 1 + plugins/signals/src/alpha.tsx | 1 + plugins/techdocs/src/alpha.tsx | 1 + plugins/user-settings/src/alpha.tsx | 1 + 19 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 .changeset/evil-cooks-watch.md diff --git a/.changeset/evil-cooks-watch.md b/.changeset/evil-cooks-watch.md new file mode 100644 index 0000000000..0982f24634 --- /dev/null +++ b/.changeset/evil-cooks-watch.md @@ -0,0 +1,21 @@ +--- +'@backstage/plugin-catalog-unprocessed-entities': patch +'@backstage/plugin-app-visualizer': patch +'@backstage/plugin-catalog-import': patch +'@backstage/plugin-catalog-graph': patch +'@backstage/plugin-notifications': patch +'@backstage/plugin-user-settings': patch +'@backstage/plugin-kubernetes': patch +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-api-docs': patch +'@backstage/plugin-devtools': patch +'@backstage/plugin-techdocs': patch +'@backstage/plugin-catalog': patch +'@backstage/plugin-signals': patch +'@backstage/plugin-search': patch +'@backstage/plugin-home': patch +'@backstage/plugin-app': patch +'@backstage/plugin-org': patch +--- + +Added the `info.packageJson` option to the plugin instance for the new frontend system. diff --git a/.changeset/petite-candies-tan.md b/.changeset/petite-candies-tan.md index 507e348f0e..e5a65f8066 100644 --- a/.changeset/petite-candies-tan.md +++ b/.changeset/petite-candies-tan.md @@ -10,7 +10,7 @@ There are two available loaders. The first one is `info.packageJson`, which can export default createFrontendPlugin({ pluginId: '...', info: { - packageJson: () => import('../package.json'), + info: { packageJson: () => import('../package.json') }, }, }); ``` diff --git a/plugins/api-docs/src/alpha.tsx b/plugins/api-docs/src/alpha.tsx index cb55f620a7..356328a61d 100644 --- a/plugins/api-docs/src/alpha.tsx +++ b/plugins/api-docs/src/alpha.tsx @@ -228,6 +228,7 @@ const apiDocsApisEntityContent = EntityContentBlueprint.make({ export default createFrontendPlugin({ pluginId: 'api-docs', + info: { packageJson: () => import('../package.json') }, routes: { root: convertLegacyRouteRef(rootRoute), }, diff --git a/plugins/app-visualizer/src/plugin.tsx b/plugins/app-visualizer/src/plugin.tsx index 92ccd00159..faaf2a5788 100644 --- a/plugins/app-visualizer/src/plugin.tsx +++ b/plugins/app-visualizer/src/plugin.tsx @@ -46,5 +46,6 @@ export const appVisualizerNavItem = NavItemBlueprint.make({ /** @public */ export const visualizerPlugin = createFrontendPlugin({ pluginId: 'app-visualizer', + info: { packageJson: () => import('../package.json') }, extensions: [appVisualizerPage, appVisualizerNavItem], }); diff --git a/plugins/app/src/plugin.ts b/plugins/app/src/plugin.ts index b166423fa1..a1e6602410 100644 --- a/plugins/app/src/plugin.ts +++ b/plugins/app/src/plugin.ts @@ -42,6 +42,7 @@ import { apis } from './defaultApis'; /** @public */ export const appPlugin = createFrontendPlugin({ pluginId: 'app', + info: { packageJson: () => import('../package.json') }, extensions: [ ...apis, App, diff --git a/plugins/catalog-graph/src/alpha.tsx b/plugins/catalog-graph/src/alpha.tsx index d70232e92a..aa1f6a05cf 100644 --- a/plugins/catalog-graph/src/alpha.tsx +++ b/plugins/catalog-graph/src/alpha.tsx @@ -87,6 +87,7 @@ const CatalogGraphPage = PageBlueprint.makeWithOverrides({ export default createFrontendPlugin({ pluginId: 'catalog-graph', + info: { packageJson: () => import('../package.json') }, routes: { catalogGraph: convertLegacyRouteRef(catalogGraphRouteRef), }, diff --git a/plugins/catalog-import/src/alpha.tsx b/plugins/catalog-import/src/alpha.tsx index 3fade2d92b..17260acc14 100644 --- a/plugins/catalog-import/src/alpha.tsx +++ b/plugins/catalog-import/src/alpha.tsx @@ -87,6 +87,7 @@ const catalogImportApi = ApiBlueprint.make({ /** @alpha */ export default createFrontendPlugin({ pluginId: 'catalog-import', + info: { packageJson: () => import('../package.json') }, extensions: [catalogImportApi, catalogImportPage], routes: { importPage: convertLegacyRouteRef(rootRouteRef), diff --git a/plugins/catalog-unprocessed-entities/src/alpha/plugin.tsx b/plugins/catalog-unprocessed-entities/src/alpha/plugin.tsx index 8ff83605c7..34a40b23d7 100644 --- a/plugins/catalog-unprocessed-entities/src/alpha/plugin.tsx +++ b/plugins/catalog-unprocessed-entities/src/alpha/plugin.tsx @@ -74,6 +74,7 @@ export const catalogUnprocessedEntitiesNavItem = NavItemBlueprint.make({ /** @alpha */ export default createFrontendPlugin({ pluginId: 'catalog-unprocessed-entities', + info: { packageJson: () => import('../../package.json') }, routes: { root: convertLegacyRouteRef(rootRouteRef), }, diff --git a/plugins/catalog/src/alpha/plugin.tsx b/plugins/catalog/src/alpha/plugin.tsx index 6fd171ed54..c5dad31cf0 100644 --- a/plugins/catalog/src/alpha/plugin.tsx +++ b/plugins/catalog/src/alpha/plugin.tsx @@ -39,6 +39,7 @@ import contextMenuItems from './contextMenuItems'; /** @alpha */ export default createFrontendPlugin({ pluginId: 'catalog', + info: { packageJson: () => import('../../package.json') }, routes: convertLegacyRouteRefs({ catalogIndex: rootRouteRef, catalogEntity: entityRouteRef, diff --git a/plugins/devtools/src/alpha/plugin.tsx b/plugins/devtools/src/alpha/plugin.tsx index 959670f3a5..52caaf4786 100644 --- a/plugins/devtools/src/alpha/plugin.tsx +++ b/plugins/devtools/src/alpha/plugin.tsx @@ -71,6 +71,7 @@ export const devToolsNavItem = NavItemBlueprint.make({ /** @alpha */ export default createFrontendPlugin({ pluginId: 'devtools', + info: { packageJson: () => import('../../package.json') }, routes: { root: convertLegacyRouteRef(rootRouteRef), }, diff --git a/plugins/home/src/alpha.tsx b/plugins/home/src/alpha.tsx index 04d612ef1c..854a8a7dd3 100644 --- a/plugins/home/src/alpha.tsx +++ b/plugins/home/src/alpha.tsx @@ -68,6 +68,7 @@ const homePage = PageBlueprint.makeWithOverrides({ */ export default createFrontendPlugin({ pluginId: 'home', + info: { packageJson: () => import('../package.json') }, extensions: [homePage], routes: { root: rootRouteRef, diff --git a/plugins/kubernetes/src/alpha/plugin.tsx b/plugins/kubernetes/src/alpha/plugin.tsx index 35cb9e037d..db7b2439bf 100644 --- a/plugins/kubernetes/src/alpha/plugin.tsx +++ b/plugins/kubernetes/src/alpha/plugin.tsx @@ -28,6 +28,7 @@ import { export default createFrontendPlugin({ pluginId: 'kubernetes', + info: { packageJson: () => import('../../package.json') }, extensions: [ kubernetesPage, entityKubernetesContent, diff --git a/plugins/notifications/src/alpha.tsx b/plugins/notifications/src/alpha.tsx index d7c4ec27b6..4424659593 100644 --- a/plugins/notifications/src/alpha.tsx +++ b/plugins/notifications/src/alpha.tsx @@ -54,6 +54,7 @@ const api = ApiBlueprint.make({ /** @alpha */ export default createFrontendPlugin({ pluginId: 'notifications', + info: { packageJson: () => import('../package.json') }, routes: convertLegacyRouteRefs({ root: rootRouteRef, }), diff --git a/plugins/org/src/alpha.tsx b/plugins/org/src/alpha.tsx index beb4f573af..d762e8c270 100644 --- a/plugins/org/src/alpha.tsx +++ b/plugins/org/src/alpha.tsx @@ -73,6 +73,7 @@ const EntityUserProfileCard = EntityCardBlueprint.make({ /** @alpha */ export default createFrontendPlugin({ pluginId: 'org', + info: { packageJson: () => import('../package.json') }, extensions: [ EntityGroupProfileCard, EntityMembersListCard, diff --git a/plugins/scaffolder/src/alpha/plugin.tsx b/plugins/scaffolder/src/alpha/plugin.tsx index aa37f80493..ebad851901 100644 --- a/plugins/scaffolder/src/alpha/plugin.tsx +++ b/plugins/scaffolder/src/alpha/plugin.tsx @@ -39,6 +39,7 @@ import { formDecoratorsApi } from './api'; /** @alpha */ export default createFrontendPlugin({ pluginId: 'scaffolder', + info: { packageJson: () => import('../../package.json') }, routes: convertLegacyRouteRefs({ root: rootRouteRef, selectedTemplate: selectedTemplateRouteRef, diff --git a/plugins/search/src/alpha.tsx b/plugins/search/src/alpha.tsx index 5f55b46028..136f95f39e 100644 --- a/plugins/search/src/alpha.tsx +++ b/plugins/search/src/alpha.tsx @@ -279,6 +279,7 @@ export const searchNavItem = NavItemBlueprint.make({ /** @alpha */ export default createFrontendPlugin({ pluginId: 'search', + info: { packageJson: () => import('../package.json') }, extensions: [searchApi, searchPage, searchNavItem], routes: convertLegacyRouteRefs({ root: rootRouteRef, diff --git a/plugins/signals/src/alpha.tsx b/plugins/signals/src/alpha.tsx index ba7f72f420..90b78b05fd 100644 --- a/plugins/signals/src/alpha.tsx +++ b/plugins/signals/src/alpha.tsx @@ -45,5 +45,6 @@ const api = ApiBlueprint.make({ /** @alpha */ export default createFrontendPlugin({ pluginId: 'signals', + info: { packageJson: () => import('../package.json') }, extensions: [api], }); diff --git a/plugins/techdocs/src/alpha.tsx b/plugins/techdocs/src/alpha.tsx index b2bf794e73..21b594c82d 100644 --- a/plugins/techdocs/src/alpha.tsx +++ b/plugins/techdocs/src/alpha.tsx @@ -236,6 +236,7 @@ const techDocsNavItem = NavItemBlueprint.make({ /** @alpha */ export default createFrontendPlugin({ pluginId: 'techdocs', + info: { packageJson: () => import('../package.json') }, extensions: [ techDocsClientApi, techDocsStorageApi, diff --git a/plugins/user-settings/src/alpha.tsx b/plugins/user-settings/src/alpha.tsx index 228c6a23d2..f351dad40a 100644 --- a/plugins/user-settings/src/alpha.tsx +++ b/plugins/user-settings/src/alpha.tsx @@ -69,6 +69,7 @@ export const settingsNavItem = NavItemBlueprint.make({ */ export default createFrontendPlugin({ pluginId: 'user-settings', + info: { packageJson: () => import('../package.json') }, extensions: [userSettingsPage, settingsNavItem], routes: convertLegacyRouteRefs({ root: settingsRouteRef, From c38c9e816925f793ed1cb8b8c80d6192962182f2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 May 2025 09:05:33 +0200 Subject: [PATCH 03/10] frontend-app-api: add support for plugin info resolution and overrides Signed-off-by: Patrik Oldsberg --- .changeset/stupid-flies-speak.md | 5 + packages/frontend-app-api/config.d.ts | 66 ++++ packages/frontend-app-api/report.api.md | 19 +- .../wiring/createPluginInfoAttacher.test.ts | 284 ++++++++++++++++++ .../src/wiring/createPluginInfoAttacher.ts | 247 +++++++++++++++ .../src/wiring/createSpecializedApp.test.tsx | 119 ++++++++ .../src/wiring/createSpecializedApp.tsx | 12 +- packages/frontend-app-api/src/wiring/index.ts | 1 + 8 files changed, 750 insertions(+), 3 deletions(-) create mode 100644 .changeset/stupid-flies-speak.md create mode 100644 packages/frontend-app-api/src/wiring/createPluginInfoAttacher.test.ts create mode 100644 packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts diff --git a/.changeset/stupid-flies-speak.md b/.changeset/stupid-flies-speak.md new file mode 100644 index 0000000000..cad07ae611 --- /dev/null +++ b/.changeset/stupid-flies-speak.md @@ -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. diff --git a/packages/frontend-app-api/config.d.ts b/packages/frontend-app-api/config.d.ts index fc265e8833..3b86b5c1d4 100644 --- a/packages/frontend-app-api/config.d.ts +++ b/packages/frontend-app-api/config.d.ts @@ -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 }>; + }; + }>; }; } diff --git a/packages/frontend-app-api/report.api.md b/packages/frontend-app-api/report.api.md index d2a3ac4d3b..7228777561 100644 --- a/packages/frontend-app-api/report.api.md +++ b/packages/frontend-app-api/report.api.md @@ -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; + manifest(): Promise; + defaultResolver(sources: { + packageJson: JsonObject | undefined; + manifest: JsonObject | undefined; + }): Promise<{ + info: FrontendPluginInfo; + }>; +}) => Promise<{ + info: FrontendPluginInfo; +}>; ``` diff --git a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.test.ts b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.test.ts new file mode 100644 index 0000000000..5595c04135 --- /dev/null +++ b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.test.ts @@ -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]', + }); + }); + }); +}); diff --git a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts new file mode 100644 index 0000000000..27a6ca73ac --- /dev/null +++ b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts @@ -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; + manifest(): Promise; + 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); +} diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index 00551e16e3..7c6bb7958b 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -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(
Test
)], + }); + + 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', + }); + }); + }); }); diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index 5f6692d600..51b0f14250 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -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', diff --git a/packages/frontend-app-api/src/wiring/index.ts b/packages/frontend-app-api/src/wiring/index.ts index 4ecce3f184..fc52dcb7a1 100644 --- a/packages/frontend-app-api/src/wiring/index.ts +++ b/packages/frontend-app-api/src/wiring/index.ts @@ -15,4 +15,5 @@ */ export { createSpecializedApp } from './createSpecializedApp'; +export { type FrontendPluginInfoResolver } from './createPluginInfoAttacher'; export * from './types'; From 6f48f718b046e8d143afa8cc4b94bbd469ec4a1d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 May 2025 09:36:32 +0200 Subject: [PATCH 04/10] frontend-plugin-api: add useAppNode Signed-off-by: Patrik Oldsberg --- .changeset/puny-pillows-wave.md | 5 ++ packages/frontend-plugin-api/report.api.md | 3 + .../src/components/AppNodeProvider.test.tsx | 84 +++++++++++++++++++ .../src/components/AppNodeProvider.tsx | 74 ++++++++++++++++ .../src/components/ExtensionBoundary.tsx | 21 +++-- .../src/components/index.ts | 1 + 6 files changed, 179 insertions(+), 9 deletions(-) create mode 100644 .changeset/puny-pillows-wave.md create mode 100644 packages/frontend-plugin-api/src/components/AppNodeProvider.test.tsx create mode 100644 packages/frontend-plugin-api/src/components/AppNodeProvider.tsx diff --git a/.changeset/puny-pillows-wave.md b/.changeset/puny-pillows-wave.md new file mode 100644 index 0000000000..813e603007 --- /dev/null +++ b/.changeset/puny-pillows-wave.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Added a new `useAppNode` hook, which can be used to get a reference to the `AppNode` from by the closest `ExtensionBoundary`. diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 05a12184a3..21c44a2c77 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -1830,6 +1830,9 @@ export { useApi }; export { useApiHolder }; +// @public +export function useAppNode(): AppNode | undefined; + // @public export function useComponentRef( ref: ComponentRef, diff --git a/packages/frontend-plugin-api/src/components/AppNodeProvider.test.tsx b/packages/frontend-plugin-api/src/components/AppNodeProvider.test.tsx new file mode 100644 index 0000000000..fcc3b74c59 --- /dev/null +++ b/packages/frontend-plugin-api/src/components/AppNodeProvider.test.tsx @@ -0,0 +1,84 @@ +/* + * 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 { + createVersionedContext, + createVersionedValueMap, +} from '@backstage/version-bridge'; +import { AppNode } from '../apis'; +import { renderHook } from '@testing-library/react'; +import { AppNodeProvider, useAppNode } from './AppNodeProvider'; +import { withLogCollector } from '@backstage/test-utils'; + +describe('AppNodeProvider', () => { + it('should provide app node context to children', () => { + const node = { id: 'test' } as unknown as AppNode; + const { result } = renderHook(() => useAppNode(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(result.current).toBe(node); + }); + + it('should return undefined when used outside provider', () => { + const { result } = renderHook(() => useAppNode()); + expect(result.current).toBeUndefined(); + }); + + it('should return the closest app node', () => { + const node1 = { id: 'test1' } as unknown as AppNode; + const node2 = { id: 'test2' } as unknown as AppNode; + + const { result } = renderHook(() => useAppNode(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toBe(node2); + }); + + it('should throw error for invalid context version', () => { + const node = { id: 'test' } as unknown as AppNode; + const Context = createVersionedContext('app-node-context'); + const value = createVersionedValueMap({ 2: { node } }); + + const { error } = withLogCollector(() => { + expect(() => + renderHook(() => useAppNode(), { + wrapper: ({ children }) => ( + {children} + ), + }), + ).toThrow('AppNodeContext v1 not available'); + }); + expect(error).toEqual([ + expect.objectContaining({ + detail: new Error('AppNodeContext v1 not available'), + }), + expect.objectContaining({ + detail: new Error('AppNodeContext v1 not available'), + }), + expect.stringContaining( + 'The above error occurred in the component:', + ), + ]); + }); +}); diff --git a/packages/frontend-plugin-api/src/components/AppNodeProvider.tsx b/packages/frontend-plugin-api/src/components/AppNodeProvider.tsx new file mode 100644 index 0000000000..2bc9715c10 --- /dev/null +++ b/packages/frontend-plugin-api/src/components/AppNodeProvider.tsx @@ -0,0 +1,74 @@ +/* + * 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 { + createVersionedContext, + createVersionedValueMap, + useVersionedContext, +} from '@backstage/version-bridge'; +import { AppNode } from '../apis'; +import { ReactNode } from 'react'; + +const CONTEXT_KEY = 'app-node-context'; + +type AppNodeContextV1 = { + node?: AppNode; +}; + +type AppNodeContextMap = { + 1: AppNodeContextV1; +}; + +const AppNodeContext = createVersionedContext(CONTEXT_KEY); + +/** @internal */ +export function AppNodeProvider({ + node, + children, +}: { + node: AppNode; + children: ReactNode; +}) { + const versionedValue = createVersionedValueMap({ 1: { node } }); + + return ; +} + +/** + * React hook providing access to the current {@link AppNode}. + * + * @public + * @remarks + * + * This hook will return the {@link AppNode} for the closest extension. This + * relies on the extension using the {@link (ExtensionBoundary:function)} component in its + * implementation, which is included by default for all common blueprints. + * + * If the current component is not inside an {@link (ExtensionBoundary:function)}, it will + * return `undefined`. + */ +export function useAppNode(): AppNode | undefined { + const versionedContext = useVersionedContext(CONTEXT_KEY); + if (!versionedContext) { + return undefined; + } + + const context = versionedContext.atVersion(1); + if (!context) { + throw new Error('AppNodeContext v1 not available'); + } + return context.node; +} diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx index 46b3956e0e..7f5581054c 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx @@ -28,6 +28,7 @@ import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/ana import { AppNode, useComponentRef } from '../apis'; import { coreComponentRefs } from './coreComponentRefs'; import { coreExtensionData } from '../wiring'; +import { AppNodeProvider } from './AppNodeProvider'; type RouteTrackerProps = PropsWithChildren<{ disableTracking?: boolean; @@ -80,15 +81,17 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) { }; return ( - }> - - - - {children} - - - - + + }> + + + + {children} + + + + + ); } diff --git a/packages/frontend-plugin-api/src/components/index.ts b/packages/frontend-plugin-api/src/components/index.ts index 5066144000..31a53be24c 100644 --- a/packages/frontend-plugin-api/src/components/index.ts +++ b/packages/frontend-plugin-api/src/components/index.ts @@ -20,3 +20,4 @@ export { } from './ExtensionBoundary'; export { coreComponentRefs } from './coreComponentRefs'; export { createComponentRef, type ComponentRef } from './createComponentRef'; +export { useAppNode } from './AppNodeProvider'; From fa5650cc80d69b4ff67b06d205263b260589d23f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 May 2025 09:53:34 +0200 Subject: [PATCH 05/10] frontend-defaults: add pluginInfoResolver + test Signed-off-by: Patrik Oldsberg --- .changeset/spotty-cases-show.md | 5 ++ packages/frontend-defaults/report.api.md | 3 ++ .../frontend-defaults/src/createApp.test.tsx | 49 +++++++++++++++++++ packages/frontend-defaults/src/createApp.tsx | 3 ++ 4 files changed, 60 insertions(+) create mode 100644 .changeset/spotty-cases-show.md diff --git a/.changeset/spotty-cases-show.md b/.changeset/spotty-cases-show.md new file mode 100644 index 0000000000..3e9a607733 --- /dev/null +++ b/.changeset/spotty-cases-show.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-defaults': patch +--- + +Forwarded the new `pluginInfoResolver` option for `createApp`. diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md index e9bcd99920..600f0a27de 100644 --- a/packages/frontend-defaults/report.api.md +++ b/packages/frontend-defaults/report.api.md @@ -10,6 +10,7 @@ import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api'; import { FrontendFeature } from '@backstage/frontend-plugin-api'; import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api'; import { JSX as JSX_2 } from 'react'; +import { FrontendPluginInfoResolver } from '@backstage/frontend-app-api'; import { ReactNode } from 'react'; // @public @@ -44,6 +45,8 @@ export interface CreateAppOptions { | CreateAppFeatureLoader )[]; loadingComponent?: ReactNode; + // (undocumented) + pluginInfoResolver?: FrontendPluginInfoResolver; } // @public diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index 4fd1ba4c5b..4f627f1839 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -23,12 +23,15 @@ import { createFrontendPlugin, ThemeBlueprint, createFrontendModule, + useAppNode, + FrontendPluginInfo, } from '@backstage/frontend-plugin-api'; import { screen, waitFor } from '@testing-library/react'; import { CreateAppFeatureLoader, createApp } from './createApp'; import { mockApis, renderWithEffects } from '@backstage/test-utils'; import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api'; import { default as appPluginOriginal } from '@backstage/plugin-app'; +import { useState, useEffect } from 'react'; describe('createApp', () => { const appPlugin = appPluginOriginal.withOverrides({ @@ -119,6 +122,52 @@ describe('createApp', () => { ); }); + it('should allow overriding the plugin info resolver', async () => { + function TestComponent() { + const appNode = useAppNode(); + const [info, setInfo] = useState( + undefined, + ); + + useEffect(() => { + appNode?.spec.source?.info().then(setInfo); + }, [appNode]); + + return
Package name: {info?.packageName}
; + } + + const app = createApp({ + configLoader: async () => ({ config: mockApis.config() }), + features: [ + appPlugin, + createFrontendPlugin({ + pluginId: 'test', + extensions: [ + PageBlueprint.make({ + params: { + defaultPath: '/', + loader: async () => , + }, + }), + ], + }), + ], + pluginInfoResolver: async () => { + return { + info: { + packageName: '@test/test', + }, + }; + }, + }); + + await renderWithEffects(app.createRoot()); + + await expect( + screen.findByText('Package name: @test/test'), + ).resolves.toBeInTheDocument(); + }); + it('should support feature loaders', async () => { const loader: CreateAppFeatureLoader = { getLoaderName() { diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index f25b71ce18..e8baa3a764 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -30,6 +30,7 @@ import { ConfigReader } from '@backstage/config'; import { CreateAppRouteBinder, createSpecializedApp, + FrontendPluginInfoResolver, } from '@backstage/frontend-app-api'; import appPlugin from '@backstage/plugin-app'; import { discoverAvailableFeatures } from './discovery'; @@ -78,6 +79,7 @@ export interface CreateAppOptions { extensionFactoryMiddleware?: | ExtensionFactoryMiddleware | ExtensionFactoryMiddleware[]; + pluginInfoResolver?: FrontendPluginInfoResolver; } /** @@ -112,6 +114,7 @@ export function createApp(options?: CreateAppOptions): { features: [appPlugin, ...loadedFeatures], bindRoutes: options?.bindRoutes, extensionFactoryMiddleware: options?.extensionFactoryMiddleware, + pluginInfoResolver: options?.pluginInfoResolver, }); const rootEl = app.tree.root.instance!.getData( From 32a64e999e7dca67af2937d0088ffb8000ddf103 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 May 2025 11:56:20 +0200 Subject: [PATCH 06/10] app-next: add custom plugin info example Signed-off-by: Patrik Oldsberg --- packages/app-next/app-config.yaml | 14 +++++ packages/app-next/src/App.tsx | 2 + .../app-next/src/examples/pagesPlugin.tsx | 24 +++++++++ packages/app-next/src/pluginInfoResolver.ts | 52 +++++++++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 packages/app-next/src/pluginInfoResolver.ts diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index 9d2bcf198a..fac6dbbdf2 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -8,6 +8,20 @@ app: catalog.createComponent: catalog-import.importPage org.catalogIndex: catalog.catalogIndex + pluginOverrides: + - match: + pluginId: pages + info: + description: 'This description was overridden in packages/app-next/app-config.yaml' + - match: + pluginId: /^catalog(-.*)?$/ + info: + ownerEntityRefs: [cubic-belugas] + - match: + packageName: '@backstage/plugin-scaffolder' + info: + ownerEntityRefs: [cubic-belugas] + extensions: # - apis.plugin.graphiql.browse.gitlab: true # - graphiql-endpoint:graphiql/gitlab: true diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index 0ecb46e06a..43cc63d1a4 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -43,6 +43,7 @@ import kubernetesPlugin from '@backstage/plugin-kubernetes/alpha'; import { convertLegacyPlugin } from '@backstage/core-compat-api'; import { convertLegacyPageExtension } from '@backstage/core-compat-api'; import { convertLegacyEntityContentExtension } from '@backstage/plugin-catalog-react/alpha'; +import { pluginInfoResolver } from './pluginInfoResolver'; /* @@ -132,6 +133,7 @@ const app = createApp({ customHomePageModule, ...collectedLegacyPlugins, ], + pluginInfoResolver, /* Handled through config instead */ // bindRoutes({ bind }) { // bind(pagesPlugin.externalRoutes, { pageX: pagesPlugin.routes.pageX }); diff --git a/packages/app-next/src/examples/pagesPlugin.tsx b/packages/app-next/src/examples/pagesPlugin.tsx index ddd080c28b..33fc7d8c0b 100644 --- a/packages/app-next/src/examples/pagesPlugin.tsx +++ b/packages/app-next/src/examples/pagesPlugin.tsx @@ -21,7 +21,10 @@ import { createExternalRouteRef, useRouteRef, PageBlueprint, + FrontendPluginInfo, + useAppNode, } from '@backstage/frontend-plugin-api'; +import { useEffect, useState } from 'react'; import { Route, Routes } from 'react-router-dom'; const indexRouteRef = createRouteRef(); @@ -36,6 +39,22 @@ export const pageXRouteRef = createRouteRef(); // path: '/page2', // }); +function PluginInfo() { + const node = useAppNode(); + const [info, setInfo] = useState(undefined); + + useEffect(() => { + node?.spec.source?.info().then(setInfo); + }, [node]); + + return ( +
+

Plugin Info

+
{JSON.stringify(info, null, 2)}
+
+ ); +} + const IndexPage = PageBlueprint.make({ name: 'index', params: { @@ -64,6 +83,7 @@ const IndexPage = PageBlueprint.make({
Settings
+ ); }; @@ -139,6 +159,10 @@ export const pagesPlugin = createFrontendPlugin({ // // OR // // 'page1' // }, + info: { + packageJson: () => import('../../package.json'), + manifest: () => import('../../catalog-info.yaml'), + }, routes: { page1: page1RouteRef, pageX: pageXRouteRef, diff --git a/packages/app-next/src/pluginInfoResolver.ts b/packages/app-next/src/pluginInfoResolver.ts new file mode 100644 index 0000000000..12f730875c --- /dev/null +++ b/packages/app-next/src/pluginInfoResolver.ts @@ -0,0 +1,52 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { FrontendPluginInfoResolver } from '@backstage/frontend-app-api'; + +// This file shows an example of what it looks like to extend the plugin info +// resolution with custom logic and fields. In this case we're reading the +// `spec.type` field from the plugin manifest (catalog-info.yaml). +// +// Using module augmentation we extend the `FrontendPluginInfo` interface to +// include our custom fields. This makes these fields available throughout our project. + +declare module '@backstage/frontend-plugin-api' { + export interface FrontendPluginInfo { + /** + * **DO NOT USE** + * + * This field is added in the example app to showcase module augmentation + * for extending the plugin info in internal apps. It only exists as an + * example in this project. + */ + exampleFieldDoNotUse?: string; + } +} + +export const pluginInfoResolver: FrontendPluginInfoResolver = async ctx => { + const manifest = (await ctx.manifest?.()) as Entity | undefined; + const { info: defaultInfo } = await ctx.defaultResolver({ + packageJson: await ctx.packageJson(), + manifest: manifest, + }); + return { + info: { + ...defaultInfo, + exampleFieldDoNotUse: manifest?.spec?.type?.toString(), + }, + }; +}; From f0f110baac3307b179f70a812bb7b8f95650704e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 May 2025 12:51:34 +0200 Subject: [PATCH 07/10] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Signed-off-by: Patrik Oldsberg --- .changeset/petite-candies-tan.md | 6 +++--- .changeset/stupid-flies-speak.md | 2 +- .../frontend-app-api/src/wiring/createPluginInfoAttacher.ts | 4 ++-- packages/frontend-defaults/report.api.md | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.changeset/petite-candies-tan.md b/.changeset/petite-candies-tan.md index e5a65f8066..6ec5c9c813 100644 --- a/.changeset/petite-candies-tan.md +++ b/.changeset/petite-candies-tan.md @@ -4,18 +4,18 @@ Added a new optional `info` option to `createFrontendPlugin` that lets you provide a loaders for different sources of metadata information about the plugin. -There are two available loaders. The first one is `info.packageJson`, which can be used to point to a `package.json` file for the plugin.This is recommended for any plugin that is defined within its own package, especially all plugins that are published to a package registry. Typical usage looks like this: +There are two available loaders. The first one is `info.packageJson`, which can be used to point to a `package.json` file for the plugin. This is recommended for any plugin that is defined within its own package, especially all plugins that are published to a package registry. Typical usage looks like this: ```ts export default createFrontendPlugin({ pluginId: '...', info: { - info: { packageJson: () => import('../package.json') }, + packageJson: () => import('../package.json'), }, }); ``` -The second loader is `info.manifest`, which can be used to point to an opaque plugin manifest. This **MUST ONLY** be used by plugins that intended for use within a single organization. Plugins that are published to an open package registry should **NOT** use this loader. The loader is useful to add additional internal metadata associated with the plugin, and it is up to the Backstage app to decide how these manifests are parsed and used. The default manifest parser in an app created with `createApp` from `@backstage/frontend-defaults` is able to parse the default `catalog-info.yaml` format and built-in fields such as `spec.owner`. +The second loader is `info.manifest`, which can be used to point to an opaque plugin manifest. This **MUST ONLY** be used by plugins that are intended for use within a single organization. Plugins that are published to an open package registry should **NOT** use this loader. The loader is useful for adding additional internal metadata associated with the plugin, and it is up to the Backstage app to decide how these manifests are parsed and used. The default manifest parser in an app created with `createApp` from `@backstage/frontend-defaults` is able to parse the default `catalog-info.yaml` format and built-in fields such as `spec.owner`. Typical usage looks like this: diff --git a/.changeset/stupid-flies-speak.md b/.changeset/stupid-flies-speak.md index cad07ae611..5156a5b8b6 100644 --- a/.changeset/stupid-flies-speak.md +++ b/.changeset/stupid-flies-speak.md @@ -2,4 +2,4 @@ '@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. +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 overridden via the `pluginInfoResolver` option to `createSpecializedApp`, and plugin-specific overrides can be applied via the new `app.pluginOverrides` key in static configuration. diff --git a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts index 27a6ca73ac..2b6f3a57f7 100644 --- a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts +++ b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts @@ -90,7 +90,7 @@ function normalizePluginInfo(info: FrontendPluginInfo) { ownerEntityRefs: info.ownerEntityRefs?.map(ref => stringifyEntityRef( parseEntityRef(ref, { - defaultKind: 'group', + defaultKind: 'Group', }), ), ), @@ -161,7 +161,7 @@ function resolveManifestInfo(manifest?: JsonValue) { info.ownerEntityRefs = [ stringifyEntityRef( parseEntityRef(manifest.spec.owner, { - defaultKind: 'group', + defaultKind: 'Group', defaultNamespace: manifest.metadata.namespace?.toString(), }), ), diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md index 600f0a27de..72f212b3b1 100644 --- a/packages/frontend-defaults/report.api.md +++ b/packages/frontend-defaults/report.api.md @@ -9,8 +9,8 @@ import { CreateAppRouteBinder } from '@backstage/frontend-app-api'; import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api'; import { FrontendFeature } from '@backstage/frontend-plugin-api'; import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api'; -import { JSX as JSX_2 } from 'react'; import { FrontendPluginInfoResolver } from '@backstage/frontend-app-api'; +import { JSX as JSX_2 } from 'react'; import { ReactNode } from 'react'; // @public From 363e515d599ca797445ebcaeea07a59c9baf633c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 May 2025 17:14:23 +0200 Subject: [PATCH 08/10] docs/frontend-system: document plugin info system Signed-off-by: Patrik Oldsberg --- docs/frontend-system/architecture/10-app.md | 95 +++++++++++++++++++ .../architecture/15-plugins.md | 29 ++++++ 2 files changed, 124 insertions(+) diff --git a/docs/frontend-system/architecture/10-app.md b/docs/frontend-system/architecture/10-app.md index d1c3820f09..2e1a23e3bc 100644 --- a/docs/frontend-system/architecture/10-app.md +++ b/docs/frontend-system/architecture/10-app.md @@ -77,3 +77,98 @@ app: ``` Note that you do not need to manually exclude packages that you also import explicitly in code, since plugin instances are deduplicated by the app. You will never end up with duplicate plugin installations except if they are in fact two different plugin instances with different IDs. + +## Plugin Info Resolution + +When a plugin is installed in an app it may provide sources of information about the plugin that can be useful to end users and admins. This includes things like what version of a plugin is running, what team owns the plugin, and who to contact for support. You can read more about how the plugins provide this information in the [plugins `info` option section](./15-plugins.md#info). + +By default the app will pick a few common fields from `package.json` files, and assume that the opaque manifests are `catalog-info.yaml` files that some information can be gathered from too. This information will then be available via the `info()` method on plugin instances, returning a structure of the `FrontendPluginInfo` type. + +### Extending Plugin Info + +The default plugin info is intended as a base to build upon. As part of setting up an app you can both customize the way that the plugin info is resolved, as well as extend the `FrontendPluginInfo` type to include more information. + +In order to extend the `FrontendPluginInfo` type you use [TypeScript module augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation). This makes it possible to extend the `FrontendPluginInfo` inferface with additional fields, which you can then add custom resolution logic for as well as access within the app. For example, you might add a `slackChannel` field as follows: + +```ts +declare module '@backstage/frontend-plugin-api' { + interface FrontendPluginInfo { + /** + * The slack channel to use for support requests for this plugin. + */ + slackChannel?: string; + } +} +``` + +### Customizing Plugin Info Resolution + +With the new `slackChannel` field in place, we now need to provide a custom resolver that knows how to extract this information from the plugin information sources. This is done by passing a custom `pluginInfoResolver` to `createApp`, which in our example is declared like this: + +```ts title="pluginInfoResolver.ts" +import { createPluginInfoResolver } from '@backstage/frontend-plugin-api'; + +// It is recommended to keep the above module augmentation in this file too + +export const pluginInfoResolver: FrontendPluginInfoResolver = async ctx => { + // In our particular example app we assume that all plugin manifests are catalog-info.yaml files + const manifest = (await ctx.manifest?.()) as Entity | undefined; + + // Call the default resolver to populate common fields + const { info } = await ctx.defaultResolver({ + packageJson: await ctx.packageJson(), + manifest: manifest, + }); + + // In this example the catalog model has been extended with a metadata.slackChannel field + const slackChannel = manifest?.metadata?.slackChannel?.toString(); + + if (slackChannel) { + info.slackChannel = slackChannel; + info.links = [ + ...(info.links ?? []), + { + title: 'Slack Channel', + url: `https://our-workspace.enterprise.slack.com/archives/${slackChannel}`, + }, + ]; + } + + return { info }; +}; +``` + +And included in the app as follows: + +```ts title="App.tsx" +import { pluginInfoResolver } from './pluginInfoResolver'; + +const app = createApp({ + pluginInfoResolver, + // ... other options +}); +``` + +### Overriding Plugin Info + +Another way to customize the plugin info is to use the `app.pluginOverrides` static configuration key. These overrides are applied after the plugin info has been resolved as a final step before making it available to users. These overrides are particularly useful to override information in third-party plugins. For example, if your organization has an individual team that is responsible for the maintenance of the Software Catalog, you might configure the following override: + +```yaml +app: + pluginOverrides: + - match: + pluginId: catalog + info: + ownerEntityRefs: [catalog-owners] +``` + +You can match on both the `pluginId` and/or `packageName` of the plugin, although the `packageName` will only be supported if the plugin provides an loader for the `package.json` file. Using `//` you are also able to use a regex pattern for this matching. For example, if you wanted to override the owner for all plugins from the `@acme` namespace, you could do the following: + +```yaml +app: + pluginOverrides: + - match: + packageName: /@acme/.*/ + info: + ownerEntityRefs: [acme-owners] +``` diff --git a/docs/frontend-system/architecture/15-plugins.md b/docs/frontend-system/architecture/15-plugins.md index 825f2a3982..6d2b14d77f 100644 --- a/docs/frontend-system/architecture/15-plugins.md +++ b/docs/frontend-system/architecture/15-plugins.md @@ -53,6 +53,35 @@ These are the routes that the plugin exposes to the app. The `routes` option dec This is a list of feature flag declarations that your plugin provides to the app. This makes sure that the feature flags are correctly registered and can be toggled in the app. To read a feature flag you can use the feature flags [Utility API](../architecture/33-utility-apis.md), accessible via `featureFlagsApiRef`. +### `info` option + +This options is used to provide loaders for different sources of information about the plugin that may be useful to users and admins. The two available loaders are `packageJson` and `manifest`, and a plugin can use either or both as needed. The resulting information is available via the `info()` method on the plugin instance once it is installed in an app, but it is up to each app to decide how to derive the information from the provided sources. + +The `info.packageJson` loader **MUST** be used by all plugins that are implemented within their own package, and it should load the `package.json` file for the plugin package. Typical usage looks like this: + +```ts +export default createFrontendPlugin({ + pluginId: 'my-plugin', + info: { + packageJson: () => import('../package.json'), + }, + extensions: [...], +}); +``` + +The `info.manifest` loader is used to point to an opaque plugin manifest. This **MUST ONLY** be used by plugins that are intended for use within a single organization. Plugins that are published to an open package registry should **NOT** use this loader. The loader is useful for adding additional internal metadata associated with the plugin, and it is up to the Backstage app to decide how these manifests are parsed and used. The default manifest parser in an app created with `createApp` from `@backstage/frontend-defaults` is able to parse the default `catalog-info.yaml` format and built-in fields such as `metadata.links` and `spec.owner`. + +Typical usage looks like this: + +```ts +export default createFrontendPlugin({ + pluginId: '...', + info: { + manifest: () => import('../catalog-info.yaml'), + }, +}); +``` + ## Installing a Plugin in an App A plugin instance is considered a frontend feature and can be installed directly in any Backstage frontend app. See the [app documentation](./10-app.md) for more information about the different ways in which you can install new features in an app. From 350011ce97759ff98e2c63bfb73ed9e4ea2e3465 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 May 2025 17:31:53 +0200 Subject: [PATCH 09/10] small review fixes for plugin info Signed-off-by: Patrik Oldsberg --- .../src/wiring/createPluginInfoAttacher.test.ts | 2 +- .../frontend-app-api/src/wiring/createPluginInfoAttacher.ts | 2 +- .../frontend-plugin-api/src/wiring/createFrontendPlugin.ts | 4 +--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.test.ts b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.test.ts index 5595c04135..6c053f8aed 100644 --- a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.test.ts +++ b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.test.ts @@ -121,7 +121,7 @@ describe('createPluginInfoAttacher', () => { }, { title: 'Repository', - url: 'https://github.com/test/project/tree/master/packages/test', + url: 'https://github.com/test/project/tree/-/packages/test', }, ], }); diff --git a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts index 2b6f3a57f7..dc2b54f534 100644 --- a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts +++ b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts @@ -210,7 +210,7 @@ function resolvePackageInfo(packageJson?: JsonObject) { url.hostname === 'github.com' && typeof packageJson.repository.directory === 'string' ) { - const path = `${url.pathname}/tree/master/${packageJson.repository.directory}`; + const path = `${url.pathname}/tree/-/${packageJson.repository.directory}`; url.pathname = path.replaceAll('//', '/'); } diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts index 13c39a37e9..c16ac329cc 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts @@ -109,9 +109,7 @@ export interface FrontendPlugin< extensions: Array; /** - * Overrides to merge with the original plugin info. The merging is done for - * each top-level field. Setting a field explicitly to `undefined` will - * remove it. + * Overrides the original info loaders of the plugin one by one. */ info?: FrontendPluginInfoOptions; }): FrontendPlugin; From d44f943e6db73b625cdeb86538cfdc7ed2416fea Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 May 2025 23:40:17 +0200 Subject: [PATCH 10/10] Update docs/frontend-system/architecture/10-app.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Signed-off-by: Patrik Oldsberg --- docs/frontend-system/architecture/10-app.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/frontend-system/architecture/10-app.md b/docs/frontend-system/architecture/10-app.md index 2e1a23e3bc..fd85ff988b 100644 --- a/docs/frontend-system/architecture/10-app.md +++ b/docs/frontend-system/architecture/10-app.md @@ -88,7 +88,7 @@ By default the app will pick a few common fields from `package.json` files, and The default plugin info is intended as a base to build upon. As part of setting up an app you can both customize the way that the plugin info is resolved, as well as extend the `FrontendPluginInfo` type to include more information. -In order to extend the `FrontendPluginInfo` type you use [TypeScript module augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation). This makes it possible to extend the `FrontendPluginInfo` inferface with additional fields, which you can then add custom resolution logic for as well as access within the app. For example, you might add a `slackChannel` field as follows: +In order to extend the `FrontendPluginInfo` type you use [TypeScript module augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation). This makes it possible to extend the `FrontendPluginInfo` interface with additional fields, which you can then add custom resolution logic for as well as access within the app. For example, you might add a `slackChannel` field as follows: ```ts declare module '@backstage/frontend-plugin-api' {