backend-plugin-api: make BackendFeature opaque
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/backend-test-utils': patch
|
||||
'@backstage/backend-app-api': patch
|
||||
---
|
||||
|
||||
Updates to match new `BackendFeature` type.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-plugin-api': minor
|
||||
---
|
||||
|
||||
Switched `BackendFeature` to be an opaque type.
|
||||
@@ -26,10 +26,13 @@ import {
|
||||
EnumerableServiceHolder,
|
||||
ServiceOrExtensionPoint,
|
||||
} from './types';
|
||||
// Direct internal import to avoid duplication
|
||||
// eslint-disable-next-line @backstage/no-forbidden-package-imports
|
||||
import { InternalBackendFeature } from '@backstage/backend-plugin-api/src/wiring/types';
|
||||
|
||||
export class BackendInitializer {
|
||||
#startPromise?: Promise<void>;
|
||||
#features = new Map<BackendFeature, unknown>();
|
||||
#features = new Array<InternalBackendFeature>();
|
||||
#registerInits = new Array<BackendRegisterInit>();
|
||||
#extensionPoints = new Map<ExtensionPoint<unknown>, unknown>();
|
||||
#serviceHolder: EnumerableServiceHolder;
|
||||
@@ -74,11 +77,22 @@ export class BackendInitializer {
|
||||
return Object.fromEntries(result);
|
||||
}
|
||||
|
||||
add<TOptions>(feature: BackendFeature, options?: TOptions) {
|
||||
add(feature: BackendFeature) {
|
||||
if (this.#startPromise) {
|
||||
throw new Error('feature can not be added after the backend has started');
|
||||
}
|
||||
this.#features.set(feature, options);
|
||||
if (feature.$$type !== '@backstage/BackendFeature') {
|
||||
throw new Error(
|
||||
`Failed to add feature, invalid type '${feature.$$type}'`,
|
||||
);
|
||||
}
|
||||
const internalFeature = feature as InternalBackendFeature;
|
||||
if (internalFeature.version !== 'v1') {
|
||||
throw new Error(
|
||||
`Failed to add feature, invalid version '${internalFeature.version}'`,
|
||||
);
|
||||
}
|
||||
this.#features.push(internalFeature);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -117,50 +131,39 @@ export class BackendInitializer {
|
||||
}
|
||||
|
||||
// Initialize all features
|
||||
for (const [feature] of this.#features) {
|
||||
const provides = new Set<ExtensionPoint<unknown>>();
|
||||
for (const feature of this.#features) {
|
||||
for (const r of feature.getRegistrations()) {
|
||||
const provides = new Set<ExtensionPoint<unknown>>();
|
||||
|
||||
let registerInit: BackendRegisterInit | undefined = undefined;
|
||||
if (r.type === 'plugin') {
|
||||
for (const [extRef, extImpl] of r.extensionPoints) {
|
||||
if (this.#extensionPoints.has(extRef)) {
|
||||
throw new Error(
|
||||
`ExtensionPoint with ID '${extRef.id}' is already registered`,
|
||||
);
|
||||
}
|
||||
this.#extensionPoints.set(extRef, extImpl);
|
||||
provides.add(extRef);
|
||||
}
|
||||
}
|
||||
|
||||
feature.register({
|
||||
registerExtensionPoint: (extensionPointRef, impl) => {
|
||||
if (registerInit) {
|
||||
throw new Error('registerExtensionPoint called after registerInit');
|
||||
}
|
||||
if (this.#extensionPoints.has(extensionPointRef)) {
|
||||
throw new Error(`API ${extensionPointRef.id} already registered`);
|
||||
}
|
||||
this.#extensionPoints.set(extensionPointRef, impl);
|
||||
provides.add(extensionPointRef);
|
||||
},
|
||||
registerInit: registerOptions => {
|
||||
if (registerInit) {
|
||||
throw new Error('registerInit must only be called once');
|
||||
}
|
||||
registerInit = {
|
||||
id: feature.id,
|
||||
provides,
|
||||
consumes: new Set(Object.values(registerOptions.deps)),
|
||||
deps: registerOptions.deps,
|
||||
init: registerOptions.init as BackendRegisterInit['init'],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
if (!registerInit) {
|
||||
throw new Error(
|
||||
`registerInit was not called by register in ${feature.id}`,
|
||||
);
|
||||
this.#registerInits.push({
|
||||
id: r.type === 'plugin' ? r.pluginId : `${r.pluginId}.${r.moduleId}`,
|
||||
provides,
|
||||
consumes: new Set(Object.values(r.init.deps)),
|
||||
init: r.init,
|
||||
});
|
||||
}
|
||||
|
||||
this.#registerInits.push(registerInit);
|
||||
}
|
||||
|
||||
const orderedRegisterResults = this.#resolveInitOrder(this.#registerInits);
|
||||
|
||||
for (const registerInit of orderedRegisterResults) {
|
||||
const deps = await this.#getInitDeps(registerInit.deps, registerInit.id);
|
||||
await registerInit.init(deps);
|
||||
const deps = await this.#getInitDeps(
|
||||
registerInit.init.deps,
|
||||
registerInit.id,
|
||||
);
|
||||
await registerInit.init.func(deps);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,8 +34,10 @@ export interface BackendRegisterInit {
|
||||
id: string;
|
||||
consumes: Set<ServiceOrExtensionPoint>;
|
||||
provides: Set<ServiceOrExtensionPoint>;
|
||||
deps: { [name: string]: ServiceOrExtensionPoint };
|
||||
init: (deps: { [name: string]: unknown }) => Promise<void>;
|
||||
init: {
|
||||
deps: { [name: string]: ServiceOrExtensionPoint };
|
||||
func: (deps: { [name: string]: unknown }) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,9 +17,7 @@ import { Readable } from 'stream';
|
||||
// @public (undocumented)
|
||||
export interface BackendFeature {
|
||||
// (undocumented)
|
||||
id: string;
|
||||
// (undocumented)
|
||||
register(reg: BackendRegistrationPoints): void;
|
||||
$$type: '@backstage/BackendFeature';
|
||||
}
|
||||
|
||||
// @public
|
||||
@@ -72,26 +70,6 @@ export interface BackendPluginRegistrationPoints {
|
||||
}): void;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface BackendRegistrationPoints {
|
||||
// (undocumented)
|
||||
registerExtensionPoint<TExtensionPoint>(
|
||||
ref: ExtensionPoint<TExtensionPoint>,
|
||||
impl: TExtensionPoint,
|
||||
): void;
|
||||
// (undocumented)
|
||||
registerInit<
|
||||
Deps extends {
|
||||
[name in string]: unknown;
|
||||
},
|
||||
>(options: {
|
||||
deps: {
|
||||
[name in keyof Deps]: ServiceRef<Deps[name]> | ExtensionPoint<Deps[name]>;
|
||||
};
|
||||
init(deps: Deps): Promise<void>;
|
||||
}): void;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface CacheClient {
|
||||
delete(key: string): Promise<void>;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
createBackendPlugin,
|
||||
createExtensionPoint,
|
||||
} from './factories';
|
||||
import { InternalBackendFeature } from './types';
|
||||
|
||||
describe('createExtensionPoint', () => {
|
||||
it('should create an ExtensionPoint', () => {
|
||||
@@ -34,11 +35,30 @@ describe('createBackendPlugin', () => {
|
||||
it('should create a BackendPlugin', () => {
|
||||
const plugin = createBackendPlugin((_options: { a: string }) => ({
|
||||
pluginId: 'x',
|
||||
register() {},
|
||||
register(r) {
|
||||
r.registerInit({ deps: {}, async init() {} });
|
||||
},
|
||||
}));
|
||||
expect(plugin).toBeDefined();
|
||||
expect(plugin({ a: 'a' })).toBeDefined();
|
||||
expect(plugin({ a: 'a' }).id).toBe('x');
|
||||
expect(plugin({ a: 'a' })).toEqual({
|
||||
$$type: '@backstage/BackendFeature',
|
||||
version: 'v1',
|
||||
getRegistrations: expect.any(Function),
|
||||
});
|
||||
expect(
|
||||
(plugin({ a: 'a' }) as InternalBackendFeature).getRegistrations(),
|
||||
).toEqual([
|
||||
{
|
||||
type: 'plugin',
|
||||
pluginId: 'x',
|
||||
extensionPoints: [],
|
||||
init: {
|
||||
deps: expect.any(Object),
|
||||
func: expect.any(Function),
|
||||
},
|
||||
},
|
||||
]);
|
||||
// @ts-expect-error
|
||||
expect(plugin()).toBeDefined();
|
||||
// @ts-expect-error
|
||||
@@ -79,7 +99,11 @@ describe('createBackendPlugin', () => {
|
||||
}));
|
||||
expect(plugin).toBeDefined();
|
||||
expect(plugin({ a: 'a' })).toBeDefined();
|
||||
expect(plugin({ a: 'a' }).id).toBe('x');
|
||||
expect(plugin({ a: 'a' })).toEqual({
|
||||
$$type: '@backstage/BackendFeature',
|
||||
version: 'v1',
|
||||
getRegistrations: expect.any(Function),
|
||||
});
|
||||
// @ts-expect-error
|
||||
expect(plugin()).toBeDefined();
|
||||
// @ts-expect-error
|
||||
@@ -107,11 +131,30 @@ describe('createBackendModule', () => {
|
||||
const mod = createBackendModule((_options: { a: string }) => ({
|
||||
pluginId: 'x',
|
||||
moduleId: 'y',
|
||||
register() {},
|
||||
register(r) {
|
||||
r.registerInit({ deps: {}, async init() {} });
|
||||
},
|
||||
}));
|
||||
expect(mod).toBeDefined();
|
||||
expect(mod({ a: 'a' })).toBeDefined();
|
||||
expect(mod({ a: 'a' }).id).toBe('x.y');
|
||||
expect(mod({ a: 'a' })).toEqual({
|
||||
$$type: '@backstage/BackendFeature',
|
||||
version: 'v1',
|
||||
getRegistrations: expect.any(Function),
|
||||
});
|
||||
expect(
|
||||
(mod({ a: 'a' }) as InternalBackendFeature).getRegistrations(),
|
||||
).toEqual([
|
||||
{
|
||||
type: 'module',
|
||||
pluginId: 'x',
|
||||
moduleId: 'y',
|
||||
init: {
|
||||
deps: expect.any(Object),
|
||||
func: expect.any(Function),
|
||||
},
|
||||
},
|
||||
]);
|
||||
// @ts-expect-error
|
||||
expect(mod()).toBeDefined();
|
||||
// @ts-expect-error
|
||||
@@ -155,7 +198,11 @@ describe('createBackendModule', () => {
|
||||
}));
|
||||
expect(mod).toBeDefined();
|
||||
expect(mod({ a: 'a' })).toBeDefined();
|
||||
expect(mod({ a: 'a' }).id).toBe('x.y');
|
||||
expect(mod({ a: 'a' })).toEqual({
|
||||
$$type: '@backstage/BackendFeature',
|
||||
version: 'v1',
|
||||
getRegistrations: expect.any(Function),
|
||||
});
|
||||
// @ts-expect-error
|
||||
expect(mod()).toBeDefined();
|
||||
// @ts-expect-error
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
BackendPluginRegistrationPoints,
|
||||
BackendFeature,
|
||||
ExtensionPoint,
|
||||
InternalBackendFeature,
|
||||
InternalBackendModuleRegistration,
|
||||
InternalBackendPluginRegistration,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
@@ -86,11 +89,59 @@ export function createBackendPlugin<TOptions extends [options?: object] = []>(
|
||||
config: BackendPluginConfig | ((...params: TOptions) => BackendPluginConfig),
|
||||
): (...params: TOptions) => BackendFeature {
|
||||
const configCallback = typeof config === 'function' ? config : () => config;
|
||||
return (...options: TOptions) => {
|
||||
return (...options: TOptions): InternalBackendFeature => {
|
||||
const c = configCallback(...options);
|
||||
|
||||
let registrations: InternalBackendPluginRegistration[];
|
||||
|
||||
return {
|
||||
...c,
|
||||
id: c.pluginId,
|
||||
$$type: '@backstage/BackendFeature',
|
||||
version: 'v1',
|
||||
getRegistrations() {
|
||||
if (registrations) {
|
||||
return registrations;
|
||||
}
|
||||
const extensionPoints: InternalBackendPluginRegistration['extensionPoints'] =
|
||||
[];
|
||||
let init: InternalBackendPluginRegistration['init'] | undefined =
|
||||
undefined;
|
||||
|
||||
c.register({
|
||||
registerExtensionPoint(ext, impl) {
|
||||
if (init) {
|
||||
throw new Error(
|
||||
'registerExtensionPoint called after registerInit',
|
||||
);
|
||||
}
|
||||
extensionPoints.push([ext, impl]);
|
||||
},
|
||||
registerInit(regInit) {
|
||||
if (init) {
|
||||
throw new Error('registerInit must only be called once');
|
||||
}
|
||||
init = {
|
||||
deps: regInit.deps,
|
||||
func: regInit.init,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
if (!init) {
|
||||
throw new Error(
|
||||
`registerInit was not called by register in ${c.pluginId}`,
|
||||
);
|
||||
}
|
||||
|
||||
registrations = [
|
||||
{
|
||||
type: 'plugin',
|
||||
pluginId: c.pluginId,
|
||||
extensionPoints,
|
||||
init,
|
||||
},
|
||||
];
|
||||
return registrations;
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -127,11 +178,49 @@ export function createBackendModule<TOptions extends [options?: object] = []>(
|
||||
config: BackendModuleConfig | ((...params: TOptions) => BackendModuleConfig),
|
||||
): (...params: TOptions) => BackendFeature {
|
||||
const configCallback = typeof config === 'function' ? config : () => config;
|
||||
return (...options: TOptions) => {
|
||||
return (...options: TOptions): InternalBackendFeature => {
|
||||
const c = configCallback(...options);
|
||||
|
||||
let registrations: InternalBackendModuleRegistration[];
|
||||
|
||||
return {
|
||||
id: `${c.pluginId}.${c.moduleId}`,
|
||||
register: c.register,
|
||||
$$type: '@backstage/BackendFeature',
|
||||
version: 'v1',
|
||||
getRegistrations() {
|
||||
if (registrations) {
|
||||
return registrations;
|
||||
}
|
||||
let init: InternalBackendModuleRegistration['init'] | undefined =
|
||||
undefined;
|
||||
|
||||
c.register({
|
||||
registerInit(regInit) {
|
||||
if (init) {
|
||||
throw new Error('registerInit must only be called once');
|
||||
}
|
||||
init = {
|
||||
deps: regInit.deps,
|
||||
func: regInit.init,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
if (!init) {
|
||||
throw new Error(
|
||||
`registerInit was not called by register in ${c.moduleId} module for ${c.pluginId}`,
|
||||
);
|
||||
}
|
||||
|
||||
registrations = [
|
||||
{
|
||||
type: 'module',
|
||||
pluginId: c.pluginId,
|
||||
moduleId: c.moduleId,
|
||||
init,
|
||||
},
|
||||
];
|
||||
return registrations;
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ export {
|
||||
export type {
|
||||
BackendModuleRegistrationPoints,
|
||||
BackendPluginRegistrationPoints,
|
||||
BackendRegistrationPoints,
|
||||
BackendFeature,
|
||||
ExtensionPoint,
|
||||
} from './types';
|
||||
|
||||
@@ -35,26 +35,6 @@ export type ExtensionPoint<T> = {
|
||||
$$type: '@backstage/ExtensionPoint';
|
||||
};
|
||||
|
||||
/**
|
||||
* The callbacks passed to the `register` method of a backend feature; this is
|
||||
* essentially a superset of {@link BackendPluginRegistrationPoints} and
|
||||
* {@link BackendModuleRegistrationPoints}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface BackendRegistrationPoints {
|
||||
registerExtensionPoint<TExtensionPoint>(
|
||||
ref: ExtensionPoint<TExtensionPoint>,
|
||||
impl: TExtensionPoint,
|
||||
): void;
|
||||
registerInit<Deps extends { [name in string]: unknown }>(options: {
|
||||
deps: {
|
||||
[name in keyof Deps]: ServiceRef<Deps[name]> | ExtensionPoint<Deps[name]>;
|
||||
};
|
||||
init(deps: Deps): Promise<void>;
|
||||
}): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The callbacks passed to the `register` method of a backend plugin.
|
||||
*
|
||||
@@ -89,7 +69,36 @@ export interface BackendModuleRegistrationPoints {
|
||||
|
||||
/** @public */
|
||||
export interface BackendFeature {
|
||||
// TODO(Rugvip): Try to get rid of the ID at this level, allowing for a feature to register multiple features as a bundle
|
||||
id: string;
|
||||
register(reg: BackendRegistrationPoints): void;
|
||||
// NOTE: This type is opaque in order to simplify future API evolution.
|
||||
$$type: '@backstage/BackendFeature';
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface InternalBackendFeature extends BackendFeature {
|
||||
version: 'v1';
|
||||
getRegistrations(): Array<
|
||||
InternalBackendPluginRegistration | InternalBackendModuleRegistration
|
||||
>;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface InternalBackendPluginRegistration {
|
||||
pluginId: string;
|
||||
type: 'plugin';
|
||||
extensionPoints: Array<readonly [ExtensionPoint<unknown>, unknown]>;
|
||||
init: {
|
||||
deps: Record<string, ServiceRef<unknown>>;
|
||||
func(deps: Record<string, unknown>): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface InternalBackendModuleRegistration {
|
||||
pluginId: string;
|
||||
moduleId: string;
|
||||
type: 'module';
|
||||
init: {
|
||||
deps: Record<string, ServiceRef<unknown> | ExtensionPoint<unknown>>;
|
||||
func(deps: Record<string, unknown>): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
BackendFeature,
|
||||
ExtensionPoint,
|
||||
coreServices,
|
||||
createBackendPlugin,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { mockServices } from '../services';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
@@ -193,16 +194,18 @@ export async function startTestBackend<
|
||||
|
||||
backendInstancesToCleanUp.push(backend);
|
||||
|
||||
backend.add({
|
||||
id: `---test-extension-point-registrar`,
|
||||
register(reg) {
|
||||
for (const [ref, impl] of extensionPoints) {
|
||||
reg.registerExtensionPoint(ref, impl);
|
||||
}
|
||||
backend.add(
|
||||
createBackendPlugin({
|
||||
pluginId: `---test-extension-point-registrar`,
|
||||
register(reg) {
|
||||
for (const [ref, impl] of extensionPoints) {
|
||||
reg.registerExtensionPoint(ref, impl);
|
||||
}
|
||||
|
||||
reg.registerInit({ deps: {}, async init() {} });
|
||||
},
|
||||
});
|
||||
reg.registerInit({ deps: {}, async init() {} });
|
||||
},
|
||||
})(),
|
||||
);
|
||||
|
||||
for (const feature of features) {
|
||||
backend.add(feature);
|
||||
|
||||
Reference in New Issue
Block a user