From 4d18b55ecc01341bb6252534a53e2782d23380be Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 4 Mar 2025 17:01:03 +0100 Subject: [PATCH] Initial implementation of an extension factory middleware Signed-off-by: Eric Peterson --- .changeset/catch-you-mamma.md | 6 +++ packages/frontend-app-api/report.api.md | 12 ++++++ .../src/tree/instantiateAppNodeTree.ts | 33 ++++++++++++++-- .../src/wiring/createSpecializedApp.tsx | 38 +++++++++++++------ packages/frontend-app-api/src/wiring/types.ts | 7 +++- packages/frontend-defaults/report.api.md | 3 ++ packages/frontend-defaults/src/createApp.tsx | 3 ++ 7 files changed, 85 insertions(+), 17 deletions(-) create mode 100644 .changeset/catch-you-mamma.md diff --git a/.changeset/catch-you-mamma.md b/.changeset/catch-you-mamma.md new file mode 100644 index 0000000000..342e695cab --- /dev/null +++ b/.changeset/catch-you-mamma.md @@ -0,0 +1,6 @@ +--- +'@backstage/frontend-app-api': patch +'@backstage/frontend-defaults': patch +--- + +It's now possible to provide a middleware that wraps all extension factories by passing an `extensionFactoryMiddleware` to either `createApp()` or `createSpecializedApp()`. diff --git a/packages/frontend-app-api/report.api.md b/packages/frontend-app-api/report.api.md index 4216f1f823..53ecb56585 100644 --- a/packages/frontend-app-api/report.api.md +++ b/packages/frontend-app-api/report.api.md @@ -3,7 +3,10 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { ApiHolder } from '@backstage/core-plugin-api'; +import { AppTree } from '@backstage/frontend-plugin-api'; import { ConfigApi } from '@backstage/core-plugin-api'; +import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/frontend-plugin-api'; import { FrontendModule } from '@backstage/frontend-plugin-api'; import { FrontendPlugin } from '@backstage/frontend-plugin-api'; @@ -29,10 +32,19 @@ export function createSpecializedApp(options?: { features?: FrontendFeature[]; config?: ConfigApi; bindRoutes?(context: { bind: CreateAppRouteBinder }): void; + apis?: ApiHolder; + extensionFactoryMiddleware?: ExtensionFactoryMiddleware; }): { + apis: ApiHolder; createRoot(): JSX_2.Element; + tree: AppTree; }; +// @public (undocumented) +export type ExtensionFactoryMiddleware = Parameters< + ExtensionDefinition['override'] +>[0]['factory']; + // @public (undocumented) export type FrontendFeature = | FrontendPlugin diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts index cc17bfae27..21c61ba1b2 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts @@ -26,6 +26,8 @@ import mapValues from 'lodash/mapValues'; import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition'; +import { ExtensionFactoryMiddleware } from '../wiring'; +import { createExtensionDataContainer } from '@internal/frontend'; type Mutable = { -readonly [P in keyof T]: T[P]; @@ -242,6 +244,7 @@ function resolveV2Inputs( /** @internal */ export function createAppNodeInstance(options: { + extensionFactoryMiddleware?: ExtensionFactoryMiddleware; node: AppNode; apis: ApiHolder; attachments: ReadonlyMap; @@ -251,9 +254,11 @@ export function createAppNodeInstance(options: { const extensionData = new Map(); const extensionDataRefs = new Set>(); - let parsedConfig: unknown; + let parsedConfig: { [x: string]: any }; try { - parsedConfig = extension.configSchema?.parse(config ?? {}); + parsedConfig = extension.configSchema?.parse(config ?? {}) as { + [x: string]: any; + }; } catch (e) { throw new Error( `Invalid configuration for extension '${id}'; caused by ${e}`, @@ -289,12 +294,30 @@ export function createAppNodeInstance(options: { extensionDataRefs.add(ref); } } else if (internalExtension.version === 'v2') { - const outputDataValues = internalExtension.factory({ + const context = { node, apis, config: parsedConfig, inputs: resolveV2Inputs(internalExtension.inputs, attachments), - }); + }; + const outputDataValues = options.extensionFactoryMiddleware + ? createExtensionDataContainer( + options.extensionFactoryMiddleware( + ({ config: configOverride } = {}) => + createExtensionDataContainer( + internalExtension.factory( + configOverride + ? { + ...context, + config: configOverride, + } + : context, + ), + ), + context, + ), + ) + : internalExtension.factory(context); const outputDataMap = new Map(); for (const value of outputDataValues) { @@ -356,6 +379,7 @@ export function createAppNodeInstance(options: { export function instantiateAppNodeTree( rootNode: AppNode, apis: ApiHolder, + extensionFactoryMiddleware?: ExtensionFactoryMiddleware, ): void { function createInstance(node: AppNode): AppNodeInstance | undefined { if (node.instance) { @@ -381,6 +405,7 @@ export function instantiateAppNodeTree( } (node as Mutable).instance = createAppNodeInstance({ + extensionFactoryMiddleware, node, apis, attachments: instantiatedAttachments, diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index 5616346d75..785f12045e 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -69,7 +69,11 @@ 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 { + ExtensionFactoryMiddleware, + FrontendFeature, + RouteInfo, +} from './types'; import { matchRoutes } from 'react-router-dom'; function deduplicateFeatures( @@ -198,7 +202,9 @@ export function createSpecializedApp(options?: { features?: FrontendFeature[]; config?: ConfigApi; bindRoutes?(context: { bind: CreateAppRouteBinder }): void; -}): { createRoot(): JSX.Element } { + apis?: ApiHolder; + extensionFactoryMiddleware?: ExtensionFactoryMiddleware; +}): { apis: ApiHolder; createRoot(): JSX.Element; tree: AppTree } { const config = options?.config ?? new ConfigReader({}, 'empty-config'); const features = deduplicateFeatures(options?.features ?? []); @@ -227,15 +233,17 @@ export function createSpecializedApp(options?: { ); const appIdentityProxy = new AppIdentityProxy(); - const apiHolder = createApiHolder({ - factories, - staticFactories: [ - createApiFactory(appTreeApiRef, appTreeApi), - createApiFactory(configApiRef, config), - createApiFactory(routeResolutionApiRef, routeResolutionApi), - createApiFactory(identityApiRef, appIdentityProxy), - ], - }); + const apiHolder = + options?.apis ?? + createApiHolder({ + factories, + staticFactories: [ + createApiFactory(appTreeApiRef, appTreeApi), + createApiFactory(configApiRef, config), + createApiFactory(routeResolutionApiRef, routeResolutionApi), + createApiFactory(identityApiRef, appIdentityProxy), + ], + }); const featureFlagApi = apiHolder.get(featureFlagsApiRef); if (featureFlagApi) { @@ -260,7 +268,11 @@ export function createSpecializedApp(options?: { } // Now instantiate the entire tree, which will skip anything that's already been instantiated - instantiateAppNodeTree(tree.root, apiHolder); + instantiateAppNodeTree( + tree.root, + apiHolder, + options?.extensionFactoryMiddleware, + ); const routeInfo = extractRouteInfoFromAppNode(tree.root); @@ -272,6 +284,8 @@ export function createSpecializedApp(options?: { const AppComponent = () => rootEl; return { + apis: apiHolder, + tree, createRoot() { return ; }, diff --git a/packages/frontend-app-api/src/wiring/types.ts b/packages/frontend-app-api/src/wiring/types.ts index 18d9c51d0e..d7eca83e0a 100644 --- a/packages/frontend-app-api/src/wiring/types.ts +++ b/packages/frontend-app-api/src/wiring/types.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { RouteRef } from '@backstage/frontend-plugin-api'; +import { ExtensionDefinition, RouteRef } from '@backstage/frontend-plugin-api'; import { FrontendModule, FrontendPlugin } from '@backstage/frontend-plugin-api'; import { BackstageRouteObject } from '../routing/types'; @@ -31,3 +31,8 @@ export type RouteInfo = { routeParents: Map; routeObjects: BackstageRouteObject[]; }; + +/** @public */ +export type ExtensionFactoryMiddleware = Parameters< + ExtensionDefinition['override'] +>[0]['factory']; diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md index b99232b784..1c0ee7e6a7 100644 --- a/packages/frontend-defaults/report.api.md +++ b/packages/frontend-defaults/report.api.md @@ -5,6 +5,7 @@ ```ts import { ConfigApi } from '@backstage/frontend-plugin-api'; import { CreateAppRouteBinder } from '@backstage/frontend-app-api'; +import { ExtensionFactoryMiddleware } from '@backstage/frontend-app-api'; import { FrontendFeature } from '@backstage/frontend-app-api'; import { JSX as JSX_2 } from 'react'; import { default as React_2 } from 'react'; @@ -32,6 +33,8 @@ export interface CreateAppOptions { config: ConfigApi; }>; // (undocumented) + extensionFactoryMiddleware?: ExtensionFactoryMiddleware; + // (undocumented) features?: (FrontendFeature | CreateAppFeatureLoader)[]; loadingComponent?: ReactNode; } diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index 79c9eb4a16..7ff59ca262 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -26,6 +26,7 @@ import { ConfigReader } from '@backstage/config'; import appPlugin from '@backstage/plugin-app'; import { CreateAppRouteBinder, + ExtensionFactoryMiddleware, FrontendFeature, createSpecializedApp, } from '@backstage/frontend-app-api'; @@ -65,6 +66,7 @@ export interface CreateAppOptions { * If set to "null" then no loading fallback component is rendered. * */ loadingComponent?: ReactNode; + extensionFactoryMiddleware?: ExtensionFactoryMiddleware; } /** @@ -111,6 +113,7 @@ export function createApp(options?: CreateAppOptions): { config, features: [appPlugin, ...discoveredFeatures, ...providedFeatures], bindRoutes: options?.bindRoutes, + extensionFactoryMiddleware: options?.extensionFactoryMiddleware, }).createRoot(); return { default: () => app };