Merge pull request #29068 from backstage/extension/factory-middleware
Introduce an extension factory middleware
This commit is contained in:
@@ -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()`.
|
||||
@@ -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'];
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user