chore: move to having a root extension with apis and app inputs

Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com>
Co-authored-by: Fredrik Adelöw <freben@users.noreply.github.com>
Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
blam
2024-08-21 16:55:29 +02:00
parent 142731f19e
commit a062f02556
12 changed files with 215 additions and 216 deletions
@@ -16,7 +16,6 @@
import React from 'react';
import {
ApiBlueprint,
ExtensionBoundary,
coreExtensionData,
createExtension,
@@ -25,9 +24,8 @@ import {
export const App = createExtension({
namespace: 'app',
attachTo: { id: 'root', input: 'default' }, // ignored
attachTo: { id: 'root', input: 'app' },
inputs: {
apis: createExtensionInput([ApiBlueprint.dataRefs.factory]),
root: createExtensionInput([coreExtensionData.reactElement], {
singleton: true,
}),
@@ -0,0 +1,35 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ApiBlueprint,
coreExtensionData,
createExtension,
createExtensionInput,
} from '@backstage/frontend-plugin-api';
export const Root = createExtension({
namespace: 'root',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
app: createExtensionInput([coreExtensionData.reactElement], {
singleton: true,
}),
apis: createExtensionInput([ApiBlueprint.dataRefs.factory]),
},
output: [],
factory: () => [],
});
@@ -1,96 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createExtension,
createExtensionOverrides,
createFrontendPlugin,
} from '@backstage/frontend-plugin-api';
import { MockConfigApi } from '@backstage/test-utils';
import { createAppTree } from './createAppTree';
const extBase = {
id: 'test',
attachTo: { id: 'app', input: 'root' },
output: [],
factory: () => [],
};
describe('createAppTree', () => {
it('throws an error when a app extension is parametrized', () => {
const config = new MockConfigApi({
app: {
extensions: [
{
app: {},
},
],
},
});
const features = [
createFrontendPlugin({
id: 'plugin',
extensions: [],
}),
];
expect(() =>
createAppTree({ features, config, builtinExtensions: [] }),
).toThrow("Configuration of the 'app' extension is forbidden");
});
it('throws an error when a app extension is overridden', () => {
const config = new MockConfigApi({});
const features = [
createExtensionOverrides({
extensions: [
createExtension({
name: 'app',
attachTo: { id: 'app/routes', input: 'route' },
inputs: {},
output: [],
factory: () => [],
}),
],
}),
];
expect(() =>
createAppTree({ features, config, builtinExtensions: [] }),
).toThrow(
"It is forbidden to override the following extension(s): 'app', which is done by one or more extension overrides",
);
});
it('throws an error when duplicated extension overrides are detected', () => {
expect(() =>
createAppTree({
features: [
createExtensionOverrides({
extensions: [
createExtension({ ...extBase, name: 'a' }),
createExtension({ ...extBase, name: 'a' }),
createExtension({ ...extBase, name: 'b' }),
],
}),
createExtensionOverrides({
extensions: [createExtension({ ...extBase, name: 'b' })],
}),
],
config: new MockConfigApi({}),
builtinExtensions: [],
}),
).toThrow('The following extensions had duplicate overrides: a, b');
});
});
@@ -1,45 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Extension, FrontendFeature } from '@backstage/frontend-plugin-api';
import { readAppExtensionsConfig } from './readAppExtensionsConfig';
import { resolveAppTree } from './resolveAppTree';
import { resolveAppNodeSpecs } from './resolveAppNodeSpecs';
import { AppTree } from '@backstage/frontend-plugin-api';
import { Config } from '@backstage/config';
import { instantiateAppNodeTree } from './instantiateAppNodeTree';
/** @internal */
export interface CreateAppTreeOptions {
features: FrontendFeature[];
builtinExtensions: Extension<any, any>[];
config: Config;
}
/** @internal */
export function createAppTree(options: CreateAppTreeOptions): AppTree {
const tree = resolveAppTree(
'app',
resolveAppNodeSpecs({
features: options.features,
builtinExtensions: options.builtinExtensions,
parameters: readAppExtensionsConfig(options.config),
forbidden: new Set(['app']),
}),
);
instantiateAppNodeTree(tree.root);
return tree;
}
@@ -16,6 +16,7 @@
import {
AnyExtensionDataRef,
ApiHolder,
ExtensionDataContainer,
ExtensionDataRef,
ExtensionInput,
@@ -242,9 +243,10 @@ function resolveV2Inputs(
/** @internal */
export function createAppNodeInstance(options: {
node: AppNode;
apis: ApiHolder;
attachments: ReadonlyMap<string, AppNode[]>;
}): AppNodeInstance {
const { node, attachments } = options;
const { node, apis, attachments } = options;
const { id, extension, config } = node.spec;
const extensionData = new Map<string, unknown>();
const extensionDataRefs = new Set<ExtensionDataRef<unknown>>();
@@ -268,6 +270,7 @@ export function createAppNodeInstance(options: {
if (internalExtension.version === 'v1') {
const namedOutputs = internalExtension.factory({
node,
apis,
config: parsedConfig,
inputs: resolveV1Inputs(internalExtension.inputs, attachments),
});
@@ -288,6 +291,7 @@ export function createAppNodeInstance(options: {
} else if (internalExtension.version === 'v2') {
const outputDataValues = internalExtension.factory({
node,
apis,
config: parsedConfig,
inputs: resolveV2Inputs(internalExtension.inputs, attachments),
});
@@ -349,7 +353,10 @@ export function createAppNodeInstance(options: {
* Starting at the provided node, instantiate all reachable nodes in the tree that have not been disabled.
* @internal
*/
export function instantiateAppNodeTree(rootNode: AppNode): void {
export function instantiateAppNodeTree(
rootNode: AppNode,
apis: ApiHolder,
): void {
function createInstance(node: AppNode): AppNodeInstance | undefined {
if (node.instance) {
return node.instance;
@@ -375,6 +382,7 @@ export function instantiateAppNodeTree(rootNode: AppNode): void {
(node as Mutable<AppNode>).instance = createAppNodeInstance({
node,
apis,
attachments: instantiatedAttachments,
});
@@ -32,7 +32,7 @@ import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/res
/** @internal */
export function resolveAppNodeSpecs(options: {
features?: FrontendFeature[];
builtinExtensions?: Extension<unknown>[];
builtinExtensions?: Extension<any, any>[];
parameters?: Array<ExtensionParameters>;
forbidden?: Set<string>;
}): AppNodeSpec[] {
@@ -19,10 +19,18 @@ import { ConfigReader } from '@backstage/config';
import {
ApiBlueprint,
AppTree,
AppTreeApi,
appTreeApiRef,
coreExtensionData,
FrontendFeature,
RouteRef,
ExternalRouteRef,
SubRouteRef,
AnyRouteRefParams,
RouteFunc,
RouteResolutionApiResolveOptions,
RouteResolutionApi,
createApiFactory,
routeResolutionApiRef,
} from '@backstage/frontend-plugin-api';
import { App } from '../extensions/App';
@@ -73,7 +81,6 @@ import { CreateAppRouteBinder } from '../routing';
import { RouteResolver } from '../routing/RouteResolver';
import { resolveRouteBindings } from '../routing/resolveRouteBindings';
import { collectRouteIds } from '../routing/collectRouteIds';
import { createAppTree } from '../tree';
import {
DefaultProgressComponent,
DefaultErrorBoundaryComponent,
@@ -93,12 +100,20 @@ import { TranslationsApi } from '../extensions/TranslationsApi';
import { ComponentsApi } from '../extensions/ComponentsApi';
import { AppLanguageApi } from '../extensions/AppLanguageApi';
import { FeatureFlagsApi } from '../extensions/FeatureFlagsApi';
import { Root } from '../extensions/Root';
import { resolveAppTree } from '../tree/resolveAppTree';
import { resolveAppNodeSpecs } from '../tree/resolveAppNodeSpecs';
import { readAppExtensionsConfig } from '../tree/readAppExtensionsConfig';
import { instantiateAppNodeTree } from '../tree/instantiateAppNodeTree';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { ApiRegistry } from '../../../core-app-api/src/apis/system/ApiRegistry';
const DefaultApis = defaultApis.map(factory =>
ApiBlueprint.make({ namespace: factory.api.id, params: { factory } }),
);
export const builtinExtensions = [
Root,
App,
AppRoot,
AppRoutes,
@@ -230,6 +245,70 @@ export function createApp(options?: {
};
}
// Helps delay callers from reaching out to the API before the app tree has been materialized
class AppTreeApiProxy implements AppTreeApi {
#safeToUse: boolean = false;
constructor(private readonly tree: AppTree) {}
getTree() {
if (!this.#safeToUse) {
throw new Error(
`You can't access the AppTreeApi during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
);
}
return { tree: this.tree };
}
initialize() {
this.#safeToUse = true;
}
}
// Helps delay callers from reaching out to the API before the app tree has been materialized
class RouteResolverProxy implements RouteResolutionApi {
#delegate: RouteResolutionApi | undefined;
constructor(
private readonly tree: AppTree,
private readonly routeBindings: Map<
ExternalRouteRef,
RouteRef | SubRouteRef
>,
private readonly basePath: string,
) {}
resolve<TParams extends AnyRouteRefParams>(
anyRouteRef:
| RouteRef<TParams>
| SubRouteRef<TParams>
| ExternalRouteRef<TParams>,
options?: RouteResolutionApiResolveOptions,
): RouteFunc<TParams> | undefined {
if (!this.#delegate) {
throw new Error(
`You can't access the RouteResolver during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
);
}
return this.#delegate.resolve(anyRouteRef, options);
}
initialize() {
const routeInfo = extractRouteInfoFromAppNode(this.tree.root);
this.#delegate = new RouteResolver(
routeInfo.routePaths,
routeInfo.routeParents,
routeInfo.routeObjects,
this.routeBindings,
this.basePath,
);
return routeInfo;
}
}
/**
* Synchronous version of {@link createApp}, expecting all features and
* config to have been loaded already.
@@ -248,32 +327,46 @@ export function createSpecializedApp(options?: {
const features = deduplicateFeatures(duplicatedFeatures);
const tree = createAppTree({
features,
builtinExtensions,
config,
});
const tree = resolveAppTree(
'root',
resolveAppNodeSpecs({
features,
builtinExtensions,
parameters: readAppExtensionsConfig(config),
forbidden: new Set(['root']),
}),
);
const routeInfo = extractRouteInfoFromAppNode(tree.root);
const routeBindings = resolveRouteBindings(
options?.bindRoutes,
config,
collectRouteIds(features),
const factories = createApiFactories({ tree });
const appTreeApi = new AppTreeApiProxy(tree);
const routeResolver = new RouteResolverProxy(
tree,
resolveRouteBindings(
options?.bindRoutes,
config,
collectRouteIds(features),
),
getBasePath(config),
);
const appIdentityProxy = new AppIdentityProxy();
const apiHolder = createApiHolder(
tree,
config,
appIdentityProxy,
new RouteResolver(
routeInfo.routePaths,
routeInfo.routeParents,
routeInfo.routeObjects,
routeBindings,
getBasePath(config),
),
);
const apiHolder = createApiHolder({
factories,
staticFactories: [
createApiFactory(appTreeApiRef, appTreeApi),
createApiFactory(configApiRef, config),
createApiFactory(routeResolutionApiRef, routeResolver),
createApiFactory(identityApiRef, appIdentityProxy),
],
});
for (const appNode of tree.root.edges.attachments.get('app') ?? []) {
instantiateAppNodeTree(appNode, apiHolder);
}
const routeInfo = routeResolver.initialize();
appTreeApi.initialize();
if (isProtectedApp()) {
const discoveryApi = apiHolder.get(discoveryApiRef);
@@ -310,7 +403,9 @@ export function createSpecializedApp(options?: {
}
}
const rootEl = tree.root.instance!.getData(coreExtensionData.reactElement);
const rootEl = tree.root.edges.attachments
.get('app')![0]
.instance!.getData(coreExtensionData.reactElement);
const AppComponent = () => (
<ApiProvider apis={apiHolder}>
@@ -331,49 +426,37 @@ export function createSpecializedApp(options?: {
};
}
function createApiHolder(
tree: AppTree,
configApi: ConfigApi,
appIdentityProxy: AppIdentityProxy,
routeResolutionApi: RouteResolutionApi,
): ApiHolder {
function createApiFactories(options: { tree: AppTree }): AnyApiFactory[] {
const emptyApiHolder = ApiRegistry.from([]);
const factories = new Array<AnyApiFactory>();
for (const apiNode of options.tree.root.edges.attachments.get('apis') ?? []) {
instantiateAppNodeTree(apiNode, emptyApiHolder);
const apiFactory = apiNode.instance?.getData(ApiBlueprint.dataRefs.factory);
if (!apiFactory) {
throw new Error(
`No API factory found in for extension ${apiNode.spec.id}`,
);
}
factories.push(apiFactory);
}
return factories;
}
function createApiHolder(options: {
factories: AnyApiFactory[];
staticFactories: AnyApiFactory[];
}): ApiHolder {
const factoryRegistry = new ApiFactoryRegistry();
const pluginApis =
tree.root.edges.attachments
.get('apis')
?.map(e => e.instance?.getData(ApiBlueprint.dataRefs.factory))
.filter((x): x is AnyApiFactory => !!x) ?? [];
for (const factory of pluginApis) {
for (const factory of options.factories) {
factoryRegistry.register('default', factory);
}
factoryRegistry.register('static', {
api: identityApiRef,
deps: {},
factory: () => appIdentityProxy,
});
factoryRegistry.register('static', {
api: appTreeApiRef,
deps: {},
factory: () => ({
getTree: () => ({ tree }),
}),
});
factoryRegistry.register('static', {
api: routeResolutionApiRef,
deps: {},
factory: () => routeResolutionApi,
});
factoryRegistry.register('static', {
api: configApiRef,
deps: {},
factory: () => configApi,
});
for (const factory of options.staticFactories) {
factoryRegistry.register('static', factory);
}
ApiResolver.validateFactories(factoryRegistry, factoryRegistry.getAllApis());
@@ -28,7 +28,7 @@ const factoryDataRef = createExtensionDataRef<AnyApiFactory>().with({
*/
export const ApiBlueprint = createExtensionBlueprint({
kind: 'api',
attachTo: { id: 'app', input: 'apis' },
attachTo: { id: 'root', input: 'apis' },
output: [factoryDataRef],
dataRefs: {
factory: factoryDataRef,
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { AppNode } from '../apis';
import { ApiHolder, AppNode } from '../apis';
import { PortableSchema } from '../schema';
import { Expand } from '../types';
import {
@@ -133,6 +133,7 @@ export type CreateExtensionOptions<
};
factory(context: {
node: AppNode;
apis: ApiHolder;
config: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
};
@@ -203,6 +204,7 @@ export interface ExtensionDefinition<
}) => ExtensionDataContainer<UOutput>,
context: {
node: AppNode;
apis: ApiHolder;
config: TConfig & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
@@ -273,6 +275,7 @@ export type InternalExtensionDefinition<
};
factory(context: {
node: AppNode;
apis: ApiHolder;
config: TConfig;
inputs: {
[inputName in string]: unknown;
@@ -292,6 +295,7 @@ export type InternalExtensionDefinition<
readonly output: Array<AnyExtensionDataRef>;
factory(context: {
node: AppNode;
apis: ApiHolder;
config: TConfig;
inputs: ResolvedExtensionInputs<{
[inputName in string]: ExtensionInput<
@@ -437,10 +441,11 @@ export function createExtension<
},
}
: undefined,
factory: ({ node, config, inputs }) => {
factory: ({ node, apis, config, inputs }) => {
if (!overrideOptions.factory) {
return newOptions.factory({
node,
apis,
config: config as any,
inputs: inputs as any,
});
@@ -450,6 +455,7 @@ export function createExtension<
return createExtensionDataContainer<UOutput>(
newOptions.factory({
node,
apis,
config: (innerContext?.config ?? config) as any,
inputs: resolveInputOverrides(
newOptions.inputs,
@@ -462,6 +468,7 @@ export function createExtension<
},
{
node,
apis,
config: config as any,
inputs: inputs as any,
},
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { AppNode } from '../apis';
import { ApiHolder, AppNode } from '../apis';
import { Expand } from '../types';
import {
ExtensionDefinition,
@@ -70,6 +70,7 @@ export type CreateExtensionBlueprintOptions<
params: TParams,
context: {
node: AppNode;
apis: ApiHolder;
config: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
};
@@ -172,6 +173,7 @@ export interface ExtensionBlueprint<
) => ExtensionDataContainer<UOutput>,
context: {
node: AppNode;
apis: ApiHolder;
config: TConfig & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
@@ -300,11 +302,12 @@ export function createExtensionBlueprint<
},
}
: undefined,
factory: ({ node, config, inputs }) => {
factory: ({ node, config, inputs, apis }) => {
return args.factory(
(innerParams, innerContext) => {
return createExtensionDataContainer<UOutput>(
options.factory(innerParams, {
apis,
node,
config: (innerContext?.config ?? config) as any,
inputs: resolveInputOverrides(
@@ -317,6 +320,7 @@ export function createExtensionBlueprint<
);
},
{
apis,
node,
config: config as any,
inputs: inputs as any,
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { AppNode } from '../apis';
import { ApiHolder, AppNode } from '../apis';
import {
ExtensionDefinition,
ResolvedExtensionInputs,
@@ -57,6 +57,7 @@ export type InternalExtension<TConfig, TConfigInput> = Extension<
[name in string]: AnyExtensionDataRef;
};
factory(context: {
apis: ApiHolder;
node: AppNode;
config: TConfig;
inputs: {
@@ -76,6 +77,7 @@ export type InternalExtension<TConfig, TConfigInput> = Extension<
};
readonly output: Array<AnyExtensionDataRef>;
factory(options: {
apis: ApiHolder;
node: AppNode;
config: TConfig;
inputs: ResolvedExtensionInputs<{
@@ -50,6 +50,9 @@ import { instantiateAppNodeTree } from '../../../frontend-app-api/src/tree/insta
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { readAppExtensionsConfig } from '../../../frontend-app-api/src/tree/readAppExtensionsConfig';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { ApiRegistry } from '../../../core-app-api/src/apis/system/ApiRegistry';
const NavItem = (props: {
routeRef: RouteRef<undefined>;
title: string;
@@ -320,7 +323,7 @@ export class ExtensionTester<UOutput extends AnyExtensionDataRef> {
}),
);
instantiateAppNodeTree(tree.root);
instantiateAppNodeTree(tree.root, ApiRegistry.from([]));
this.#tree = tree;