From 9e3868fbb08edee84eafeba6bd9cddf42480d65b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 14 May 2025 11:51:55 +0200 Subject: [PATCH] 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,