Merge pull request #29068 from backstage/extension/factory-middleware

Introduce an extension factory middleware
This commit is contained in:
Eric Peterson
2025-03-07 15:41:49 +01:00
committed by GitHub
10 changed files with 319 additions and 20 deletions
+6
View File
@@ -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()`.
+12
View File
@@ -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
@@ -925,6 +925,24 @@ describe('instantiateAppNodeTree', () => {
});
});
it('should create an extension passed through an extension factory middleware', () => {
const attachments = new Map();
const instance = createAppNodeInstance({
node: makeNode(simpleExtension),
attachments,
apis: testApis,
*extensionFactoryMiddleware(originalFactory) {
const output = originalFactory({
config: { output: 'modified' },
});
yield* output;
},
});
expect(Array.from(instance.getDataRefs())).toEqual([testDataRef]);
expect(instance.getData(testDataRef)).toEqual('modified');
});
it('should refuse to create an extension with invalid config', () => {
expect(() =>
createAppNodeInstance({
@@ -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<T> = {
-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<string, AppNode[]>;
@@ -251,9 +254,11 @@ export function createAppNodeInstance(options: {
const extensionData = new Map<string, unknown>();
const extensionDataRefs = new Set<ExtensionDataRef<unknown>>();
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<string, unknown>();
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<AppNode>).instance = createAppNodeInstance({
extensionFactoryMiddleware,
node,
apis,
attachments: instantiatedAttachments,
@@ -29,7 +29,7 @@ import {
} from '@backstage/frontend-plugin-api';
import { screen, render } from '@testing-library/react';
import { createSpecializedApp } from './createSpecializedApp';
import { mockApis } from '@backstage/test-utils';
import { mockApis, TestApiRegistry } from '@backstage/test-utils';
import React from 'react';
import {
configApiRef,
@@ -37,7 +37,7 @@ import {
featureFlagsApiRef,
} from '@backstage/core-plugin-api';
import { MemoryRouter } from 'react-router-dom';
import { ApiProvider } from '@backstage/core-app-api';
import { ApiProvider, ConfigReader } from '@backstage/core-app-api';
describe('createSpecializedApp', () => {
it('should render the root app', () => {
@@ -166,6 +166,81 @@ describe('createSpecializedApp', () => {
render(app.createRoot());
expect(screen.getByText('flags:test=a,test=b')).toBeInTheDocument();
expect(app.apis).toMatchInlineSnapshot(`
ApiResolver {
"apis": Map {
"core.featureflags" => {
"getRegisteredFlags": [Function],
"registerFlag": [Function],
},
},
"factories": ApiFactoryRegistry {
"factories": Map {
"core.featureflags" => {
"factory": {
"api": ApiRefImpl {
"config": {
"id": "core.featureflags",
},
},
"deps": {},
"factory": [Function],
},
"priority": 10,
},
"core.app-tree" => {
"factory": {
"api": ApiRefImpl {
"config": {
"id": "core.app-tree",
},
},
"deps": {},
"factory": [Function],
},
"priority": 100,
},
"core.config" => {
"factory": {
"api": ApiRefImpl {
"config": {
"id": "core.config",
},
},
"deps": {},
"factory": [Function],
},
"priority": 100,
},
"core.route-resolution" => {
"factory": {
"api": ApiRefImpl {
"config": {
"id": "core.route-resolution",
},
},
"deps": {},
"factory": [Function],
},
"priority": 100,
},
"core.identity" => {
"factory": {
"api": ApiRefImpl {
"config": {
"id": "core.identity",
},
},
"deps": {},
"factory": [Function],
},
"priority": 100,
},
},
},
}
`);
});
it('should intitialize the APIs in the correct order to allow for overrides', () => {
@@ -237,10 +312,59 @@ describe('createSpecializedApp', () => {
expect(mockAnalyticsApi).toHaveBeenCalled();
});
it('should use provided apis', async () => {
const app = createSpecializedApp({
apis: TestApiRegistry.from([
configApiRef,
new ConfigReader({ anything: 'config' }),
]),
features: [
createFrontendPlugin({
id: 'test',
extensions: [
createExtension({
attachTo: { id: 'root', input: 'app' },
output: [coreExtensionData.reactElement],
factory: ({ apis }) => [
coreExtensionData.reactElement(
<div>
providedApis:
{apis.get(configApiRef)!.getString('anything')}
</div>,
),
],
}),
],
}),
],
});
render(app.createRoot());
expect(screen.getByText('providedApis:config')).toBeInTheDocument();
expect(app.apis).toMatchInlineSnapshot(`
TestApiRegistry {
"apis": Map {
"core.config" => ConfigReader {
"context": "mock-config",
"data": {
"anything": "config",
},
"fallback": undefined,
"filteredKeys": undefined,
"notifiedFilteredKeys": Set {},
"prefix": "",
},
},
}
`);
});
it('should make the app structure available through the AppTreeApi', async () => {
let appTreeApi: AppTreeApi | undefined = undefined;
createSpecializedApp({
const { tree } = createSpecializedApp({
features: [
createFrontendPlugin({
id: 'test',
@@ -265,6 +389,55 @@ describe('createSpecializedApp', () => {
]
</root>"
`);
expect(tree).toMatchInlineSnapshot(`
{
"nodes": Map {
"test" => {
"attachments": undefined,
"id": "test",
"output": [
"core.reactElement",
],
},
"root" => {
"attachments": {
"app": [
{
"attachments": undefined,
"id": "test",
"output": [
"core.reactElement",
],
},
],
},
"id": "root",
"output": [
"core.reactElement",
],
},
},
"orphans": [],
"root": {
"attachments": {
"app": [
{
"attachments": undefined,
"id": "test",
"output": [
"core.reactElement",
],
},
],
},
"id": "root",
"output": [
"core.reactElement",
],
},
}
`);
});
it('should support route bindings', async () => {
@@ -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 <AppComponent />;
},
@@ -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<RouteRef, RouteRef | undefined>;
routeObjects: BackstageRouteObject[];
};
/** @public */
export type ExtensionFactoryMiddleware = Parameters<
ExtensionDefinition['override']
>[0]['factory'];
+3
View File
@@ -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;
}
@@ -397,6 +397,46 @@ describe('createApp', () => {
expect(screen.queryByText('Custom app root element')).toBeNull();
});
it('should use a custom extensionFactoryMiddleware', async () => {
const app = createApp({
configLoader: async () => ({ config: mockApis.config() }),
features: [
appPlugin,
createFrontendPlugin({
id: 'test-plugin',
extensions: [
PageBlueprint.make({
name: 'test-page',
params: {
defaultPath: '/',
loader: async () => <>Test Page</>,
},
}),
],
}),
],
*extensionFactoryMiddleware(originalFactory, context) {
const output = originalFactory();
yield* output;
const element = output.get(coreExtensionData.reactElement);
if (element) {
yield coreExtensionData.reactElement(
<div data-testid={`wrapped(${context.node.spec.id})`}>
{element}
</div>,
);
}
},
});
await renderWithEffects(app.createRoot());
await expect(
screen.findByTestId('wrapped(page:test-plugin/test-page)'),
).resolves.toHaveTextContent('Test Page');
});
describe('modules', () => {
it('should be able to override extensions with a plugin extension override', async () => {
const mod = createFrontendModule({
@@ -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 };