Merge pull request #13618 from backstage/mob/refactories

backstage-plugin-api: introduce service scope and rework service factories
This commit is contained in:
Patrik Oldsberg
2022-09-12 17:10:01 +02:00
committed by GitHub
27 changed files with 619 additions and 353 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-plugin-api': patch
---
Service are now scoped to either `'plugin'` or `'root'` scope. Service factories have been updated to provide dependency instances directly rather than factory functions.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-node': patch
---
Updated usage of experimental backend service APIs.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': patch
---
Updated service implementations and backend wiring to support scoped service.
@@ -18,6 +18,7 @@ import { CacheManager } from '@backstage/backend-common';
import {
configServiceRef,
createServiceFactory,
pluginMetadataServiceRef,
cacheServiceRef,
} from '@backstage/backend-plugin-api';
@@ -25,13 +26,13 @@ import {
export const cacheFactory = createServiceFactory({
service: cacheServiceRef,
deps: {
configFactory: configServiceRef,
config: configServiceRef,
plugin: pluginMetadataServiceRef,
},
factory: async ({ configFactory }) => {
const config = await configFactory('root');
async factory({ config }) {
const cacheManager = CacheManager.fromConfig(config);
return async (pluginId: string) => {
return cacheManager.forPlugin(pluginId);
return async ({ plugin }) => {
return cacheManager.forPlugin(plugin.getId());
};
},
});
@@ -19,23 +19,20 @@ import {
configServiceRef,
createServiceFactory,
loggerToWinstonLogger,
loggerServiceRef,
rootLoggerServiceRef,
} from '@backstage/backend-plugin-api';
/** @public */
export const configFactory = createServiceFactory({
service: configServiceRef,
deps: {
loggerFactory: loggerServiceRef,
logger: rootLoggerServiceRef,
},
factory: async ({ loggerFactory }) => {
const logger = await loggerFactory('root');
async factory({ logger }) {
const config = await loadBackendConfig({
argv: process.argv,
logger: loggerToWinstonLogger(logger),
});
return async () => {
return config;
};
return config;
},
});
@@ -19,19 +19,20 @@ import {
configServiceRef,
createServiceFactory,
databaseServiceRef,
pluginMetadataServiceRef,
} from '@backstage/backend-plugin-api';
/** @public */
export const databaseFactory = createServiceFactory({
service: databaseServiceRef,
deps: {
configFactory: configServiceRef,
config: configServiceRef,
plugin: pluginMetadataServiceRef,
},
factory: async ({ configFactory }) => {
const config = await configFactory('root');
async factory({ config }) {
const databaseManager = DatabaseManager.fromConfig(config);
return async (pluginId: string) => {
return databaseManager.forPlugin(pluginId);
return async ({ plugin }) => {
return databaseManager.forPlugin(plugin.getId());
};
},
});
@@ -25,10 +25,9 @@ import {
export const discoveryFactory = createServiceFactory({
service: discoveryServiceRef,
deps: {
configFactory: configServiceRef,
config: configServiceRef,
},
factory: async ({ configFactory }) => {
const config = await configFactory('root');
async factory({ config }) {
const discovery = SingleHostDiscovery.fromConfig(config);
return async () => {
return discovery;
@@ -18,6 +18,7 @@ import {
createServiceFactory,
httpRouterServiceRef,
configServiceRef,
pluginMetadataServiceRef,
} from '@backstage/backend-plugin-api';
import Router from 'express-promise-router';
import { Handler } from 'express';
@@ -27,18 +28,20 @@ import { createServiceBuilder } from '@backstage/backend-common';
export const httpRouterFactory = createServiceFactory({
service: httpRouterServiceRef,
deps: {
configFactory: configServiceRef,
config: configServiceRef,
plugin: pluginMetadataServiceRef,
},
factory: async ({ configFactory }) => {
async factory({ config }) {
const rootRouter = Router();
const service = createServiceBuilder(module)
.loadConfig(await configFactory('root'))
.loadConfig(config)
.addRouter('', rootRouter);
await service.start();
return async (pluginId?: string) => {
return async ({ plugin }) => {
const pluginId = plugin.getId();
const path = pluginId ? `/api/${pluginId}` : '';
return {
use(handler: Handler) {
@@ -14,38 +14,23 @@
* limitations under the License.
*/
import { createRootLogger } from '@backstage/backend-common';
import {
createServiceFactory,
Logger,
loggerServiceRef,
pluginMetadataServiceRef,
rootLoggerServiceRef,
} from '@backstage/backend-plugin-api';
import { Logger as WinstonLogger } from 'winston';
class BackstageLogger implements Logger {
static fromWinston(logger: WinstonLogger): BackstageLogger {
return new BackstageLogger(logger);
}
private constructor(private readonly winston: WinstonLogger) {}
info(message: string, ...meta: any[]): void {
this.winston.info(message, ...meta);
}
child(fields: { [name: string]: string }): Logger {
return new BackstageLogger(this.winston.child(fields));
}
}
/** @public */
export const loggerFactory = createServiceFactory({
service: loggerServiceRef,
deps: {},
factory: async () => {
const root = BackstageLogger.fromWinston(createRootLogger());
return async (pluginId: string) => {
return root.child({ pluginId });
deps: {
rootLogger: rootLoggerServiceRef,
plugin: pluginMetadataServiceRef,
},
async factory({ rootLogger }) {
return async ({ plugin }) => {
return rootLogger.child({ pluginId: plugin.getId() });
};
},
});
@@ -27,20 +27,16 @@ import { ServerPermissionClient } from '@backstage/plugin-permission-node';
export const permissionsFactory = createServiceFactory({
service: permissionsServiceRef,
deps: {
configFactory: configServiceRef,
discoveryFactory: discoveryServiceRef,
tokenManagerFactory: tokenManagerServiceRef,
config: configServiceRef,
discovery: discoveryServiceRef,
tokenManager: tokenManagerServiceRef,
},
factory: async ({ configFactory, discoveryFactory, tokenManagerFactory }) => {
const config = await configFactory('root');
const discovery = await discoveryFactory('root');
const tokenManager = await tokenManagerFactory('root');
const permissions = ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager,
});
return async (_pluginId: string) => {
return permissions;
async factory({ config }) {
return async ({ discovery, tokenManager }) => {
return ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager,
});
};
},
});
@@ -0,0 +1,48 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createRootLogger } from '@backstage/backend-common';
import {
createServiceFactory,
Logger,
rootLoggerServiceRef,
} from '@backstage/backend-plugin-api';
import { Logger as WinstonLogger } from 'winston';
class BackstageLogger implements Logger {
static fromWinston(logger: WinstonLogger): BackstageLogger {
return new BackstageLogger(logger);
}
private constructor(private readonly winston: WinstonLogger) {}
info(message: string, ...meta: any[]): void {
this.winston.info(message, ...meta);
}
child(fields: { [name: string]: string }): Logger {
return new BackstageLogger(this.winston.child(fields));
}
}
/** @public */
export const loggerFactory = createServiceFactory({
service: rootLoggerServiceRef,
deps: {},
async factory() {
return BackstageLogger.fromWinston(createRootLogger());
},
});
@@ -17,6 +17,7 @@
import {
configServiceRef,
createServiceFactory,
pluginMetadataServiceRef,
schedulerServiceRef,
} from '@backstage/backend-plugin-api';
import { TaskScheduler } from '@backstage/backend-tasks';
@@ -25,13 +26,13 @@ import { TaskScheduler } from '@backstage/backend-tasks';
export const schedulerFactory = createServiceFactory({
service: schedulerServiceRef,
deps: {
configFactory: configServiceRef,
config: configServiceRef,
plugin: pluginMetadataServiceRef,
},
factory: async ({ configFactory }) => {
const config = await configFactory('root');
async factory({ config }) {
const taskScheduler = TaskScheduler.fromConfig(config);
return async (pluginId: string) => {
return taskScheduler.forPlugin(pluginId);
return async ({ plugin }) => {
return taskScheduler.forPlugin(plugin.getId());
};
},
});
@@ -27,32 +27,11 @@ import { ServerTokenManager } from '@backstage/backend-common';
export const tokenManagerFactory = createServiceFactory({
service: tokenManagerServiceRef,
deps: {
configFactory: configServiceRef,
loggerFactory: loggerServiceRef,
config: configServiceRef,
logger: loggerServiceRef,
},
factory: async ({ configFactory, loggerFactory }) => {
const logger = await loggerFactory('root');
const config = await configFactory('root');
return async (_pluginId: string) => {
// doesn't the logger want to be inferred from the plugin tho here?
// maybe ... also why do we recreate it every time otherwise
// we should memoize on a per plugin right? so I think it's should be fine to re-use the plugin one
// we shouldn't recreate on a per plugin basis.
// hm - on the other hand, is this really ever called more than once?
// not this function right. should only be called when the plugin requests this serviceRef
// yeah so no need to worry about memo probably
// but we still want to scope the logger to the ServrTokenmanagfer>?
// mm sure maybe
// maybe in this case it doesn't provide so much value b
// oh hang on - isn't it up to THE MANAGER to make a child internally if it wants to do that
// so that it becomes a property intrinsic to that class, no matter how it's constructed
// or is that too much responsibility for it - making the constructor complex so to speak, making it harder to tweak that behavior
// this is not ultra efficient :)
// I think the naming here is wrong to be gonest
// this isn't like the cache manager or the database manager
// the manager name is confusuion i think
// aye perhaps
async factory() {
return async ({ config, logger }) => {
return ServerTokenManager.fromConfig(config, {
logger: loggerToWinstonLogger(logger),
});
@@ -27,15 +27,14 @@ import {
export const urlReaderFactory = createServiceFactory({
service: urlReaderServiceRef,
deps: {
configFactory: configServiceRef,
loggerFactory: loggerServiceRef,
config: configServiceRef,
logger: loggerServiceRef,
},
factory: async ({ configFactory, loggerFactory }) => {
return async (pluginId: string) => {
const logger = await loggerFactory(pluginId);
async factory() {
return async ({ config, logger }) => {
return UrlReaders.default({
config,
logger: loggerToWinstonLogger(logger),
config: await configFactory(pluginId),
});
};
},
@@ -50,11 +50,12 @@ export class BackendInitializer {
if (extensionPoint) {
result.set(name, extensionPoint);
} else {
const factory = await this.#serviceHolder.get(
const impl = await this.#serviceHolder.get(
ref as ServiceRef<unknown>,
pluginId,
);
if (factory) {
result.set(name, await factory(pluginId));
if (impl) {
result.set(name, impl);
} else {
missingRefs.add(ref);
}
@@ -18,158 +18,209 @@ import {
createServiceRef,
createServiceFactory,
ServiceRef,
pluginMetadataServiceRef,
} 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 };
async factory() {
return async () => {
return { x: 1 };
};
},
});
const ref2 = createServiceRef<{ x: number; pluginId: string }>({
const ref2 = createServiceRef<{ x: number }>({
scope: 'root',
id: '2',
});
const sf2 = createServiceFactory({
service: ref2,
deps: {},
factory: async () => {
return async pluginId => {
return { x: 2, pluginId };
};
async factory() {
return { x: 2 };
},
});
const sf2b = createServiceFactory({
service: ref2,
deps: {},
factory: async () => {
return async pluginId => {
return { x: 22, pluginId };
};
async factory() {
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 }),
}),
async factory() {
return 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 }),
async factory() {
return 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 }),
async factory() {
return 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,
});
expect(await registry.get(ref1, 'catalog')).not.toBe(
await registry.get(ref2, 'catalog'),
);
});
it('should not be possible for root scoped services to depend on plugin scoped services', async () => {
const factory = createServiceFactory({
service: ref2,
deps: { pluginDep: ref1 },
async factory() {
return { x: 2 };
},
});
const registry = new ServiceRegistry([factory, sf1]);
await expect(registry.get(ref2, 'catalog')).rejects.toThrow(
"Failed to instantiate 'root' scoped service '2' because it depends on 'plugin' scoped service '1'.",
);
});
it('should be possible for plugin scoped services to depend on root scoped services', async () => {
const factory = createServiceFactory({
service: ref1,
deps: { rootDep: ref2 },
async factory({ rootDep }) {
return async () => ({ x: rootDep.x });
},
});
const registry = new ServiceRegistry([factory, sf2]);
await expect(registry.get(ref1, 'catalog')).resolves.toEqual({
x: 2,
});
});
it('should be possible for root scoped services to depend on root scoped services', async () => {
const ref = createServiceRef<{ x: number }>({ id: 'x', scope: 'root' });
const factory = createServiceFactory({
service: ref,
deps: { rootDep: ref2 },
async factory({ rootDep }) {
return { x: rootDep.x };
},
});
const registry = new ServiceRegistry([factory, sf2]);
await expect(registry.get(ref, 'catalog')).resolves.toEqual({
x: 2,
});
});
it('should return the pluginId from the pluginMetadata service', async () => {
const ref = createServiceRef<{ pluginId: string }>({ id: 'x' });
const factory = createServiceFactory({
service: ref,
deps: { meta: pluginMetadataServiceRef },
async factory() {
return async ({ meta }) => ({ pluginId: meta.getId() });
},
});
const registry = new ServiceRegistry([factory]);
await expect(registry.get(ref, 'catalog')).resolves.toEqual({
pluginId: 'catalog',
});
expect(await factory1('catalog')).not.toBe(await factory2('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 () => {
@@ -177,7 +228,9 @@ describe('ServiceRegistry', () => {
createServiceFactory({
service,
deps: {},
factory: async () => async () => {},
async factory() {
return async () => {};
},
}),
);
const ref = createServiceRef<void>({
@@ -186,17 +239,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 +260,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 +281,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 +296,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 +310,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 +326,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 +337,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 +359,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;
};
});
}
}
+1 -2
View File
@@ -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;
};
/**
+73 -35
View File
@@ -66,10 +66,10 @@ export interface BackendRegistrationPoints {
}
// @public (undocumented)
export const cacheServiceRef: ServiceRef<PluginCacheManager>;
export const cacheServiceRef: ServiceRef<PluginCacheManager, 'plugin'>;
// @public (undocumented)
export const configServiceRef: ServiceRef<Config>;
export const configServiceRef: ServiceRef<Config, 'root'>;
// @public (undocumented)
export function createBackendModule<
@@ -105,22 +105,25 @@ export function createExtensionPoint<T>(options: {
// @public (undocumented)
export function createServiceFactory<
TService,
TScope extends 'root' | 'plugin',
TImpl extends TService,
TDeps extends {
[name in string]: unknown;
[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: ServiceRefsToInstances<TDeps, 'root'>,
options: TOpts,
): Promise<FactoryFunc<TImpl>>;
): TScope extends 'root'
? Promise<TImpl>
: Promise<(deps: ServiceRefsToInstances<TDeps>) => Promise<TImpl>>;
}): undefined extends TOpts
? (options?: TOpts) => ServiceFactory<TService>
: (options: TOpts) => ServiceFactory<TService>;
@@ -128,21 +131,26 @@ export function createServiceFactory<
// @public (undocumented)
export function createServiceRef<T>(options: {
id: string;
scope?: 'plugin';
defaultFactory?: (
service: ServiceRef<T>,
service: ServiceRef<T, 'plugin'>,
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>;
}): ServiceRef<T>;
}): ServiceRef<T, 'plugin'>;
// @public (undocumented)
export const databaseServiceRef: ServiceRef<PluginDatabaseManager>;
export function createServiceRef<T>(options: {
id: string;
scope: 'root';
defaultFactory?: (
service: ServiceRef<T, 'root'>,
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>;
}): ServiceRef<T, 'root'>;
// @public (undocumented)
export type DepsToDepFactories<T> = {
[key in keyof T]: (pluginId: string) => Promise<T[key]>;
};
export const databaseServiceRef: ServiceRef<PluginDatabaseManager, 'plugin'>;
// @public (undocumented)
export const discoveryServiceRef: ServiceRef<PluginEndpointDiscovery>;
export const discoveryServiceRef: ServiceRef<PluginEndpointDiscovery, 'plugin'>;
// @public
export type ExtensionPoint<T> = {
@@ -152,9 +160,6 @@ export type ExtensionPoint<T> = {
$$ref: 'extension-point';
};
// @public (undocumented)
export type FactoryFunc<Impl> = (pluginId: string) => Promise<Impl>;
// @public (undocumented)
export interface HttpRouterService {
// (undocumented)
@@ -162,7 +167,7 @@ export interface HttpRouterService {
}
// @public (undocumented)
export const httpRouterServiceRef: ServiceRef<HttpRouterService>;
export const httpRouterServiceRef: ServiceRef<HttpRouterService, 'plugin'>;
// @public (undocumented)
export interface Logger {
@@ -173,7 +178,7 @@ export interface Logger {
}
// @public (undocumented)
export const loggerServiceRef: ServiceRef<Logger>;
export const loggerServiceRef: ServiceRef<Logger, 'plugin'>;
// @public (undocumented)
export function loggerToWinstonLogger(
@@ -183,33 +188,66 @@ export function loggerToWinstonLogger(
// @public (undocumented)
export const permissionsServiceRef: ServiceRef<
PermissionAuthorizer | PermissionEvaluator
PermissionAuthorizer | PermissionEvaluator,
'plugin'
>;
// @public (undocumented)
export const schedulerServiceRef: ServiceRef<PluginTaskScheduler>;
export interface PluginMetadata {
// (undocumented)
getId(): string;
}
// @public (undocumented)
export type ServiceFactory<TService = unknown> = {
service: ServiceRef<TService>;
deps: {
[key in string]: ServiceRef<unknown>;
};
factory(deps: {
[key in string]: unknown;
}): Promise<FactoryFunc<TService>>;
};
export const pluginMetadataServiceRef: ServiceRef<PluginMetadata, 'plugin'>;
// @public (undocumented)
export const rootLoggerServiceRef: ServiceRef<Logger, 'root'>;
// @public (undocumented)
export const schedulerServiceRef: ServiceRef<PluginTaskScheduler, 'plugin'>;
// @public (undocumented)
export type ServiceFactory<TService = unknown> =
| {
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 type ServiceRef<T> = {
export type ServiceRef<
TService,
TScope extends 'root' | 'plugin' = 'root' | 'plugin',
> = {
id: string;
T: T;
scope: TScope;
T: TService;
toString(): string;
$$ref: 'service';
};
// @public (undocumented)
export const tokenManagerServiceRef: ServiceRef<TokenManager>;
export const tokenManagerServiceRef: ServiceRef<TokenManager, 'plugin'>;
// @public (undocumented)
export type TypesToServiceRef<T> = {
@@ -217,5 +255,5 @@ export type TypesToServiceRef<T> = {
};
// @public (undocumented)
export const urlReaderServiceRef: ServiceRef<UrlReader>;
export const urlReaderServiceRef: ServiceRef<UrlReader, 'plugin'>;
```
@@ -21,5 +21,6 @@ import { createServiceRef } from '../system/types';
* @public
*/
export const configServiceRef = createServiceRef<Config>({
id: 'core.config',
id: 'core.root.config',
scope: 'root',
});
@@ -26,3 +26,6 @@ export { discoveryServiceRef } from './discoveryServiceRef';
export { tokenManagerServiceRef } from './tokenManagerServiceRef';
export { permissionsServiceRef } from './permissionsServiceRef';
export { schedulerServiceRef } from './schedulerServiceRef';
export { rootLoggerServiceRef } from './rootLoggerServiceRef';
export { pluginMetadataServiceRef } from './pluginMetadataServiceRef';
export type { PluginMetadata } from './pluginMetadataServiceRef';
@@ -0,0 +1,31 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createServiceRef } from '../system/types';
/**
* @public
*/
export interface PluginMetadata {
getId(): string;
}
/**
* @public
*/
export const pluginMetadataServiceRef = createServiceRef<PluginMetadata>({
id: 'core.plugin-metadata',
});
@@ -0,0 +1,26 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createServiceRef } from '../system/types';
import { Logger } from './loggerServiceRef';
/**
* @public
*/
export const rootLoggerServiceRef = createServiceRef<Logger>({
id: 'core.root.logger',
scope: 'root',
});
@@ -14,11 +14,5 @@
* limitations under the License.
*/
export type {
ServiceRef,
TypesToServiceRef,
DepsToDepFactories,
FactoryFunc,
ServiceFactory,
} from './types';
export type { ServiceRef, TypesToServiceRef, ServiceFactory } from './types';
export { createServiceRef, createServiceFactory } from './types';
@@ -19,63 +19,87 @@
*
* @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]>;
};
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 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>>;
};
/**
* @public
*/
export function createServiceRef<T>(options: {
id: string;
scope?: 'plugin';
defaultFactory?: (
service: ServiceRef<T>,
service: ServiceRef<T, 'plugin'>,
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>;
}): ServiceRef<T, 'plugin'>;
/** @public */
export function createServiceRef<T>(options: {
id: string;
scope: 'root';
defaultFactory?: (
service: ServiceRef<T, 'root'>,
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>;
}): ServiceRef<T, 'root'>;
export function createServiceRef<T>(options: {
id: string;
scope?: 'plugin' | 'root';
defaultFactory?:
| ((
service: ServiceRef<T, 'plugin'>,
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>)
| ((
service: ServiceRef<T, 'root'>,
) => Promise<ServiceFactory<T> | (() => ServiceFactory<T>)>);
}): ServiceRef<T> {
const { id, defaultFactory } = options;
const { id, scope = 'plugin', defaultFactory } = options;
return {
id,
scope,
get T(): T {
throw new Error(`tried to read ServiceRef.T of ${this}`);
},
@@ -84,32 +108,51 @@ 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>)>;
};
}
/** @ignore */
type ServiceRefsToInstances<
T extends { [key in string]: ServiceRef<unknown> },
TScope extends 'root' | 'plugin' = 'root' | 'plugin',
> = {
[name in {
[key in keyof T]: T[key] extends ServiceRef<unknown, TScope> ? key : never;
}[keyof T]]: T[name] 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: ServiceRefsToInstances<TDeps, 'root'>,
options: TOpts,
): Promise<FactoryFunc<TImpl>>;
): TScope extends 'root'
? Promise<TImpl>
: Promise<(deps: ServiceRefsToInstances<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: ServiceRefsToInstances<TDeps, 'root'>) {
return config.factory(deps, options!);
},
} as ServiceFactory<TService>);
}
+1 -1
View File
@@ -109,7 +109,7 @@ export type CatalogProcessorResult =
| CatalogProcessorRefreshKeysResult;
// @alpha
export const catalogServiceRef: ServiceRef<CatalogApi>;
export const catalogServiceRef: ServiceRef<CatalogApi, 'plugin'>;
// @public
export type DeferredEntity = {
+4 -6
View File
@@ -31,13 +31,11 @@ export const catalogServiceRef = createServiceRef<CatalogApi>({
createServiceFactory({
service,
deps: {
discoveryFactory: discoveryServiceRef,
discoveryApi: discoveryServiceRef,
},
factory: async ({ discoveryFactory }) => {
const discoveryApi = await discoveryFactory('root');
const catalogClient = new CatalogClient({ discoveryApi });
return async _pluginId => {
return catalogClient;
async factory() {
return async ({ discoveryApi }) => {
return new CatalogClient({ discoveryApi });
};
},
}),