From d3b49a60f2f96e33b04362f1d5f6dd89501cb8bd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 5 Aug 2024 13:03:16 +0200 Subject: [PATCH] backend-app-api: add support for feature loaders Signed-off-by: Patrik Oldsberg --- .../src/wiring/BackendInitializer.test.ts | 123 ++++++++++++++++++ .../src/wiring/BackendInitializer.ts | 89 +++++++++++-- 2 files changed, 199 insertions(+), 13 deletions(-) diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.test.ts b/packages/backend-app-api/src/wiring/BackendInitializer.test.ts index 3bbef5a4c0..6f60d65861 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.test.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.test.ts @@ -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', diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.ts b/packages/backend-app-api/src/wiring/BackendInitializer.ts index 902258b9b0..4bfbf6244f 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.ts @@ -54,6 +54,7 @@ export class BackendInitializer { #extensionPoints = new Map(); #serviceRegistry: ServiceRegistry; #registeredFeatures = new Array>(); + #registeredFeatureLoaders = new Array(); 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(); + const missingRefs = new Set(); + + 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, + '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(); + + 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'; }