backend-plugin-api: add createBackendFeatureLoader

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-08-05 01:26:08 +02:00
parent 8b45adcadd
commit 6061061a81
6 changed files with 349 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-plugin-api': patch
---
Added `createBackendFeatureLoader`, which can be used to create an installable backend feature that can in turn load in additional backend features in a dynamic way.
+41
View File
@@ -213,6 +213,47 @@ export namespace coreServices {
identity: ServiceRef<IdentityService, 'plugin'>;
}
// @public
export function createBackendFeatureLoader<
TDeps extends {
[name in string]: unknown;
},
>(options: CreateBackendFeatureLoaderOptions<TDeps>): BackendFeature;
// @public
export interface CreateBackendFeatureLoaderOptions<
TDeps extends {
[name in string]: unknown;
},
> {
// (undocumented)
deps?: {
[name in keyof TDeps]: ServiceRef<TDeps[name], 'root'>;
};
// (undocumented)
loader(deps: TDeps):
| Iterable<
| BackendFeature
| Promise<{
default: BackendFeature;
}>
>
| Promise<
Iterable<
| BackendFeature
| Promise<{
default: BackendFeature;
}>
>
>
| AsyncIterable<
| BackendFeature
| {
default: BackendFeature;
}
>;
}
// @public
export function createBackendModule(
options: CreateBackendModuleOptions,
@@ -0,0 +1,213 @@
/*
* Copyright 2022 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 { coreServices, createServiceFactory } from '../services';
import { BackendFeature } from '../types';
import { createBackendFeatureLoader } from './createBackendFeatureLoader';
import { createBackendPlugin } from './createBackendPlugin';
import {
InternalBackendFeature,
InternalBackendFeatureLoaderRegistration,
} from './types';
describe('createBackendFeatureLoader', () => {
it('should create an empty feature loader', () => {
const result = createBackendFeatureLoader({
deps: {},
loader: () => [],
});
const plugin = result as unknown as InternalBackendFeature;
expect(plugin.$$type).toEqual('@backstage/BackendFeature');
expect(plugin.version).toEqual('v1');
expect(plugin.getRegistrations).toEqual(expect.any(Function));
expect(plugin.getRegistrations()).toEqual([
{
type: 'loader',
description: expect.stringMatching(/^created at '.*'$/),
deps: expect.any(Object),
loader: expect.any(Function),
},
]);
});
it('should create a feature loader that loads a few features', async () => {
const result = createBackendFeatureLoader({
deps: {
config: coreServices.rootConfig,
},
loader({ config: _unused }) {
return [
createBackendPlugin({
pluginId: 'x',
register() {},
})(),
createServiceFactory({
service: coreServices.pluginMetadata,
deps: {},
factory: () => ({ getId: () => 'fake-id' }),
})(),
// Dynamic import format
Promise.resolve({
default: createBackendPlugin({
pluginId: 'y',
register() {},
})(),
}),
];
},
}) as InternalBackendFeature;
expect(result.$$type).toEqual('@backstage/BackendFeature');
expect(result.version).toEqual('v1');
expect(result.getRegistrations).toEqual(expect.any(Function));
const registrations = result.getRegistrations();
expect(registrations).toEqual([
{
type: 'loader',
description: expect.stringMatching(/^created at '.*'$/),
deps: expect.any(Object),
loader: expect.any(Function),
},
]);
const results = await (registrations[0] as any).loader({ config: {} });
expect(results.length).toBe(3);
const [pluginX, serviceFactory, pluginY] = results;
expect(pluginX.$$type).toBe('@backstage/BackendFeature');
expect(serviceFactory.$$type).toBe('@backstage/BackendFeature');
expect(pluginY.$$type).toBe('@backstage/BackendFeature');
expect(serviceFactory.service.id).toBe(coreServices.pluginMetadata.id);
});
it('should support multiple output formats', async () => {
const feature = createBackendPlugin({ pluginId: 'x', register() {} })();
const dynamicFeature = Promise.resolve({ default: feature });
async function extractResult(f: BackendFeature) {
const internal = f as InternalBackendFeature;
const reg =
internal.getRegistrations()[0] as InternalBackendFeatureLoaderRegistration;
return reg.loader({});
}
await expect(
extractResult(
createBackendFeatureLoader({
loader() {
return [feature];
},
}),
),
).resolves.toEqual([feature]);
await expect(
extractResult(
createBackendFeatureLoader({
async loader() {
return [feature];
},
}),
),
).resolves.toEqual([feature]);
await expect(
extractResult(
createBackendFeatureLoader({
*loader() {
yield feature;
},
}),
),
).resolves.toEqual([feature]);
await expect(
extractResult(
createBackendFeatureLoader({
async *loader() {
yield feature;
},
}),
),
).resolves.toEqual([feature]);
await expect(
extractResult(
createBackendFeatureLoader({
loader() {
return [dynamicFeature];
},
}),
),
).resolves.toEqual([feature]);
await expect(
extractResult(
createBackendFeatureLoader({
async loader() {
return [dynamicFeature];
},
}),
),
).resolves.toEqual([feature]);
await expect(
extractResult(
createBackendFeatureLoader({
*loader() {
yield dynamicFeature;
},
}),
),
).resolves.toEqual([feature]);
await expect(
extractResult(
createBackendFeatureLoader({
async *loader() {
yield dynamicFeature;
},
}),
),
).resolves.toEqual([feature]);
});
it('should only allow dependencies on root scoped services', () => {
createBackendFeatureLoader({
deps: {
rootLogger: coreServices.rootLogger,
},
loader: () => [],
});
createBackendFeatureLoader({
deps: {
// @ts-expect-error
logger: coreServices.logger,
},
loader: () => [],
});
createBackendFeatureLoader({
deps: {
rootLogger: coreServices.rootLogger,
// @ts-expect-error
logger: coreServices.logger,
},
loader: () => [],
});
expect('test').toBe('test');
});
});
@@ -0,0 +1,73 @@
/*
* 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 { ServiceRef } from '../services';
import { BackendFeature } from '../types';
import { InternalBackendFeature } from './types';
/**
* @public
* Options for creating a new backend feature loader.
*/
export interface CreateBackendFeatureLoaderOptions<
TDeps extends { [name in string]: unknown },
> {
deps?: {
[name in keyof TDeps]: ServiceRef<TDeps[name], 'root'>;
};
loader(
deps: TDeps,
):
| Iterable<BackendFeature | Promise<{ default: BackendFeature }>>
| Promise<Iterable<BackendFeature | Promise<{ default: BackendFeature }>>>
| AsyncIterable<BackendFeature | { default: BackendFeature }>;
}
/**
* @public
* Creates a new backend feature loader.
*/
export function createBackendFeatureLoader<
TDeps extends { [name in string]: unknown },
>(options: CreateBackendFeatureLoaderOptions<TDeps>): BackendFeature {
const registrations = [
{
type: 'loader',
description: `created at '${new Date().toISOString()}'`,
deps: options.deps,
async loader(deps: TDeps) {
const it = await options.loader(deps);
const result = new Array<BackendFeature>();
for await (const item of it) {
if ('$$type' in item && item.$$type === '@backstage/BackendFeature') {
result.push(item);
} else if ('default' in item) {
result.push(item.default);
} else {
throw new Error(`Invalid item "${item}"`);
}
}
return result;
},
},
];
return {
$$type: '@backstage/BackendFeature',
version: 'v1',
getRegistrations: () => registrations,
} as InternalBackendFeature;
}
@@ -21,6 +21,10 @@ import { type CreateExtensionPointOptions } from './createExtensionPoint';
export { createBackendModule } from './createBackendModule';
export { createBackendPlugin } from './createBackendPlugin';
export { createExtensionPoint } from './createExtensionPoint';
export {
createBackendFeatureLoader,
type CreateBackendFeatureLoaderOptions,
} from './createBackendFeatureLoader';
export type {
BackendModuleRegistrationPoints,
@@ -76,7 +76,9 @@ export interface BackendModuleRegistrationPoints {
export interface InternalBackendFeature extends BackendFeature {
version: 'v1';
getRegistrations(): Array<
InternalBackendPluginRegistration | InternalBackendModuleRegistration
| InternalBackendPluginRegistration
| InternalBackendModuleRegistration
| InternalBackendFeatureLoaderRegistration
>;
}
@@ -102,3 +104,13 @@ export interface InternalBackendModuleRegistration {
func(deps: Record<string, unknown>): Promise<void>;
};
}
/**
* @public
*/
export interface InternalBackendFeatureLoaderRegistration {
type: 'loader';
description: string;
deps: Record<string, ServiceRef<unknown>>;
loader(deps: Record<string, unknown>): Promise<BackendFeature[]>;
}