backend-app-api: add support for feature loaders

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-08-05 13:03:16 +02:00
parent d4d048b3ff
commit d3b49a60f2
2 changed files with 199 additions and 13 deletions
@@ -24,6 +24,7 @@ import {
createBackendPlugin,
createBackendModule,
createExtensionPoint,
createBackendFeatureLoader,
} from '@backstage/backend-plugin-api';
import { BackendInitializer } from './BackendInitializer';
@@ -105,6 +106,128 @@ describe('BackendInitializer', () => {
expect(factory3).not.toHaveBeenCalled();
});
it('should discover features from feature loader', async () => {
const ref1 = createServiceRef<{ x: number }>({
id: '1',
scope: 'root',
});
const ref2 = createServiceRef<{ x: number }>({
id: '2',
scope: 'plugin',
});
const factory1 = jest.fn();
const factory2 = jest.fn();
const pluginInit = jest.fn(async () => {});
const moduleInit = jest.fn(async () => {});
const init = new BackendInitializer(baseFactories);
init.add(
createBackendFeatureLoader({
*loader() {
yield createServiceFactory({
service: ref1,
deps: {},
factory: factory1,
});
yield createServiceFactory({
service: ref2,
initialization: 'always',
deps: {},
factory: factory2,
});
yield createBackendPlugin({
pluginId: 'test',
register(reg) {
reg.registerInit({
deps: {},
init: pluginInit,
});
},
});
yield createBackendModule({
pluginId: 'test',
moduleId: 'tester',
register(reg) {
reg.registerInit({
deps: {},
init: moduleInit,
});
},
});
},
}),
);
await init.start();
expect(factory1).toHaveBeenCalled();
expect(factory2).toHaveBeenCalled();
expect(pluginInit).toHaveBeenCalled();
expect(moduleInit).toHaveBeenCalled();
});
it('should refuse to override already initialized services through loaded features', async () => {
const ref1 = createServiceRef<{ x: number }>({
id: '1',
scope: 'root',
});
const init = new BackendInitializer([
...baseFactories,
createServiceFactory({
service: ref1,
deps: {},
factory: () => ({ x: 1 }),
}),
]);
init.add(
createBackendFeatureLoader({
deps: { service1: ref1 },
*loader() {
yield createServiceFactory({
service: ref1,
deps: {},
factory: jest.fn(),
});
},
}),
);
await expect(init.start()).rejects.toThrow(
'Unable to set service factory with id 1, service has already been instantiated',
);
});
it('should refuse feature loader that depends on a plugin scoped service', async () => {
const ref1 = createServiceRef<{ x: number }>({
id: '1',
});
const init = new BackendInitializer([
...baseFactories,
createServiceFactory({
service: ref1,
deps: {},
factory: () => ({ x: 1 }),
}),
]);
init.add(
createBackendFeatureLoader({
// @ts-expect-error
deps: { service1: ref1 },
*loader() {
yield createServiceFactory({
service: ref1,
deps: {},
factory: jest.fn(),
});
},
}),
);
await expect(init.start()).rejects.toThrow(
/^Feature loaders can only depend on root scoped services, but 'service1' is scoped to 'plugin'. Offending loader is created at '.*'$/,
);
});
it('should initialize plugin scoped services with eager initialization', async () => {
const ref1 = createServiceRef<{ x: number }>({
id: '1',
@@ -54,6 +54,7 @@ export class BackendInitializer {
#extensionPoints = new Map<string, { impl: unknown; pluginId: string }>();
#serviceRegistry: ServiceRegistry;
#registeredFeatures = new Array<Promise<BackendFeature>>();
#registeredFeatureLoaders = new Array<InternalBackendFeatureLoader>();
constructor(defaultApiFactories: ServiceFactory[]) {
this.#serviceRegistry = ServiceRegistry.create([...defaultApiFactories]);
@@ -109,12 +110,9 @@ export class BackendInitializer {
#addFeature(feature: BackendFeature) {
if (isServiceFactory(feature)) {
this.#serviceRegistry.add(feature);
} else if (isBackendFeatureLoader(feature)) {
this.#registeredFeatureLoaders.push(feature);
} else if (isBackendRegistrations(feature)) {
if (feature.version !== 'v1') {
throw new Error(
`Failed to add feature, invalid version '${feature.version}'`,
);
}
this.#registrations.push(feature);
} else {
throw new Error(
@@ -170,6 +168,8 @@ export class BackendInitializer {
this.#serviceRegistry.checkForCircularDeps();
}
await this.#applyBackendFeatureLoaders(this.#registeredFeatureLoaders);
// Initialize all root scoped services
await this.#serviceRegistry.initializeEagerServicesWithScope('root');
@@ -385,6 +385,69 @@ export class BackendInitializer {
throw new Error('Unexpected plugin lifecycle service implementation');
}
async #applyBackendFeatureLoaders(loaders: InternalBackendFeatureLoader[]) {
for (const loader of loaders) {
const deps = new Map<string, unknown>();
const missingRefs = new Set<ServiceOrExtensionPoint>();
for (const [name, ref] of Object.entries(loader.deps ?? {})) {
if (ref.scope !== 'root') {
throw new Error(
`Feature loaders can only depend on root scoped services, but '${name}' is scoped to '${ref.scope}'. Offending loader is ${loader.description}`,
);
}
const impl = await this.#serviceRegistry.get(
ref as ServiceRef<unknown>,
'root',
);
if (impl) {
deps.set(name, impl);
} else {
missingRefs.add(ref);
}
}
if (missingRefs.size > 0) {
const missing = Array.from(missingRefs).join(', ');
throw new Error(
`No service available for the following ref(s): ${missing}, depended on by feature loader ${loader.description}`,
);
}
const result = await loader
.loader(Object.fromEntries(deps))
.catch(error => {
throw new ForwardedError(
`Feature loader ${loader.description} failed`,
error,
);
});
let didAddServiceFactory = false;
const newLoaders = new Array<InternalBackendFeatureLoader>();
for await (const feature of result) {
if (isBackendFeatureLoader(feature)) {
newLoaders.push(feature);
} else {
didAddServiceFactory =
didAddServiceFactory || isServiceFactory(feature);
this.#addFeature(feature);
}
}
// Every time we add a new service factory we need to make sure that we don't have circular dependencies
if (didAddServiceFactory) {
this.#serviceRegistry.checkForCircularDeps();
}
// Apply loaders recursively, depth-first
if (newLoaders.length > 0) {
await this.#applyBackendFeatureLoaders(newLoaders);
}
}
}
}
function toInternalBackendFeature(
@@ -410,10 +473,7 @@ function isServiceFactory(
return true;
}
// Backwards compatibility for v1 registrations that use duck typing
if ('service' in internal) {
return true;
}
return false;
return 'service' in internal;
}
function isBackendRegistrations(
@@ -424,8 +484,11 @@ function isBackendRegistrations(
return true;
}
// Backwards compatibility for v1 registrations that use duck typing
if ('getRegistrations' in internal) {
return true;
}
return false;
return 'getRegistrations' in internal;
}
function isBackendFeatureLoader(
feature: BackendFeature,
): feature is InternalBackendFeatureLoader {
return toInternalBackendFeature(feature).featureType === 'loader';
}