diff --git a/packages/backend-app-api/src/wiring/ServiceRegistry.test.ts b/packages/backend-app-api/src/wiring/ServiceRegistry.test.ts index a00f6c0b4b..ab709dcb77 100644 --- a/packages/backend-app-api/src/wiring/ServiceRegistry.test.ts +++ b/packages/backend-app-api/src/wiring/ServiceRegistry.test.ts @@ -21,28 +21,28 @@ import { } from '@backstage/backend-plugin-api'; import { ServiceRegistry } from './ServiceRegistry'; -const ref1 = createServiceRef<{ x: number; pluginId: string }>({ +const ref1 = createServiceRef<{ x: number }>({ id: '1', }); const sf1 = createServiceFactory({ service: ref1, deps: {}, factory: async () => { - return async pluginId => { - return { x: 1, pluginId }; + return async () => { + return { x: 1 }; }; }, }); -const ref2 = createServiceRef<{ x: number; pluginId: string }>({ +const ref2 = createServiceRef<{ x: number }>({ id: '2', }); const sf2 = createServiceFactory({ service: ref2, deps: {}, factory: async () => { - return async pluginId => { - return { x: 2, pluginId }; + return async () => { + return { x: 2 }; }; }, }); @@ -50,126 +50,115 @@ const sf2b = createServiceFactory({ service: ref2, deps: {}, factory: async () => { - return async pluginId => { - return { x: 22, pluginId }; + return async () => { + return { x: 22 }; }; }, }); -const refDefault1 = createServiceRef<{ x: number; pluginId: string }>({ +const refDefault1 = createServiceRef<{ x: number }>({ id: '1', defaultFactory: async service => createServiceFactory({ service, deps: {}, - factory: async () => async pluginId => ({ x: 10, pluginId }), + factory: async () => async () => ({ x: 10 }), }), }); -const refDefault2a = createServiceRef<{ x: number; pluginId: string }>({ +const refDefault2a = createServiceRef<{ x: number }>({ id: '2a', defaultFactory: async service => createServiceFactory({ service, deps: {}, - factory: async () => async pluginId => ({ x: 20, pluginId }), + factory: async () => async () => ({ x: 20 }), }), }); -const refDefault2b = createServiceRef<{ x: number; pluginId: string }>({ +const refDefault2b = createServiceRef<{ x: number }>({ id: '2b', defaultFactory: async service => createServiceFactory({ service, deps: {}, - factory: async () => async pluginId => ({ x: 220, pluginId }), + factory: async () => async () => ({ x: 220 }), }), }); describe('ServiceRegistry', () => { it('should return undefined if there is no factory defined', async () => { const registry = new ServiceRegistry([]); - expect(registry.get(ref1)).toBe(undefined); + expect(registry.get(ref1, 'catalog')).toBe(undefined); }); - it('should return a factory for a registered ref', async () => { + it('should return an implementation for a registered ref', async () => { const registry = new ServiceRegistry([sf1]); - const factory = registry.get(ref1)!; - expect(factory).toEqual(expect.any(Function)); - await expect(factory('catalog')).resolves.toEqual({ - x: 1, - pluginId: 'catalog', - }); - await expect(factory('scaffolder')).resolves.toEqual({ - x: 1, - pluginId: 'scaffolder', - }); - expect(await factory('catalog')).toBe(await factory('catalog')); + await expect(registry.get(ref1, 'catalog')).resolves.toEqual({ x: 1 }); + await expect(registry.get(ref1, 'scaffolder')).resolves.toEqual({ x: 1 }); + expect(await registry.get(ref1, 'catalog')).toBe( + await registry.get(ref1, 'catalog'), + ); + expect(await registry.get(ref1, 'scaffolder')).toBe( + await registry.get(ref1, 'scaffolder'), + ); + expect(await registry.get(ref1, 'catalog')).not.toBe( + await registry.get(ref1, 'scaffolder'), + ); }); it('should handle multiple factories with different serviceRefs', async () => { const registry = new ServiceRegistry([sf1, sf2]); - const factory1 = registry.get(ref1)!; - const factory2 = registry.get(ref2)!; - expect(factory1).toEqual(expect.any(Function)); - expect(factory2).toEqual(expect.any(Function)); - await expect(factory1('catalog')).resolves.toEqual({ + + await expect(registry.get(ref1, 'catalog')).resolves.toEqual({ x: 1, - pluginId: 'catalog', }); - await expect(factory2('catalog')).resolves.toEqual({ + await expect(registry.get(ref2, 'catalog')).resolves.toEqual({ x: 2, - pluginId: 'catalog', }); - expect(await factory1('catalog')).not.toBe(await factory2('catalog')); + expect(await registry.get(ref1, 'catalog')).not.toBe( + await registry.get(ref2, 'catalog'), + ); }); it('should use the last factory for each ref', async () => { const registry = new ServiceRegistry([sf2, sf2b]); - const factory2 = registry.get(ref2)!; - await expect(factory2('catalog')).resolves.toEqual({ + await expect(registry.get(ref2, 'catalog')).resolves.toEqual({ x: 22, - pluginId: 'catalog', }); }); - it('should return the defaultFactory from the ref if not provided to the registry', async () => { + it('should use the defaultFactory from the ref if not provided to the registry', async () => { const registry = new ServiceRegistry([]); - const factory = registry.get(refDefault1)!; - expect(factory).toEqual(expect.any(Function)); - await expect(factory('catalog')).resolves.toEqual({ + await expect(registry.get(refDefault1, 'catalog')).resolves.toEqual({ x: 10, - pluginId: 'catalog', }); }); - it('should not return the defaultFactory from the ref if provided to the registry', async () => { + it('should not use the defaultFactory from the ref if provided to the registry', async () => { const registry = new ServiceRegistry([sf1]); - const factory = registry.get(refDefault1)!; - expect(factory).toEqual(expect.any(Function)); - await expect(factory('catalog')).resolves.toEqual({ + await expect(registry.get(refDefault1, 'catalog')).resolves.toEqual({ x: 1, - pluginId: 'catalog', }); }); it('should handle duplicate defaultFactories by duplicating the implementations', async () => { const registry = new ServiceRegistry([]); - const factoryA = registry.get(refDefault2a)!; - const factoryB = registry.get(refDefault2b)!; - expect(factoryA).toEqual(expect.any(Function)); - expect(factoryB).toEqual(expect.any(Function)); - await expect(factoryA('catalog')).resolves.toEqual({ + await expect(registry.get(refDefault2a, 'catalog')).resolves.toEqual({ x: 20, - pluginId: 'catalog', }); - await expect(factoryB('catalog')).resolves.toEqual({ + await expect(registry.get(refDefault2b, 'catalog')).resolves.toEqual({ x: 220, - pluginId: 'catalog', }); - expect(await factoryA('catalog')).toBe(await factoryA('catalog')); - expect(await factoryB('catalog')).toBe(await factoryB('catalog')); - expect(await factoryA('catalog')).not.toBe(await factoryB('catalog')); + expect(await registry.get(refDefault2a, 'catalog')).toBe( + await registry.get(refDefault2a, 'catalog'), + ); + expect(await registry.get(refDefault2b, 'catalog')).toBe( + await registry.get(refDefault2b, 'catalog'), + ); + expect(await registry.get(refDefault2a, 'catalog')).not.toBe( + await registry.get(refDefault2b, 'catalog'), + ); }); it('should only call each default factory loader once', async () => { @@ -186,17 +175,16 @@ describe('ServiceRegistry', () => { }); const registry = new ServiceRegistry([]); - const factory = registry.get(ref)!; await Promise.all([ - expect(factory('catalog')).resolves.toBeUndefined(), - expect(factory('catalog')).resolves.toBeUndefined(), + expect(registry.get(ref, 'catalog')).resolves.toBeUndefined(), + expect(registry.get(ref, 'catalog')).resolves.toBeUndefined(), ]); expect(factoryLoader).toHaveBeenCalledTimes(1); }); it('should not call factory functions more than once', async () => { - const innerFactory = jest.fn(async (pluginId: string) => { - return { x: 1, pluginId }; + const innerFactory = jest.fn(async () => { + return { x: 1 }; }); const factory = jest.fn(async () => innerFactory); const myFactory = createServiceFactory({ @@ -208,17 +196,15 @@ describe('ServiceRegistry', () => { const registry = new ServiceRegistry([myFactory]); await Promise.all([ - registry.get(ref1)!('catalog')!, - registry.get(ref1)!('catalog')!, - registry.get(ref1)!('catalog')!, - registry.get(ref1)!('scaffolder')!, - registry.get(ref1)!('scaffolder')!, + registry.get(ref1, 'catalog')!, + registry.get(ref1, 'catalog')!, + registry.get(ref1, 'catalog')!, + registry.get(ref1, 'scaffolder')!, + registry.get(ref1, 'scaffolder')!, ]); expect(factory).toHaveBeenCalledTimes(1); expect(innerFactory).toHaveBeenCalledTimes(2); - expect(innerFactory).toHaveBeenCalledWith('catalog'); - expect(innerFactory).toHaveBeenCalledWith('scaffolder'); }); it('should throw if dependencies are not available', async () => { @@ -231,9 +217,8 @@ describe('ServiceRegistry', () => { }); const registry = new ServiceRegistry([myFactory]); - const factory = registry.get(ref1)!; - await expect(factory('catalog')).rejects.toThrow( + await expect(registry.get(ref1, 'catalog')).rejects.toThrow( "Failed to instantiate service '1' for 'catalog' because the following dependent services are missing: '2'", ); }); @@ -247,8 +232,8 @@ describe('ServiceRegistry', () => { const factoryA = createServiceFactory({ service: refA, deps: { b: refB }, - async factory({ b }) { - return async pluginId => b(pluginId); + async factory() { + return async ({ b }) => b; }, }); @@ -261,9 +246,8 @@ describe('ServiceRegistry', () => { }); const registry = new ServiceRegistry([factoryA, factoryB]); - const factory = registry.get(refA)!; - await expect(factory('catalog')).rejects.toThrow( + await expect(registry.get(refA, 'catalog')).rejects.toThrow( "Failed to instantiate service 'a' for 'catalog' because the factory function threw an error, Error: Failed to instantiate service 'b' for 'catalog' because the following dependent services are missing: 'c', 'd'", ); }); @@ -278,9 +262,8 @@ describe('ServiceRegistry', () => { }); const registry = new ServiceRegistry([myFactory]); - const factory = registry.get(ref1)!; - await expect(factory('catalog')).rejects.toThrow( + await expect(registry.get(ref1, 'catalog')).rejects.toThrow( "Failed to instantiate service '1' because the top-level factory function threw an error, Error: top-level error", ); }); @@ -290,17 +273,16 @@ describe('ServiceRegistry', () => { service: ref1, deps: {}, async factory() { - return pluginId => { - throw new Error(`error in plugin ${pluginId}`); + return () => { + throw new Error(`error in plugin`); }; }, }); const registry = new ServiceRegistry([myFactory]); - const factory = registry.get(ref1)!; - await expect(factory('catalog')).rejects.toThrow( - "Failed to instantiate service '1' for 'catalog' because the factory function threw an error, Error: error in plugin catalog", + await expect(registry.get(ref1, 'catalog')).rejects.toThrow( + "Failed to instantiate service '1' for 'catalog' because the factory function threw an error, Error: error in plugin", ); }); @@ -313,9 +295,8 @@ describe('ServiceRegistry', () => { }); const registry = new ServiceRegistry([]); - const factory = registry.get(ref)!; - await expect(factory('catalog')).rejects.toThrow( + await expect(registry.get(ref, 'catalog')).rejects.toThrow( "Failed to instantiate service '1' because the default factory loader threw an error, Error: default factory error", ); }); diff --git a/packages/backend-app-api/src/wiring/ServiceRegistry.ts b/packages/backend-app-api/src/wiring/ServiceRegistry.ts index e0509aecde..007ca00c88 100644 --- a/packages/backend-app-api/src/wiring/ServiceRegistry.ts +++ b/packages/backend-app-api/src/wiring/ServiceRegistry.ts @@ -16,8 +16,8 @@ import { ServiceFactory, - FactoryFunc, ServiceRef, + pluginMetadataServiceRef, } from '@backstage/backend-plugin-api'; import { stringifyError } from '@backstage/errors'; @@ -37,7 +37,9 @@ export class ServiceRegistry { readonly #implementations: Map< ServiceFactory, { - factoryFunc: Promise>; + factoryFunc: Promise< + (deps: { [name in string]: unknown }) => Promise + >; byPlugin: Map>; } >; @@ -58,67 +60,123 @@ export class ServiceRegistry { this.#implementations = new Map(); } - get(ref: ServiceRef): FactoryFunc | undefined { - let factory = this.#providedFactories.get(ref.id); - const { __defaultFactory: defaultFactory } = ref as InternalServiceRef; - if (!factory && !defaultFactory) { + #resolveFactory( + ref: ServiceRef, + pluginId: string, + ): Promise | undefined { + // Special case handling of the plugin metadata service, generating a custom factory for it each time + if (ref.id === pluginMetadataServiceRef.id) { + return Promise.resolve({ + scope: 'plugin', + service: pluginMetadataServiceRef, + deps: {}, + factory: async () => async () => ({ + getId() { + return pluginId; + }, + }), + }); + } + + let resolvedFactory: Promise | ServiceFactory | undefined = + this.#providedFactories.get(ref.id); + const { __defaultFactory: defaultFactory } = + ref as InternalServiceRef; + if (!resolvedFactory && !defaultFactory) { return undefined; } - return async (pluginId: string): Promise => { - if (!factory) { - let loadedFactory = this.#loadedDefaultFactories.get(defaultFactory!); - if (!loadedFactory) { - loadedFactory = Promise.resolve() - .then(() => defaultFactory!(ref)) - .then(f => - typeof f === 'function' ? f() : f, - ) as Promise; - this.#loadedDefaultFactories.set(defaultFactory!, loadedFactory); - } - // NOTE: This await is safe as long as #providedFactories is not mutated. - factory = await loadedFactory.catch(error => { - throw new Error( - `Failed to instantiate service '${ - ref.id - }' because the default factory loader threw an error, ${stringifyError( - error, - )}`, + if (!resolvedFactory) { + let loadedFactory = this.#loadedDefaultFactories.get(defaultFactory!); + if (!loadedFactory) { + loadedFactory = Promise.resolve() + .then(() => defaultFactory!(ref)) + .then(f => + typeof f === 'function' ? f() : f, + ) as Promise; + this.#loadedDefaultFactories.set(defaultFactory!, loadedFactory); + } + resolvedFactory = loadedFactory.catch(error => { + throw new Error( + `Failed to instantiate service '${ + ref.id + }' because the default factory loader threw an error, ${stringifyError( + error, + )}`, + ); + }); + } + + return Promise.resolve(resolvedFactory); + } + + #separateMapForTheRootService = new Map>(); + + #checkForMissingDeps(factory: ServiceFactory, pluginId: string) { + const missingDeps = Object.values(factory.deps).filter(ref => { + if (ref.id === pluginMetadataServiceRef.id) { + return false; + } + if (this.#providedFactories.get(ref.id)) { + return false; + } + + return !(ref as InternalServiceRef).__defaultFactory; + }); + + if (missingDeps.length) { + const missing = missingDeps.map(r => `'${r.id}'`).join(', '); + throw new Error( + `Failed to instantiate service '${factory.service.id}' for '${pluginId}' because the following dependent services are missing: ${missing}`, + ); + } + } + + get(ref: ServiceRef, pluginId: string): Promise | undefined { + return this.#resolveFactory(ref, pluginId)?.then(factory => { + if (factory.scope === 'root') { + let existing = this.#separateMapForTheRootService.get(factory); + if (!existing) { + this.#checkForMissingDeps(factory, pluginId); + const rootDeps = new Array>(); + + for (const [name, serviceRef] of Object.entries(factory.deps)) { + if (serviceRef.scope !== 'root') { + throw new Error( + `Failed to instantiate 'root' scoped service '${ref.id}' because it depends on '${serviceRef.scope}' scoped service '${serviceRef.id}'.`, + ); + } + const target = this.get(serviceRef, pluginId)!; + rootDeps.push(target.then(impl => [name, impl])); + } + + existing = Promise.all(rootDeps).then(entries => + factory.factory(Object.fromEntries(entries)), ); - }); + this.#separateMapForTheRootService.set(factory, existing); + } + return existing as Promise; } let implementation = this.#implementations.get(factory); if (!implementation) { - const missingRefs = new Array>(); - const factoryDeps: { [name in string]: FactoryFunc } = {}; + this.#checkForMissingDeps(factory, pluginId); + const rootDeps = new Array>(); for (const [name, serviceRef] of Object.entries(factory.deps)) { - const target = this.get(serviceRef); - if (!target) { - missingRefs.push(serviceRef); - } else { - factoryDeps[name] = target; + if (serviceRef.scope === 'root') { + const target = this.get(serviceRef, pluginId)!; + rootDeps.push(target.then(impl => [name, impl])); } } - if (missingRefs.length) { - const missing = missingRefs.map(r => `'${r.id}'`).join(', '); - throw new Error( - `Failed to instantiate service '${ref.id}' for '${pluginId}' because the following dependent services are missing: ${missing}`, - ); - } - implementation = { - factoryFunc: Promise.resolve() - .then(() => factory!.factory(factoryDeps)) + factoryFunc: Promise.all(rootDeps) + .then(entries => factory.factory(Object.fromEntries(entries))) .catch(error => { + const cause = stringifyError(error); throw new Error( - `Failed to instantiate service '${ - ref.id - }' because the top-level factory function threw an error, ${stringifyError( - error, - )}`, + `Failed to instantiate service '${ref.id}' because the top-level factory function threw an error, ${cause}`, ); }), byPlugin: new Map(), @@ -129,24 +187,29 @@ export class ServiceRegistry { let result = implementation.byPlugin.get(pluginId) as Promise; if (!result) { - result = implementation.factoryFunc.then(func => - Promise.resolve() - .then(() => func(pluginId)) - .catch(error => { - throw new Error( - `Failed to instantiate service '${ - ref.id - }' for '${pluginId}' because the factory function threw an error, ${stringifyError( - error, - )}`, - ); - }), - ); + const allDeps = new Array>(); + for (const [name, serviceRef] of Object.entries(factory.deps)) { + const target = this.get(serviceRef, pluginId)!; + allDeps.push(target.then(impl => [name, impl])); + } + + result = implementation.factoryFunc + .then(func => + Promise.all(allDeps).then(entries => + func(Object.fromEntries(entries)), + ), + ) + .catch(error => { + const cause = stringifyError(error); + throw new Error( + `Failed to instantiate service '${ref.id}' for '${pluginId}' because the factory function threw an error, ${cause}`, + ); + }); implementation.byPlugin.set(pluginId, result); } return result; - }; + }); } } diff --git a/packages/backend-app-api/src/wiring/types.ts b/packages/backend-app-api/src/wiring/types.ts index febc6830c7..40ce6aa1ae 100644 --- a/packages/backend-app-api/src/wiring/types.ts +++ b/packages/backend-app-api/src/wiring/types.ts @@ -18,7 +18,6 @@ import { ServiceFactory, BackendFeature, ExtensionPoint, - FactoryFunc, ServiceRef, } from '@backstage/backend-plugin-api'; import { BackstageBackend } from './BackstageBackend'; @@ -47,7 +46,7 @@ export interface CreateSpecializedBackendOptions { } export type ServiceHolder = { - get(api: ServiceRef): FactoryFunc | undefined; + get(api: ServiceRef, pluginId: string): Promise | undefined; }; /** diff --git a/packages/backend-plugin-api/src/services/system/types.ts b/packages/backend-plugin-api/src/services/system/types.ts index a19c17bb9c..4a9f91541a 100644 --- a/packages/backend-plugin-api/src/services/system/types.ts +++ b/packages/backend-plugin-api/src/services/system/types.ts @@ -19,63 +19,84 @@ * * @public */ -export type ServiceRef = { +export type ServiceRef< + TService, + TScope extends 'root' | 'plugin' = 'root' | 'plugin', +> = { id: string; + /** + * This determines the scope at which this service is available. + * + * Root scoped services are available to all other services but + * may only depend on other root scoped services. + * + * Plugin scoped services are only available to other plugin scoped + * services but may depend on all other services. + */ + scope: TScope; + /** * Utility for getting the type of the service, using `typeof serviceRef.T`. * Attempting to actually read this value will result in an exception. */ - T: T; + T: TService; toString(): string; $$ref: 'service'; }; -/** - * @internal - */ -export type InternalServiceRef = ServiceRef & { - /** - * The default factory that will be used to create service - * instances if no other factory is provided. - */ - __defaultFactory?: ( - service: ServiceRef, - ) => Promise | (() => ServiceFactory)>; -}; - /** @public */ export type TypesToServiceRef = { [key in keyof T]: ServiceRef }; /** @public */ -export type DepsToDepFactories = { - [key in keyof T]: (pluginId: string) => Promise; -}; - -/** @public */ -export type FactoryFunc = (pluginId: string) => Promise; - -/** @public */ -export type ServiceFactory = { - service: ServiceRef; - deps: { [key in string]: ServiceRef }; - factory(deps: { [key in string]: unknown }): Promise>; -}; +export type ServiceFactory = + | { + // This scope prop is needed in addition to the service ref, as TypeScript + // can't properly discriminate the two factory types otherwise. + scope: 'root'; + service: ServiceRef; + deps: { [key in string]: ServiceRef }; + factory(deps: { [key in string]: unknown }): Promise; + } + | { + scope: 'plugin'; + service: ServiceRef; + deps: { [key in string]: ServiceRef }; + factory(deps: { [key in string]: unknown }): Promise< + (deps: { [key in string]: unknown }) => Promise + >; + }; /** * @public */ export function createServiceRef(options: { id: string; + scope?: 'plugin'; defaultFactory?: ( service: ServiceRef, ) => Promise | (() => ServiceFactory)>; -}): ServiceRef { - const { id, defaultFactory } = options; +}): ServiceRef; +export function createServiceRef(options: { + id: string; + scope: 'root'; + defaultFactory?: ( + service: ServiceRef, + ) => Promise | (() => ServiceFactory)>; +}): ServiceRef; +export function createServiceRef(options: { + id: string; + scope?: 'plugin' | 'root'; + defaultFactory?: ( + service: ServiceRef, + ) => Promise | (() => ServiceFactory)>; +}): ServiceRef { + const { id, scope = 'plugin', defaultFactory } = options; return { id, + scope, get T(): T { throw new Error(`tried to read ServiceRef.T of ${this}`); }, @@ -84,32 +105,58 @@ export function createServiceRef(options: { }, $$ref: 'service', // TODO: declare __defaultFactory: defaultFactory, - } as InternalServiceRef; + } as ServiceRef & { + __defaultFactory?: ( + service: ServiceRef, + ) => Promise | (() => ServiceFactory)>; + }; } +type OnlyRootScopeDependencies< + TDeps extends { [key in string]: ServiceRef }, +> = Pick< + TDeps, + { + [name in keyof TDeps]: TDeps[name]['scope'] extends 'root' ? name : never; + }[keyof TDeps] +>; + +type DependencyRefsToInstances< + T extends { [key in string]: ServiceRef }, +> = { + [key in keyof T]: T[key] extends ServiceRef ? TImpl : never; +}; + /** * @public */ export function createServiceFactory< TService, + TScope extends 'root' | 'plugin', TImpl extends TService, - TDeps extends { [name in string]: unknown }, + TDeps extends { [name in string]: ServiceRef }, TOpts extends { [name in string]: unknown } | undefined = undefined, ->(factory: { - service: ServiceRef; - deps: TypesToServiceRef; +>(config: { + service: ServiceRef; + deps: TDeps; factory( - deps: DepsToDepFactories, + deps: DependencyRefsToInstances>, options: TOpts, - ): Promise>; + ): TScope extends 'root' + ? Promise + : Promise<(deps: DependencyRefsToInstances) => Promise>; }): undefined extends TOpts ? (options?: TOpts) => ServiceFactory : (options: TOpts) => ServiceFactory { - return (options?: TOpts) => ({ - service: factory.service, - deps: factory.deps, - factory(deps: DepsToDepFactories) { - return factory.factory(deps, options!); - }, - }); + return (options?: TOpts) => + ({ + scope: config.service.scope, + service: config.service, + deps: config.deps, + factory( + deps: DependencyRefsToInstances>, + ) { + return config.factory(deps, options!); + }, + } as ServiceFactory); }