backend-app-api: add support for feature loaders
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user