backend-{plugin,app}-api: introduce service scopes and update registry to support
Co-authored-by: Fredrik Adelöw <freben@gmail.com> Co-authored-by: blam <ben@blam.sh> Co-authored-by: Johan Haals <johan.haals@gmail.com> Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<unknown>>;
|
||||
factoryFunc: Promise<
|
||||
(deps: { [name in string]: unknown }) => Promise<unknown>
|
||||
>;
|
||||
byPlugin: Map<string, Promise<unknown>>;
|
||||
}
|
||||
>;
|
||||
@@ -58,67 +60,123 @@ export class ServiceRegistry {
|
||||
this.#implementations = new Map();
|
||||
}
|
||||
|
||||
get<T>(ref: ServiceRef<T>): FactoryFunc<T> | undefined {
|
||||
let factory = this.#providedFactories.get(ref.id);
|
||||
const { __defaultFactory: defaultFactory } = ref as InternalServiceRef<T>;
|
||||
if (!factory && !defaultFactory) {
|
||||
#resolveFactory(
|
||||
ref: ServiceRef<unknown>,
|
||||
pluginId: string,
|
||||
): Promise<ServiceFactory> | 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> | ServiceFactory | undefined =
|
||||
this.#providedFactories.get(ref.id);
|
||||
const { __defaultFactory: defaultFactory } =
|
||||
ref as InternalServiceRef<unknown>;
|
||||
if (!resolvedFactory && !defaultFactory) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return async (pluginId: string): Promise<T> => {
|
||||
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<ServiceFactory>;
|
||||
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<ServiceFactory>;
|
||||
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<ServiceFactory, Promise<unknown>>();
|
||||
|
||||
#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<unknown>).__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<T>(ref: ServiceRef<T>, pluginId: string): Promise<T> | 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<Promise<[name: string, impl: unknown]>>();
|
||||
|
||||
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<T>;
|
||||
}
|
||||
|
||||
let implementation = this.#implementations.get(factory);
|
||||
if (!implementation) {
|
||||
const missingRefs = new Array<ServiceRef<unknown>>();
|
||||
const factoryDeps: { [name in string]: FactoryFunc<unknown> } = {};
|
||||
this.#checkForMissingDeps(factory, pluginId);
|
||||
const rootDeps = new Array<Promise<[name: string, impl: unknown]>>();
|
||||
|
||||
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<any>;
|
||||
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<Promise<[name: string, impl: unknown]>>();
|
||||
|
||||
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;
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T>(api: ServiceRef<T>): FactoryFunc<T> | undefined;
|
||||
get<T>(api: ServiceRef<T>, pluginId: string): Promise<T> | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,63 +19,84 @@
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ServiceRef<T> = {
|
||||
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<T> = ServiceRef<T> & {
|
||||
/**
|
||||
* The default factory that will be used to create service
|
||||
* instances if no other factory is provided.
|
||||
*/
|
||||
__defaultFactory?: (
|
||||
service: ServiceRef<T>,
|
||||
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export type TypesToServiceRef<T> = { [key in keyof T]: ServiceRef<T[key]> };
|
||||
|
||||
/** @public */
|
||||
export type DepsToDepFactories<T> = {
|
||||
[key in keyof T]: (pluginId: string) => Promise<T[key]>;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export type FactoryFunc<Impl> = (pluginId: string) => Promise<Impl>;
|
||||
|
||||
/** @public */
|
||||
export type ServiceFactory<TService = unknown> = {
|
||||
service: ServiceRef<TService>;
|
||||
deps: { [key in string]: ServiceRef<unknown> };
|
||||
factory(deps: { [key in string]: unknown }): Promise<FactoryFunc<TService>>;
|
||||
};
|
||||
export type ServiceFactory<TService = unknown> =
|
||||
| {
|
||||
// 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<TService, 'root'>;
|
||||
deps: { [key in string]: ServiceRef<unknown> };
|
||||
factory(deps: { [key in string]: unknown }): Promise<TService>;
|
||||
}
|
||||
| {
|
||||
scope: 'plugin';
|
||||
service: ServiceRef<TService, 'plugin'>;
|
||||
deps: { [key in string]: ServiceRef<unknown> };
|
||||
factory(deps: { [key in string]: unknown }): Promise<
|
||||
(deps: { [key in string]: unknown }) => Promise<TService>
|
||||
>;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function createServiceRef<T>(options: {
|
||||
id: string;
|
||||
scope?: 'plugin';
|
||||
defaultFactory?: (
|
||||
service: ServiceRef<T>,
|
||||
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>;
|
||||
}): ServiceRef<T> {
|
||||
const { id, defaultFactory } = options;
|
||||
}): ServiceRef<T, 'plugin'>;
|
||||
export function createServiceRef<T>(options: {
|
||||
id: string;
|
||||
scope: 'root';
|
||||
defaultFactory?: (
|
||||
service: ServiceRef<T>,
|
||||
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>;
|
||||
}): ServiceRef<T, 'root'>;
|
||||
export function createServiceRef<T>(options: {
|
||||
id: string;
|
||||
scope?: 'plugin' | 'root';
|
||||
defaultFactory?: (
|
||||
service: ServiceRef<T>,
|
||||
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>;
|
||||
}): ServiceRef<T, 'plugin' | 'root'> {
|
||||
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<T>(options: {
|
||||
},
|
||||
$$ref: 'service', // TODO: declare
|
||||
__defaultFactory: defaultFactory,
|
||||
} as InternalServiceRef<T>;
|
||||
} as ServiceRef<T, typeof scope> & {
|
||||
__defaultFactory?: (
|
||||
service: ServiceRef<T>,
|
||||
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>;
|
||||
};
|
||||
}
|
||||
|
||||
type OnlyRootScopeDependencies<
|
||||
TDeps extends { [key in string]: ServiceRef<unknown> },
|
||||
> = Pick<
|
||||
TDeps,
|
||||
{
|
||||
[name in keyof TDeps]: TDeps[name]['scope'] extends 'root' ? name : never;
|
||||
}[keyof TDeps]
|
||||
>;
|
||||
|
||||
type DependencyRefsToInstances<
|
||||
T extends { [key in string]: ServiceRef<unknown> },
|
||||
> = {
|
||||
[key in keyof T]: T[key] extends ServiceRef<infer TImpl> ? 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<unknown> },
|
||||
TOpts extends { [name in string]: unknown } | undefined = undefined,
|
||||
>(factory: {
|
||||
service: ServiceRef<TService>;
|
||||
deps: TypesToServiceRef<TDeps>;
|
||||
>(config: {
|
||||
service: ServiceRef<TService, TScope>;
|
||||
deps: TDeps;
|
||||
factory(
|
||||
deps: DepsToDepFactories<TDeps>,
|
||||
deps: DependencyRefsToInstances<OnlyRootScopeDependencies<TDeps>>,
|
||||
options: TOpts,
|
||||
): Promise<FactoryFunc<TImpl>>;
|
||||
): TScope extends 'root'
|
||||
? Promise<TImpl>
|
||||
: Promise<(deps: DependencyRefsToInstances<TDeps>) => Promise<TImpl>>;
|
||||
}): undefined extends TOpts
|
||||
? (options?: TOpts) => ServiceFactory<TService>
|
||||
: (options: TOpts) => ServiceFactory<TService> {
|
||||
return (options?: TOpts) => ({
|
||||
service: factory.service,
|
||||
deps: factory.deps,
|
||||
factory(deps: DepsToDepFactories<TDeps>) {
|
||||
return factory.factory(deps, options!);
|
||||
},
|
||||
});
|
||||
return (options?: TOpts) =>
|
||||
({
|
||||
scope: config.service.scope,
|
||||
service: config.service,
|
||||
deps: config.deps,
|
||||
factory(
|
||||
deps: DependencyRefsToInstances<OnlyRootScopeDependencies<TDeps>>,
|
||||
) {
|
||||
return config.factory(deps, options!);
|
||||
},
|
||||
} as ServiceFactory<TService>);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user