backend-app-api: enable service overrides over feature loaders
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-app-api': minor
|
||||
---
|
||||
|
||||
Service factories added by feature loaders now have lower priority and will be ignored if a factory for the same service is added directly by `backend.add(serviceFactory)`.
|
||||
@@ -73,6 +73,29 @@ export default createBackendFeatureLoader({
|
||||
});
|
||||
```
|
||||
|
||||
### Overriding service factories
|
||||
|
||||
Service factories registered by feature loaders have lower priority by ones added directly via `backend.add`. This allows you to use a feature loader for a larger number of service implementations, but still override individual services.
|
||||
|
||||
The ordering in which different feature loaders or service factories are added does not matter. There is also no priority between feature loaders, if two different feature loaders add a factory for the same service, the backend will fail to start.
|
||||
|
||||
```ts
|
||||
const backend = createBackend();
|
||||
|
||||
backend.add(
|
||||
createBackendFeatureLoader({
|
||||
async *loader() {
|
||||
yield import('./commonDiscoveryService'); // discovery service
|
||||
yield import('./commonRootLoggerService'); // root logger service
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
backend.add(import('./myDiscoveryService')); // discovery service
|
||||
```
|
||||
|
||||
The result of the above example is that the backend starts up with `./myDiscoveryService` as the discovery service implementation, while `./commonDiscoveryService` is ignored. The `./commonRootLoggerService` will still be used.
|
||||
|
||||
### Dynamic logic
|
||||
|
||||
A feature loader can also be asynchronous, and for example fetch data from an external source to determine which features to load:
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
createBackendModule,
|
||||
createExtensionPoint,
|
||||
createBackendFeatureLoader,
|
||||
ServiceRef,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { BackendInitializer } from './BackendInitializer';
|
||||
import { instanceMetadataServiceRef } from '@backstage/backend-plugin-api/alpha';
|
||||
@@ -50,6 +51,18 @@ const baseFactories = [
|
||||
loggerServiceFactory,
|
||||
];
|
||||
|
||||
function mkNoopFactory(ref: ServiceRef<{}, 'plugin'>) {
|
||||
const fn = jest.fn().mockReturnValue({});
|
||||
return Object.assign(
|
||||
fn,
|
||||
createServiceFactory({
|
||||
service: ref,
|
||||
deps: {},
|
||||
factory: fn,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const testPlugin = createBackendPlugin({
|
||||
pluginId: 'test',
|
||||
register(reg) {
|
||||
@@ -167,6 +180,109 @@ describe('BackendInitializer', () => {
|
||||
expect(moduleInit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore services provided by feature loaders that have already been explicitly added', async () => {
|
||||
const ref = createServiceRef<{}>({ id: '1' });
|
||||
const factory1 = mkNoopFactory(ref);
|
||||
const factory2 = mkNoopFactory(ref);
|
||||
const factory3 = mkNoopFactory(ref);
|
||||
|
||||
const init = new BackendInitializer([...baseFactories, factory1]);
|
||||
init.add(factory2);
|
||||
init.add(
|
||||
createBackendFeatureLoader({
|
||||
deps: {},
|
||||
*loader() {
|
||||
yield factory3;
|
||||
},
|
||||
}),
|
||||
);
|
||||
init.add(
|
||||
createBackendPlugin({
|
||||
pluginId: 'tester',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { ref },
|
||||
async init() {},
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await init.start();
|
||||
|
||||
expect(factory1).not.toHaveBeenCalled();
|
||||
expect(factory2).toHaveBeenCalled();
|
||||
expect(factory3).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Note: this is an important escape hatch in case to loaders conflict and you need to select the winning service factory
|
||||
it('should allow duplicate service from feature loaders if overridden', async () => {
|
||||
const ref = createServiceRef<{}>({ id: '1' });
|
||||
const factory1 = mkNoopFactory(ref);
|
||||
const factory2 = mkNoopFactory(ref);
|
||||
const factory3 = mkNoopFactory(ref);
|
||||
const factory4 = mkNoopFactory(ref);
|
||||
|
||||
const init = new BackendInitializer([...baseFactories, factory1]);
|
||||
init.add(factory2);
|
||||
init.add(
|
||||
createBackendFeatureLoader({
|
||||
deps: {},
|
||||
*loader() {
|
||||
yield factory3;
|
||||
yield factory4;
|
||||
},
|
||||
}),
|
||||
);
|
||||
init.add(
|
||||
createBackendPlugin({
|
||||
pluginId: 'tester',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { ref },
|
||||
async init() {},
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await init.start();
|
||||
|
||||
expect(factory1).not.toHaveBeenCalled();
|
||||
expect(factory2).toHaveBeenCalled();
|
||||
expect(factory3).not.toHaveBeenCalled();
|
||||
expect(factory4).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject duplicate service factories from feature loader without an explicit override', async () => {
|
||||
const ref = createServiceRef<{}>({ id: '1' });
|
||||
const factory1 = mkNoopFactory(ref);
|
||||
const factory2 = mkNoopFactory(ref);
|
||||
const factory3 = mkNoopFactory(ref);
|
||||
|
||||
const init = new BackendInitializer([...baseFactories, factory1]);
|
||||
init.add(
|
||||
createBackendFeatureLoader({
|
||||
deps: {},
|
||||
*loader() {
|
||||
yield factory2;
|
||||
},
|
||||
}),
|
||||
);
|
||||
init.add(
|
||||
createBackendFeatureLoader({
|
||||
deps: {},
|
||||
*loader() {
|
||||
yield factory3;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(init.start()).rejects.toThrow(
|
||||
'Duplicate service implementations provided for 1 by both feature loader created at',
|
||||
);
|
||||
});
|
||||
|
||||
it('should refuse to override already initialized services through loaded features', async () => {
|
||||
const ref1 = createServiceRef<{ x: number }>({
|
||||
id: '1',
|
||||
|
||||
@@ -519,6 +519,11 @@ export class BackendInitializer {
|
||||
}
|
||||
|
||||
async #applyBackendFeatureLoaders(loaders: InternalBackendFeatureLoader[]) {
|
||||
const servicesAddedByLoaders = new Map<
|
||||
string,
|
||||
InternalBackendFeatureLoader
|
||||
>();
|
||||
|
||||
for (const loader of loaders) {
|
||||
const deps = new Map<string, unknown>();
|
||||
const missingRefs = new Set<ServiceOrExtensionPoint>();
|
||||
@@ -564,8 +569,32 @@ export class BackendInitializer {
|
||||
if (isBackendFeatureLoader(feature)) {
|
||||
newLoaders.push(feature);
|
||||
} else {
|
||||
didAddServiceFactory ||= isServiceFactory(feature);
|
||||
this.#addFeature(feature);
|
||||
// This block makes sure that feature loaders do not provide duplicate
|
||||
// implementations for the same service, but at the same time allows
|
||||
// service factories provided by feature loaders to be overridden by
|
||||
// ones that are explicitly installed with backend.add(serviceFactory).
|
||||
//
|
||||
// If a factory has already been explicitly installed, the service
|
||||
// factory provided by the loader will simply be ignored.
|
||||
if (isServiceFactory(feature)) {
|
||||
const conflictingLoader = servicesAddedByLoaders.get(
|
||||
feature.service.id,
|
||||
);
|
||||
if (conflictingLoader) {
|
||||
throw new Error(
|
||||
`Duplicate service implementations provided for ${feature.service.id} by both feature loader ${loader.description} and feature loader ${conflictingLoader.description}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check that this service wasn't already explicitly added by backend.add(serviceFactory)
|
||||
if (!this.#serviceRegistry.hasBeenAdded(feature.service)) {
|
||||
didAddServiceFactory = true;
|
||||
servicesAddedByLoaders.set(feature.service.id, loader);
|
||||
this.#addFeature(feature);
|
||||
}
|
||||
} else {
|
||||
this.#addFeature(feature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -191,6 +191,16 @@ export class ServiceRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
hasBeenAdded(ref: ServiceRef<any>) {
|
||||
if (ref.id === coreServices.pluginMetadata.id) {
|
||||
return true;
|
||||
}
|
||||
if (ref.multiton) {
|
||||
return false;
|
||||
}
|
||||
return this.#addedFactoryIds.has(ref.id);
|
||||
}
|
||||
|
||||
add(factory: ServiceFactory) {
|
||||
const factoryId = factory.service.id;
|
||||
if (factoryId === coreServices.pluginMetadata.id) {
|
||||
|
||||
Reference in New Issue
Block a user