frontend-app-api: support array of extension factory middlewares

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-03-10 09:53:14 +01:00
parent abcdf444e5
commit e23f5e0762
11 changed files with 180 additions and 42 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': patch
---
Added new `ExtensionMiddlewareFactory` type.
+4 -7
View File
@@ -6,7 +6,7 @@
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 { ExtensionFactoryMiddleware } 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';
@@ -32,17 +32,14 @@ export function createSpecializedApp(options?: {
config?: ConfigApi;
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
apis?: ApiHolder;
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
extensionFactoryMiddleware?:
| ExtensionFactoryMiddleware
| ExtensionFactoryMiddleware[];
}): {
apis: ApiHolder;
tree: AppTree;
};
// @public (undocumented)
export type ExtensionFactoryMiddleware = Parameters<
ExtensionDefinition['override']
>[0]['factory'];
// @public (undocumented)
export type FrontendFeature =
| FrontendPlugin
@@ -19,6 +19,7 @@ import {
ApiHolder,
ExtensionDataContainer,
ExtensionDataRef,
ExtensionFactoryMiddleware,
ExtensionInput,
ResolvedExtensionInputs,
} from '@backstage/frontend-plugin-api';
@@ -26,7 +27,6 @@ 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> = {
@@ -302,20 +302,16 @@ export function createAppNodeInstance(options: {
};
const outputDataValues = options.extensionFactoryMiddleware
? createExtensionDataContainer(
options.extensionFactoryMiddleware(
({ config: configOverride } = {}) =>
createExtensionDataContainer(
internalExtension.factory(
configOverride
? {
...context,
config: configOverride,
}
: context,
),
),
context,
),
options.extensionFactoryMiddleware(overrideContext => {
return createExtensionDataContainer(
internalExtension.factory({
node: context.node,
apis: context.apis,
inputs: context.inputs,
config: overrideContext?.config ?? context.config,
}),
);
}, context),
)
: internalExtension.factory(context);
@@ -26,6 +26,7 @@ import {
createExtensionInput,
useRouteRef,
analyticsApiRef,
createExtensionDataRef,
} from '@backstage/frontend-plugin-api';
import { screen, render } from '@testing-library/react';
import { createSpecializedApp } from './createSpecializedApp';
@@ -603,4 +604,71 @@ describe('createSpecializedApp', () => {
</root>"
`);
});
it('should apply multiple middlewares in order', () => {
const textDataRef = createExtensionDataRef<string>().with({ id: 'text' });
const app = createSpecializedApp({
features: [
createFrontendPlugin({
id: 'test',
extensions: [
createExtension({
attachTo: { id: 'root', input: 'app' },
inputs: {
text: createExtensionInput([textDataRef], {
singleton: true,
}),
},
output: [coreExtensionData.reactElement],
factory: ({ inputs }) => [
coreExtensionData.reactElement(
<>{inputs.text.get(textDataRef)}</>,
),
],
}),
createExtension({
name: 'child',
attachTo: { id: 'test', input: 'text' },
config: {
schema: {
text: z => z.string().default('test'),
},
},
output: [textDataRef],
factory: ({ config }) => [textDataRef(config.text)],
}),
],
}),
],
extensionFactoryMiddleware: [
function* middleware(originalFactory, { config }) {
const result = originalFactory({
config: config && { text: `1-${config.text}` },
});
yield* result;
const el = result.get(textDataRef);
if (el) {
yield textDataRef(`${el}-1`);
}
},
function* middleware(originalFactory, { config }) {
const result = originalFactory({
config: config && { text: `2-${config.text}` },
});
yield* result;
const el = result.get(textDataRef);
if (el) {
yield textDataRef(`${el}-2`);
}
},
],
});
const root = app.tree.root.instance!.getData(
coreExtensionData.reactElement,
);
expect(render(root).container.textContent).toBe('1-2-test-1-2');
});
});
@@ -30,6 +30,7 @@ import {
createApiFactory,
routeResolutionApiRef,
AppNode,
ExtensionFactoryMiddleware,
} from '@backstage/frontend-plugin-api';
import {
AnyApiFactory,
@@ -40,10 +41,16 @@ import {
identityApiRef,
} from '@backstage/core-plugin-api';
import { ApiFactoryRegistry, ApiResolver } from '@backstage/core-app-api';
import { OpaqueFrontendPlugin } from '@internal/frontend';
import {
createExtensionDataContainer,
OpaqueFrontendPlugin,
} from '@internal/frontend';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
import {
resolveExtensionDefinition,
toInternalExtension,
} from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
import { extractRouteInfoFromAppNode } from '../routing/extractRouteInfoFromAppNode';
@@ -67,11 +74,7 @@ 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 {
ExtensionFactoryMiddleware,
FrontendFeature,
RouteInfo,
} from './types';
import { FrontendFeature, RouteInfo } from './types';
import { matchRoutes } from 'react-router-dom';
function deduplicateFeatures(
@@ -202,7 +205,9 @@ export function createSpecializedApp(options?: {
config?: ConfigApi;
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
apis?: ApiHolder;
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
extensionFactoryMiddleware?:
| ExtensionFactoryMiddleware
| ExtensionFactoryMiddleware[];
}): { apis: ApiHolder; tree: AppTree } {
const config = options?.config ?? new ConfigReader({}, 'empty-config');
const features = deduplicateFeatures(options?.features ?? []);
@@ -267,7 +272,11 @@ export function createSpecializedApp(options?: {
}
// Now instantiate the entire tree, which will skip anything that's already been instantiated
instantiateAppNodeTree(tree.root, apis, options?.extensionFactoryMiddleware);
instantiateAppNodeTree(
tree.root,
apis,
mergeExtensionFactoryMiddleware(options?.extensionFactoryMiddleware),
);
const routeInfo = extractRouteInfoFromAppNode(tree.root);
@@ -313,3 +322,37 @@ function createApiHolder(options: {
return new ApiResolver(factoryRegistry);
}
function mergeExtensionFactoryMiddleware(
middlewares?: ExtensionFactoryMiddleware | ExtensionFactoryMiddleware[],
): ExtensionFactoryMiddleware | undefined {
if (!middlewares) {
return undefined;
}
if (!Array.isArray(middlewares)) {
return middlewares;
}
if (middlewares.length <= 1) {
return middlewares[0];
}
return middlewares.reduce((prev, next) => {
if (!prev || !next) {
return prev ?? next;
}
return (orig, ctx) => {
const internalExt = toInternalExtension(ctx.node.spec.extension);
if (internalExt.version !== 'v2') {
return orig();
}
return next(ctxOverrides => {
return createExtensionDataContainer(
prev(orig, {
node: ctx.node,
apis: ctx.apis,
config: ctxOverrides?.config ?? ctx.config,
}),
);
}, ctx);
};
});
}
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ExtensionDefinition, RouteRef } from '@backstage/frontend-plugin-api';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { FrontendModule, FrontendPlugin } from '@backstage/frontend-plugin-api';
import { BackstageRouteObject } from '../routing/types';
@@ -31,8 +31,3 @@ export type RouteInfo = {
routeParents: Map<RouteRef, RouteRef | undefined>;
routeObjects: BackstageRouteObject[];
};
/** @public */
export type ExtensionFactoryMiddleware = Parameters<
ExtensionDefinition['override']
>[0]['factory'];
+4 -2
View File
@@ -5,7 +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 { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api';
import { FrontendFeature } from '@backstage/frontend-app-api';
import { JSX as JSX_2 } from 'react';
import { default as React_2 } from 'react';
@@ -33,7 +33,9 @@ export interface CreateAppOptions {
config: ConfigApi;
}>;
// (undocumented)
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
extensionFactoryMiddleware?:
| ExtensionFactoryMiddleware
| ExtensionFactoryMiddleware[];
// (undocumented)
features?: (FrontendFeature | CreateAppFeatureLoader)[];
loadingComponent?: ReactNode;
+8 -3
View File
@@ -15,7 +15,11 @@
*/
import React, { JSX, ReactNode } from 'react';
import { ConfigApi, coreExtensionData } from '@backstage/frontend-plugin-api';
import {
ConfigApi,
coreExtensionData,
ExtensionFactoryMiddleware,
} from '@backstage/frontend-plugin-api';
import { stringifyError } from '@backstage/errors';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { defaultConfigLoaderSync } from '../../core-app-api/src/app/defaultConfigLoader';
@@ -26,7 +30,6 @@ import { ConfigReader } from '@backstage/config';
import appPlugin from '@backstage/plugin-app';
import {
CreateAppRouteBinder,
ExtensionFactoryMiddleware,
FrontendFeature,
createSpecializedApp,
} from '@backstage/frontend-app-api';
@@ -66,7 +69,9 @@ export interface CreateAppOptions {
* If set to "null" then no loading fallback component is rendered. *
*/
loadingComponent?: ReactNode;
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
extensionFactoryMiddleware?:
| ExtensionFactoryMiddleware
| ExtensionFactoryMiddleware[];
}
/**
@@ -1175,6 +1175,18 @@ export type ExtensionDefinitionParameters = {
params?: object;
};
// @public (undocumented)
export type ExtensionFactoryMiddleware = (
originalFactory: (contextOverrides?: {
config?: JsonObject;
}) => ExtensionDataContainer<AnyExtensionDataRef>,
context: {
node: AppNode;
apis: ApiHolder;
config?: JsonObject;
},
) => Iterable<ExtensionDataValue<any, any>>;
// @public (undocumented)
export interface ExtensionInput<
UExtensionData extends ExtensionDataRef<
@@ -54,6 +54,7 @@ export {
type ExtensionOverrides,
type FeatureFlagConfig,
type FrontendFeature,
type ExtensionFactoryMiddleware,
} from './types';
export {
type CreateExtensionBlueprintOptions,
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { JsonObject } from '@backstage/types';
import { ExternalRouteRef, RouteRef, SubRouteRef } from '../routing';
import { ExtensionDefinition } from './createExtension';
import {
@@ -22,6 +23,7 @@ import {
ExtensionDataValue,
} from './createExtensionDataRef';
import { FrontendPlugin } from './createFrontendPlugin';
import { ApiHolder, AppNode } from '../apis';
/**
* Feature flag configuration.
@@ -78,3 +80,15 @@ export type ExtensionDataContainer<UExtensionData extends AnyExtensionDataRef> =
: IData
: never;
};
/** @public */
export type ExtensionFactoryMiddleware = (
originalFactory: (contextOverrides?: {
config?: JsonObject;
}) => ExtensionDataContainer<AnyExtensionDataRef>,
context: {
node: AppNode;
apis: ApiHolder;
config?: JsonObject;
},
) => Iterable<ExtensionDataValue<any, any>>;