From 8b45adcadd32fa70eee7c9c4a967b6764ec61a4f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 4 Aug 2024 17:37:38 +0200 Subject: [PATCH 01/11] backend-plugin-api: internal refactor to split creators Signed-off-by: Patrik Oldsberg --- ...es.test.ts => createBackendModule.test.ts} | 53 +--- .../src/wiring/createBackendModule.ts | 107 ++++++++ .../src/wiring/createBackendPlugin.test.ts | 55 +++++ .../src/wiring/createBackendPlugin.ts | 100 ++++++++ .../src/wiring/createExtensionPoint.test.ts | 27 +++ .../src/wiring/createExtensionPoint.ts | 58 +++++ .../src/wiring/factories.ts | 229 ------------------ .../backend-plugin-api/src/wiring/index.ts | 16 +- 8 files changed, 354 insertions(+), 291 deletions(-) rename packages/backend-plugin-api/src/wiring/{factories.test.ts => createBackendModule.test.ts} (53%) create mode 100644 packages/backend-plugin-api/src/wiring/createBackendModule.ts create mode 100644 packages/backend-plugin-api/src/wiring/createBackendPlugin.test.ts create mode 100644 packages/backend-plugin-api/src/wiring/createBackendPlugin.ts create mode 100644 packages/backend-plugin-api/src/wiring/createExtensionPoint.test.ts create mode 100644 packages/backend-plugin-api/src/wiring/createExtensionPoint.ts delete mode 100644 packages/backend-plugin-api/src/wiring/factories.ts diff --git a/packages/backend-plugin-api/src/wiring/factories.test.ts b/packages/backend-plugin-api/src/wiring/createBackendModule.test.ts similarity index 53% rename from packages/backend-plugin-api/src/wiring/factories.test.ts rename to packages/backend-plugin-api/src/wiring/createBackendModule.test.ts index 6d5c1f39cd..31496020bf 100644 --- a/packages/backend-plugin-api/src/wiring/factories.test.ts +++ b/packages/backend-plugin-api/src/wiring/createBackendModule.test.ts @@ -14,60 +14,9 @@ * limitations under the License. */ -import { - createBackendModule, - createBackendPlugin, - createExtensionPoint, -} from './factories'; +import { createBackendModule } from './createBackendModule'; import { InternalBackendFeature } from './types'; -describe('createExtensionPoint', () => { - it('should create an ExtensionPoint', () => { - const extensionPoint = createExtensionPoint({ id: 'x' }); - expect(extensionPoint).toBeDefined(); - expect(extensionPoint.id).toBe('x'); - expect(() => extensionPoint.T).not.toThrow(); - expect(String(extensionPoint)).toBe('extensionPoint{x}'); - }); -}); - -describe('createBackendPlugin', () => { - it('should create a BackendPlugin', () => { - const result = createBackendPlugin({ - pluginId: 'x', - register(r) { - r.registerInit({ deps: {}, async init() {} }); - }, - }); - - // legacy form - const legacy = result() as unknown as InternalBackendFeature; - expect(legacy.$$type).toEqual('@backstage/BackendFeature'); - expect(legacy.version).toEqual('v1'); - expect(legacy.getRegistrations).toEqual(expect.any(Function)); - - // new form - const plugin = result as unknown as InternalBackendFeature; - expect(plugin.$$type).toEqual('@backstage/BackendFeature'); - expect(plugin.version).toEqual('v1'); - expect(plugin.getRegistrations).toEqual(expect.any(Function)); - expect(plugin.getRegistrations()).toEqual([ - { - type: 'plugin', - pluginId: 'x', - extensionPoints: [], - init: { - deps: expect.any(Object), - func: expect.any(Function), - }, - }, - ]); - - // @ts-expect-error - expect(plugin({ a: 'a' })).toBeDefined(); - }); -}); - describe('createBackendModule', () => { it('should create a BackendModule', () => { const result = createBackendModule({ diff --git a/packages/backend-plugin-api/src/wiring/createBackendModule.ts b/packages/backend-plugin-api/src/wiring/createBackendModule.ts new file mode 100644 index 0000000000..56f041606e --- /dev/null +++ b/packages/backend-plugin-api/src/wiring/createBackendModule.ts @@ -0,0 +1,107 @@ +/* + * 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 { BackendFeatureCompat } from '../types'; +import { + BackendModuleRegistrationPoints, + InternalBackendModuleRegistration, + InternalBackendPluginRegistration, +} from './types'; + +/** + * The configuration options passed to {@link createBackendModule}. + * + * @public + * @see {@link https://backstage.io/docs/backend-system/architecture/modules | The architecture of modules} + * @see {@link https://backstage.io/docs/backend-system/architecture/naming-patterns | Recommended naming patterns} + */ +export interface CreateBackendModuleOptions { + /** + * Should exactly match the `id` of the plugin that the module extends. + * + * @see {@link https://backstage.io/docs/backend-system/architecture/naming-patterns | Recommended naming patterns} + */ + pluginId: string; + + /** + * The ID of this module, used to identify the module and ensure that it is not installed twice. + */ + moduleId: string; + register(reg: BackendModuleRegistrationPoints): void; +} + +/** + * Creates a new backend module for a given plugin. + * + * @public + * @see {@link https://backstage.io/docs/backend-system/architecture/modules | The architecture of modules} + * @see {@link https://backstage.io/docs/backend-system/architecture/naming-patterns | Recommended naming patterns} + */ +export function createBackendModule( + options: CreateBackendModuleOptions, +): BackendFeatureCompat { + function getRegistrations() { + const extensionPoints: InternalBackendPluginRegistration['extensionPoints'] = + []; + let init: InternalBackendModuleRegistration['init'] | undefined = undefined; + + options.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 ${options.moduleId} module for ${options.pluginId}`, + ); + } + + return [ + { + type: 'module', + pluginId: options.pluginId, + moduleId: options.moduleId, + extensionPoints, + init, + }, + ]; + } + + function backendFeatureCompatWrapper() { + return backendFeatureCompatWrapper; + } + + Object.assign(backendFeatureCompatWrapper, { + $$type: '@backstage/BackendFeature' as const, + version: 'v1', + getRegistrations, + }); + + return backendFeatureCompatWrapper as BackendFeatureCompat; +} diff --git a/packages/backend-plugin-api/src/wiring/createBackendPlugin.test.ts b/packages/backend-plugin-api/src/wiring/createBackendPlugin.test.ts new file mode 100644 index 0000000000..cd1ce164bf --- /dev/null +++ b/packages/backend-plugin-api/src/wiring/createBackendPlugin.test.ts @@ -0,0 +1,55 @@ +/* + * 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 { createBackendPlugin } from './createBackendPlugin'; +import { InternalBackendFeature } from './types'; + +describe('createBackendPlugin', () => { + it('should create a BackendPlugin', () => { + const result = createBackendPlugin({ + pluginId: 'x', + register(r) { + r.registerInit({ deps: {}, async init() {} }); + }, + }); + + // legacy form + const legacy = result() as unknown as InternalBackendFeature; + expect(legacy.$$type).toEqual('@backstage/BackendFeature'); + expect(legacy.version).toEqual('v1'); + expect(legacy.getRegistrations).toEqual(expect.any(Function)); + + // new form + const plugin = result as unknown as InternalBackendFeature; + expect(plugin.$$type).toEqual('@backstage/BackendFeature'); + expect(plugin.version).toEqual('v1'); + expect(plugin.getRegistrations).toEqual(expect.any(Function)); + expect(plugin.getRegistrations()).toEqual([ + { + type: 'plugin', + pluginId: 'x', + extensionPoints: [], + init: { + deps: expect.any(Object), + func: expect.any(Function), + }, + }, + ]); + + // @ts-expect-error + expect(plugin({ a: 'a' })).toBeDefined(); + }); +}); diff --git a/packages/backend-plugin-api/src/wiring/createBackendPlugin.ts b/packages/backend-plugin-api/src/wiring/createBackendPlugin.ts new file mode 100644 index 0000000000..f5984ef24b --- /dev/null +++ b/packages/backend-plugin-api/src/wiring/createBackendPlugin.ts @@ -0,0 +1,100 @@ +/* + * 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 { BackendFeatureCompat } from '../types'; +import { + BackendPluginRegistrationPoints, + InternalBackendPluginRegistration, +} from './types'; + +/** + * The configuration options passed to {@link createBackendPlugin}. + * + * @public + * @see {@link https://backstage.io/docs/backend-system/architecture/plugins | The architecture of plugins} + * @see {@link https://backstage.io/docs/backend-system/architecture/naming-patterns | Recommended naming patterns} + */ +export interface CreateBackendPluginOptions { + /** + * The ID of this plugin. + * + * @see {@link https://backstage.io/docs/backend-system/architecture/naming-patterns | Recommended naming patterns} + */ + pluginId: string; + register(reg: BackendPluginRegistrationPoints): void; +} + +/** + * Creates a new backend plugin. + * + * @public + * @see {@link https://backstage.io/docs/backend-system/architecture/plugins | The architecture of plugins} + * @see {@link https://backstage.io/docs/backend-system/architecture/naming-patterns | Recommended naming patterns} + */ +export function createBackendPlugin( + options: CreateBackendPluginOptions, +): BackendFeatureCompat { + function getRegistrations() { + const extensionPoints: InternalBackendPluginRegistration['extensionPoints'] = + []; + let init: InternalBackendPluginRegistration['init'] | undefined = undefined; + + options.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 ${options.pluginId}`, + ); + } + + return [ + { + type: 'plugin', + pluginId: options.pluginId, + extensionPoints, + init, + }, + ]; + } + + function backendFeatureCompatWrapper() { + return backendFeatureCompatWrapper; + } + + Object.assign(backendFeatureCompatWrapper, { + $$type: '@backstage/BackendFeature' as const, + version: 'v1', + getRegistrations, + }); + + return backendFeatureCompatWrapper as BackendFeatureCompat; +} diff --git a/packages/backend-plugin-api/src/wiring/createExtensionPoint.test.ts b/packages/backend-plugin-api/src/wiring/createExtensionPoint.test.ts new file mode 100644 index 0000000000..825063e845 --- /dev/null +++ b/packages/backend-plugin-api/src/wiring/createExtensionPoint.test.ts @@ -0,0 +1,27 @@ +/* + * 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 { createExtensionPoint } from './createExtensionPoint'; + +describe('createExtensionPoint', () => { + it('should create an ExtensionPoint', () => { + const extensionPoint = createExtensionPoint({ id: 'x' }); + expect(extensionPoint).toBeDefined(); + expect(extensionPoint.id).toBe('x'); + expect(() => extensionPoint.T).not.toThrow(); + expect(String(extensionPoint)).toBe('extensionPoint{x}'); + }); +}); diff --git a/packages/backend-plugin-api/src/wiring/createExtensionPoint.ts b/packages/backend-plugin-api/src/wiring/createExtensionPoint.ts new file mode 100644 index 0000000000..9dbae249ea --- /dev/null +++ b/packages/backend-plugin-api/src/wiring/createExtensionPoint.ts @@ -0,0 +1,58 @@ +/* + * 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 { ExtensionPoint } from './types'; + +/** + * The configuration options passed to {@link createExtensionPoint}. + * + * @public + * @see {@link https://backstage.io/docs/backend-system/architecture/extension-points | The architecture of extension points} + * @see {@link https://backstage.io/docs/backend-system/architecture/naming-patterns | Recommended naming patterns} + */ +export interface CreateExtensionPointOptions { + /** + * The ID of this extension point. + * + * @see {@link https://backstage.io/docs/backend-system/architecture/naming-patterns | Recommended naming patterns} + */ + id: string; +} + +/** + * Creates a new backend extension point. + * + * @public + * @see {@link https://backstage.io/docs/backend-system/architecture/extension-points | The architecture of extension points} + */ +export function createExtensionPoint( + options: CreateExtensionPointOptions, +): ExtensionPoint { + return { + id: options.id, + get T(): T { + if (process.env.NODE_ENV === 'test') { + // Avoid throwing errors so tests asserting extensions' properties cannot be easily broken + return null as T; + } + throw new Error(`tried to read ExtensionPoint.T of ${this}`); + }, + toString() { + return `extensionPoint{${options.id}}`; + }, + $$type: '@backstage/ExtensionPoint', + }; +} diff --git a/packages/backend-plugin-api/src/wiring/factories.ts b/packages/backend-plugin-api/src/wiring/factories.ts deleted file mode 100644 index 81e2247152..0000000000 --- a/packages/backend-plugin-api/src/wiring/factories.ts +++ /dev/null @@ -1,229 +0,0 @@ -/* - * 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 { BackendFeatureCompat } from '../types'; -import { - BackendModuleRegistrationPoints, - BackendPluginRegistrationPoints, - ExtensionPoint, - InternalBackendModuleRegistration, - InternalBackendPluginRegistration, -} from './types'; - -/** - * The configuration options passed to {@link createExtensionPoint}. - * - * @public - * @see {@link https://backstage.io/docs/backend-system/architecture/extension-points | The architecture of extension points} - * @see {@link https://backstage.io/docs/backend-system/architecture/naming-patterns | Recommended naming patterns} - */ -export interface CreateExtensionPointOptions { - /** - * The ID of this extension point. - * - * @see {@link https://backstage.io/docs/backend-system/architecture/naming-patterns | Recommended naming patterns} - */ - id: string; -} - -/** - * Creates a new backend extension point. - * - * @public - * @see {@link https://backstage.io/docs/backend-system/architecture/extension-points | The architecture of extension points} - */ -export function createExtensionPoint( - options: CreateExtensionPointOptions, -): ExtensionPoint { - return { - id: options.id, - get T(): T { - if (process.env.NODE_ENV === 'test') { - // Avoid throwing errors so tests asserting extensions' properties cannot be easily broken - return null as T; - } - throw new Error(`tried to read ExtensionPoint.T of ${this}`); - }, - toString() { - return `extensionPoint{${options.id}}`; - }, - $$type: '@backstage/ExtensionPoint', - }; -} - -/** - * The configuration options passed to {@link createBackendPlugin}. - * - * @public - * @see {@link https://backstage.io/docs/backend-system/architecture/plugins | The architecture of plugins} - * @see {@link https://backstage.io/docs/backend-system/architecture/naming-patterns | Recommended naming patterns} - */ -export interface CreateBackendPluginOptions { - /** - * The ID of this plugin. - * - * @see {@link https://backstage.io/docs/backend-system/architecture/naming-patterns | Recommended naming patterns} - */ - pluginId: string; - register(reg: BackendPluginRegistrationPoints): void; -} - -/** - * Creates a new backend plugin. - * - * @public - * @see {@link https://backstage.io/docs/backend-system/architecture/plugins | The architecture of plugins} - * @see {@link https://backstage.io/docs/backend-system/architecture/naming-patterns | Recommended naming patterns} - */ -export function createBackendPlugin( - options: CreateBackendPluginOptions, -): BackendFeatureCompat { - function getRegistrations() { - const extensionPoints: InternalBackendPluginRegistration['extensionPoints'] = - []; - let init: InternalBackendPluginRegistration['init'] | undefined = undefined; - - options.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 ${options.pluginId}`, - ); - } - - return [ - { - type: 'plugin', - pluginId: options.pluginId, - extensionPoints, - init, - }, - ]; - } - - function backendFeatureCompatWrapper() { - return backendFeatureCompatWrapper; - } - - Object.assign(backendFeatureCompatWrapper, { - $$type: '@backstage/BackendFeature' as const, - version: 'v1', - getRegistrations, - }); - - return backendFeatureCompatWrapper as BackendFeatureCompat; -} - -/** - * The configuration options passed to {@link createBackendModule}. - * - * @public - * @see {@link https://backstage.io/docs/backend-system/architecture/modules | The architecture of modules} - * @see {@link https://backstage.io/docs/backend-system/architecture/naming-patterns | Recommended naming patterns} - */ -export interface CreateBackendModuleOptions { - /** - * Should exactly match the `id` of the plugin that the module extends. - * - * @see {@link https://backstage.io/docs/backend-system/architecture/naming-patterns | Recommended naming patterns} - */ - pluginId: string; - - /** - * The ID of this module, used to identify the module and ensure that it is not installed twice. - */ - moduleId: string; - register(reg: BackendModuleRegistrationPoints): void; -} - -/** - * Creates a new backend module for a given plugin. - * - * @public - * @see {@link https://backstage.io/docs/backend-system/architecture/modules | The architecture of modules} - * @see {@link https://backstage.io/docs/backend-system/architecture/naming-patterns | Recommended naming patterns} - */ -export function createBackendModule( - options: CreateBackendModuleOptions, -): BackendFeatureCompat { - function getRegistrations() { - const extensionPoints: InternalBackendPluginRegistration['extensionPoints'] = - []; - let init: InternalBackendModuleRegistration['init'] | undefined = undefined; - - options.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 ${options.moduleId} module for ${options.pluginId}`, - ); - } - - return [ - { - type: 'module', - pluginId: options.pluginId, - moduleId: options.moduleId, - extensionPoints, - init, - }, - ]; - } - - function backendFeatureCompatWrapper() { - return backendFeatureCompatWrapper; - } - - Object.assign(backendFeatureCompatWrapper, { - $$type: '@backstage/BackendFeature' as const, - version: 'v1', - getRegistrations, - }); - - return backendFeatureCompatWrapper as BackendFeatureCompat; -} diff --git a/packages/backend-plugin-api/src/wiring/index.ts b/packages/backend-plugin-api/src/wiring/index.ts index 5197cec050..5f7960e9cd 100644 --- a/packages/backend-plugin-api/src/wiring/index.ts +++ b/packages/backend-plugin-api/src/wiring/index.ts @@ -14,17 +14,13 @@ * limitations under the License. */ -import type { - CreateBackendPluginOptions, - CreateBackendModuleOptions, - CreateExtensionPointOptions, -} from './factories'; +import { type CreateBackendModuleOptions } from './createBackendModule'; +import { type CreateBackendPluginOptions } from './createBackendPlugin'; +import { type CreateExtensionPointOptions } from './createExtensionPoint'; -export { - createBackendModule, - createBackendPlugin, - createExtensionPoint, -} from './factories'; +export { createBackendModule } from './createBackendModule'; +export { createBackendPlugin } from './createBackendPlugin'; +export { createExtensionPoint } from './createExtensionPoint'; export type { BackendModuleRegistrationPoints, From 6061061a818900224275c157d275621c32c53bf0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 5 Aug 2024 01:26:08 +0200 Subject: [PATCH 02/11] backend-plugin-api: add createBackendFeatureLoader Signed-off-by: Patrik Oldsberg --- .changeset/calm-crabs-drop.md | 5 + packages/backend-plugin-api/api-report.md | 41 ++++ .../wiring/createBackendFeatureLoader.test.ts | 213 ++++++++++++++++++ .../src/wiring/createBackendFeatureLoader.ts | 73 ++++++ .../backend-plugin-api/src/wiring/index.ts | 4 + .../backend-plugin-api/src/wiring/types.ts | 14 +- 6 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 .changeset/calm-crabs-drop.md create mode 100644 packages/backend-plugin-api/src/wiring/createBackendFeatureLoader.test.ts create mode 100644 packages/backend-plugin-api/src/wiring/createBackendFeatureLoader.ts diff --git a/.changeset/calm-crabs-drop.md b/.changeset/calm-crabs-drop.md new file mode 100644 index 0000000000..09bed7c091 --- /dev/null +++ b/.changeset/calm-crabs-drop.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-plugin-api': patch +--- + +Added `createBackendFeatureLoader`, which can be used to create an installable backend feature that can in turn load in additional backend features in a dynamic way. diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index b3c35d3699..3bd112682a 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -213,6 +213,47 @@ export namespace coreServices { identity: ServiceRef; } +// @public +export function createBackendFeatureLoader< + TDeps extends { + [name in string]: unknown; + }, +>(options: CreateBackendFeatureLoaderOptions): BackendFeature; + +// @public +export interface CreateBackendFeatureLoaderOptions< + TDeps extends { + [name in string]: unknown; + }, +> { + // (undocumented) + deps?: { + [name in keyof TDeps]: ServiceRef; + }; + // (undocumented) + loader(deps: TDeps): + | Iterable< + | BackendFeature + | Promise<{ + default: BackendFeature; + }> + > + | Promise< + Iterable< + | BackendFeature + | Promise<{ + default: BackendFeature; + }> + > + > + | AsyncIterable< + | BackendFeature + | { + default: BackendFeature; + } + >; +} + // @public export function createBackendModule( options: CreateBackendModuleOptions, diff --git a/packages/backend-plugin-api/src/wiring/createBackendFeatureLoader.test.ts b/packages/backend-plugin-api/src/wiring/createBackendFeatureLoader.test.ts new file mode 100644 index 0000000000..98471bef4c --- /dev/null +++ b/packages/backend-plugin-api/src/wiring/createBackendFeatureLoader.test.ts @@ -0,0 +1,213 @@ +/* + * 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 { coreServices, createServiceFactory } from '../services'; +import { BackendFeature } from '../types'; +import { createBackendFeatureLoader } from './createBackendFeatureLoader'; +import { createBackendPlugin } from './createBackendPlugin'; +import { + InternalBackendFeature, + InternalBackendFeatureLoaderRegistration, +} from './types'; + +describe('createBackendFeatureLoader', () => { + it('should create an empty feature loader', () => { + const result = createBackendFeatureLoader({ + deps: {}, + loader: () => [], + }); + + const plugin = result as unknown as InternalBackendFeature; + expect(plugin.$$type).toEqual('@backstage/BackendFeature'); + expect(plugin.version).toEqual('v1'); + expect(plugin.getRegistrations).toEqual(expect.any(Function)); + expect(plugin.getRegistrations()).toEqual([ + { + type: 'loader', + description: expect.stringMatching(/^created at '.*'$/), + deps: expect.any(Object), + loader: expect.any(Function), + }, + ]); + }); + + it('should create a feature loader that loads a few features', async () => { + const result = createBackendFeatureLoader({ + deps: { + config: coreServices.rootConfig, + }, + loader({ config: _unused }) { + return [ + createBackendPlugin({ + pluginId: 'x', + register() {}, + })(), + createServiceFactory({ + service: coreServices.pluginMetadata, + deps: {}, + factory: () => ({ getId: () => 'fake-id' }), + })(), + // Dynamic import format + Promise.resolve({ + default: createBackendPlugin({ + pluginId: 'y', + register() {}, + })(), + }), + ]; + }, + }) as InternalBackendFeature; + + expect(result.$$type).toEqual('@backstage/BackendFeature'); + expect(result.version).toEqual('v1'); + expect(result.getRegistrations).toEqual(expect.any(Function)); + + const registrations = result.getRegistrations(); + expect(registrations).toEqual([ + { + type: 'loader', + description: expect.stringMatching(/^created at '.*'$/), + deps: expect.any(Object), + loader: expect.any(Function), + }, + ]); + + const results = await (registrations[0] as any).loader({ config: {} }); + expect(results.length).toBe(3); + const [pluginX, serviceFactory, pluginY] = results; + expect(pluginX.$$type).toBe('@backstage/BackendFeature'); + expect(serviceFactory.$$type).toBe('@backstage/BackendFeature'); + expect(pluginY.$$type).toBe('@backstage/BackendFeature'); + expect(serviceFactory.service.id).toBe(coreServices.pluginMetadata.id); + }); + + it('should support multiple output formats', async () => { + const feature = createBackendPlugin({ pluginId: 'x', register() {} })(); + const dynamicFeature = Promise.resolve({ default: feature }); + + async function extractResult(f: BackendFeature) { + const internal = f as InternalBackendFeature; + const reg = + internal.getRegistrations()[0] as InternalBackendFeatureLoaderRegistration; + return reg.loader({}); + } + + await expect( + extractResult( + createBackendFeatureLoader({ + loader() { + return [feature]; + }, + }), + ), + ).resolves.toEqual([feature]); + + await expect( + extractResult( + createBackendFeatureLoader({ + async loader() { + return [feature]; + }, + }), + ), + ).resolves.toEqual([feature]); + + await expect( + extractResult( + createBackendFeatureLoader({ + *loader() { + yield feature; + }, + }), + ), + ).resolves.toEqual([feature]); + + await expect( + extractResult( + createBackendFeatureLoader({ + async *loader() { + yield feature; + }, + }), + ), + ).resolves.toEqual([feature]); + + await expect( + extractResult( + createBackendFeatureLoader({ + loader() { + return [dynamicFeature]; + }, + }), + ), + ).resolves.toEqual([feature]); + + await expect( + extractResult( + createBackendFeatureLoader({ + async loader() { + return [dynamicFeature]; + }, + }), + ), + ).resolves.toEqual([feature]); + + await expect( + extractResult( + createBackendFeatureLoader({ + *loader() { + yield dynamicFeature; + }, + }), + ), + ).resolves.toEqual([feature]); + + await expect( + extractResult( + createBackendFeatureLoader({ + async *loader() { + yield dynamicFeature; + }, + }), + ), + ).resolves.toEqual([feature]); + }); + + it('should only allow dependencies on root scoped services', () => { + createBackendFeatureLoader({ + deps: { + rootLogger: coreServices.rootLogger, + }, + loader: () => [], + }); + createBackendFeatureLoader({ + deps: { + // @ts-expect-error + logger: coreServices.logger, + }, + loader: () => [], + }); + createBackendFeatureLoader({ + deps: { + rootLogger: coreServices.rootLogger, + // @ts-expect-error + logger: coreServices.logger, + }, + loader: () => [], + }); + expect('test').toBe('test'); + }); +}); diff --git a/packages/backend-plugin-api/src/wiring/createBackendFeatureLoader.ts b/packages/backend-plugin-api/src/wiring/createBackendFeatureLoader.ts new file mode 100644 index 0000000000..9722cc534f --- /dev/null +++ b/packages/backend-plugin-api/src/wiring/createBackendFeatureLoader.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2024 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 { ServiceRef } from '../services'; +import { BackendFeature } from '../types'; +import { InternalBackendFeature } from './types'; + +/** + * @public + * Options for creating a new backend feature loader. + */ +export interface CreateBackendFeatureLoaderOptions< + TDeps extends { [name in string]: unknown }, +> { + deps?: { + [name in keyof TDeps]: ServiceRef; + }; + loader( + deps: TDeps, + ): + | Iterable> + | Promise>> + | AsyncIterable; +} + +/** + * @public + * Creates a new backend feature loader. + */ +export function createBackendFeatureLoader< + TDeps extends { [name in string]: unknown }, +>(options: CreateBackendFeatureLoaderOptions): BackendFeature { + const registrations = [ + { + type: 'loader', + description: `created at '${new Date().toISOString()}'`, + deps: options.deps, + async loader(deps: TDeps) { + const it = await options.loader(deps); + const result = new Array(); + for await (const item of it) { + if ('$$type' in item && item.$$type === '@backstage/BackendFeature') { + result.push(item); + } else if ('default' in item) { + result.push(item.default); + } else { + throw new Error(`Invalid item "${item}"`); + } + } + return result; + }, + }, + ]; + + return { + $$type: '@backstage/BackendFeature', + version: 'v1', + getRegistrations: () => registrations, + } as InternalBackendFeature; +} diff --git a/packages/backend-plugin-api/src/wiring/index.ts b/packages/backend-plugin-api/src/wiring/index.ts index 5f7960e9cd..95eea6c3d4 100644 --- a/packages/backend-plugin-api/src/wiring/index.ts +++ b/packages/backend-plugin-api/src/wiring/index.ts @@ -21,6 +21,10 @@ import { type CreateExtensionPointOptions } from './createExtensionPoint'; export { createBackendModule } from './createBackendModule'; export { createBackendPlugin } from './createBackendPlugin'; export { createExtensionPoint } from './createExtensionPoint'; +export { + createBackendFeatureLoader, + type CreateBackendFeatureLoaderOptions, +} from './createBackendFeatureLoader'; export type { BackendModuleRegistrationPoints, diff --git a/packages/backend-plugin-api/src/wiring/types.ts b/packages/backend-plugin-api/src/wiring/types.ts index 55d43ccb7c..7569cc2b41 100644 --- a/packages/backend-plugin-api/src/wiring/types.ts +++ b/packages/backend-plugin-api/src/wiring/types.ts @@ -76,7 +76,9 @@ export interface BackendModuleRegistrationPoints { export interface InternalBackendFeature extends BackendFeature { version: 'v1'; getRegistrations(): Array< - InternalBackendPluginRegistration | InternalBackendModuleRegistration + | InternalBackendPluginRegistration + | InternalBackendModuleRegistration + | InternalBackendFeatureLoaderRegistration >; } @@ -102,3 +104,13 @@ export interface InternalBackendModuleRegistration { func(deps: Record): Promise; }; } + +/** + * @public + */ +export interface InternalBackendFeatureLoaderRegistration { + type: 'loader'; + description: string; + deps: Record>; + loader(deps: Record): Promise; +} From 8f70547ea54ccf19b071312cd2894cc10c423fd7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 5 Aug 2024 01:43:06 +0200 Subject: [PATCH 03/11] backend-plugin-api: refactor to flatten internal backend feature loader Signed-off-by: Patrik Oldsberg --- .../src/services/system/types.test.ts | 10 +++- .../src/services/system/types.ts | 3 ++ .../wiring/createBackendFeatureLoader.test.ts | 52 ++++++------------- .../src/wiring/createBackendFeatureLoader.ts | 44 +++++++--------- .../backend-plugin-api/src/wiring/types.ts | 10 ++-- 5 files changed, 53 insertions(+), 66 deletions(-) diff --git a/packages/backend-plugin-api/src/services/system/types.test.ts b/packages/backend-plugin-api/src/services/system/types.test.ts index adf097761b..32c1774455 100644 --- a/packages/backend-plugin-api/src/services/system/types.test.ts +++ b/packages/backend-plugin-api/src/services/system/types.test.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { createServiceFactory, createServiceRef } from './types'; +import { + InternalServiceFactory, + createServiceFactory, + createServiceRef, +} from './types'; const ref = createServiceRef({ id: 'x' }); const rootDep = createServiceRef({ id: 'y', scope: 'root' }); @@ -36,6 +40,10 @@ describe('createServiceFactory', () => { }, }); expect(metaFactory).toEqual(expect.any(Function)); + expect(metaFactory().$$type).toBe('@backstage/BackendFeature'); + expect((metaFactory() as InternalServiceFactory).featureType).toBe( + 'service', + ); expect(metaFactory().service).toBe(ref); // @ts-expect-error diff --git a/packages/backend-plugin-api/src/services/system/types.ts b/packages/backend-plugin-api/src/services/system/types.ts index d31350806d..ccec36e39a 100644 --- a/packages/backend-plugin-api/src/services/system/types.ts +++ b/packages/backend-plugin-api/src/services/system/types.ts @@ -78,6 +78,7 @@ export interface InternalServiceFactory< TScope extends 'plugin' | 'root' = 'plugin' | 'root', > extends ServiceFactory { version: 'v1'; + featureType: 'service'; initialization?: 'always' | 'lazy'; deps: { [key in string]: ServiceRef }; createRootContext?(deps: { [key in string]: unknown }): Promise; @@ -307,6 +308,7 @@ export function createServiceFactory< return { $$type: '@backstage/BackendFeature', version: 'v1', + featureType: 'service', service: c.service, initialization: c.initialization, deps: c.deps, @@ -322,6 +324,7 @@ export function createServiceFactory< return { $$type: '@backstage/BackendFeature', version: 'v1', + featureType: 'service', service: c.service, initialization: c.initialization, ...('createRootContext' in c diff --git a/packages/backend-plugin-api/src/wiring/createBackendFeatureLoader.test.ts b/packages/backend-plugin-api/src/wiring/createBackendFeatureLoader.test.ts index 98471bef4c..37fc079551 100644 --- a/packages/backend-plugin-api/src/wiring/createBackendFeatureLoader.test.ts +++ b/packages/backend-plugin-api/src/wiring/createBackendFeatureLoader.test.ts @@ -15,33 +15,25 @@ */ import { coreServices, createServiceFactory } from '../services'; +import { InternalServiceFactory } from '../services/system/types'; import { BackendFeature } from '../types'; import { createBackendFeatureLoader } from './createBackendFeatureLoader'; import { createBackendPlugin } from './createBackendPlugin'; -import { - InternalBackendFeature, - InternalBackendFeatureLoaderRegistration, -} from './types'; +import { InternalBackendFeatureLoader } from './types'; describe('createBackendFeatureLoader', () => { it('should create an empty feature loader', () => { const result = createBackendFeatureLoader({ deps: {}, loader: () => [], - }); + }) as InternalBackendFeatureLoader; - const plugin = result as unknown as InternalBackendFeature; - expect(plugin.$$type).toEqual('@backstage/BackendFeature'); - expect(plugin.version).toEqual('v1'); - expect(plugin.getRegistrations).toEqual(expect.any(Function)); - expect(plugin.getRegistrations()).toEqual([ - { - type: 'loader', - description: expect.stringMatching(/^created at '.*'$/), - deps: expect.any(Object), - loader: expect.any(Function), - }, - ]); + expect(result.$$type).toEqual('@backstage/BackendFeature'); + expect(result.version).toEqual('v1'); + expect(result.featureType).toEqual('loader'); + expect(result.deps).toEqual({}); + expect(result.loader).toEqual(expect.any(Function)); + expect(result.description).toMatch(/^created at '.*'$/); }); it('should create a feature loader that loads a few features', async () => { @@ -69,29 +61,21 @@ describe('createBackendFeatureLoader', () => { }), ]; }, - }) as InternalBackendFeature; + }) as InternalBackendFeatureLoader; expect(result.$$type).toEqual('@backstage/BackendFeature'); expect(result.version).toEqual('v1'); - expect(result.getRegistrations).toEqual(expect.any(Function)); + expect(result.featureType).toEqual('loader'); - const registrations = result.getRegistrations(); - expect(registrations).toEqual([ - { - type: 'loader', - description: expect.stringMatching(/^created at '.*'$/), - deps: expect.any(Object), - loader: expect.any(Function), - }, - ]); - - const results = await (registrations[0] as any).loader({ config: {} }); + const results = await result.loader({ config: {} }); expect(results.length).toBe(3); const [pluginX, serviceFactory, pluginY] = results; expect(pluginX.$$type).toBe('@backstage/BackendFeature'); expect(serviceFactory.$$type).toBe('@backstage/BackendFeature'); expect(pluginY.$$type).toBe('@backstage/BackendFeature'); - expect(serviceFactory.service.id).toBe(coreServices.pluginMetadata.id); + expect((serviceFactory as InternalServiceFactory).service.id).toBe( + coreServices.pluginMetadata.id, + ); }); it('should support multiple output formats', async () => { @@ -99,10 +83,8 @@ describe('createBackendFeatureLoader', () => { const dynamicFeature = Promise.resolve({ default: feature }); async function extractResult(f: BackendFeature) { - const internal = f as InternalBackendFeature; - const reg = - internal.getRegistrations()[0] as InternalBackendFeatureLoaderRegistration; - return reg.loader({}); + const internal = f as InternalBackendFeatureLoader; + return internal.loader({}); } await expect( diff --git a/packages/backend-plugin-api/src/wiring/createBackendFeatureLoader.ts b/packages/backend-plugin-api/src/wiring/createBackendFeatureLoader.ts index 9722cc534f..de26b5154d 100644 --- a/packages/backend-plugin-api/src/wiring/createBackendFeatureLoader.ts +++ b/packages/backend-plugin-api/src/wiring/createBackendFeatureLoader.ts @@ -16,7 +16,7 @@ import { ServiceRef } from '../services'; import { BackendFeature } from '../types'; -import { InternalBackendFeature } from './types'; +import { InternalBackendFeatureLoader } from './types'; /** * @public @@ -43,31 +43,25 @@ export interface CreateBackendFeatureLoaderOptions< export function createBackendFeatureLoader< TDeps extends { [name in string]: unknown }, >(options: CreateBackendFeatureLoaderOptions): BackendFeature { - const registrations = [ - { - type: 'loader', - description: `created at '${new Date().toISOString()}'`, - deps: options.deps, - async loader(deps: TDeps) { - const it = await options.loader(deps); - const result = new Array(); - for await (const item of it) { - if ('$$type' in item && item.$$type === '@backstage/BackendFeature') { - result.push(item); - } else if ('default' in item) { - result.push(item.default); - } else { - throw new Error(`Invalid item "${item}"`); - } - } - return result; - }, - }, - ]; - return { $$type: '@backstage/BackendFeature', version: 'v1', - getRegistrations: () => registrations, - } as InternalBackendFeature; + featureType: 'loader', + description: `created at '${new Date().toISOString()}'`, + deps: options.deps, + async loader(deps: TDeps) { + const it = await options.loader(deps); + const result = new Array(); + for await (const item of it) { + if ('$$type' in item && item.$$type === '@backstage/BackendFeature') { + result.push(item); + } else if ('default' in item) { + result.push(item.default); + } else { + throw new Error(`Invalid item "${item}"`); + } + } + return result; + }, + } as InternalBackendFeatureLoader; } diff --git a/packages/backend-plugin-api/src/wiring/types.ts b/packages/backend-plugin-api/src/wiring/types.ts index 7569cc2b41..df0ca93089 100644 --- a/packages/backend-plugin-api/src/wiring/types.ts +++ b/packages/backend-plugin-api/src/wiring/types.ts @@ -75,10 +75,9 @@ export interface BackendModuleRegistrationPoints { /** @internal */ export interface InternalBackendFeature extends BackendFeature { version: 'v1'; + featureType: 'registrations'; getRegistrations(): Array< - | InternalBackendPluginRegistration - | InternalBackendModuleRegistration - | InternalBackendFeatureLoaderRegistration + InternalBackendPluginRegistration | InternalBackendModuleRegistration >; } @@ -108,8 +107,9 @@ export interface InternalBackendModuleRegistration { /** * @public */ -export interface InternalBackendFeatureLoaderRegistration { - type: 'loader'; +export interface InternalBackendFeatureLoader extends BackendFeature { + version: 'v1'; + featureType: 'loader'; description: string; deps: Record>; loader(deps: Record): Promise; From dc0d284419732fde05aa4c1dbecf71a1bb3ec3ca Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 5 Aug 2024 09:53:54 +0200 Subject: [PATCH 04/11] backend-plugin-api: fix feature loader description Signed-off-by: Patrik Oldsberg --- .../src/wiring/createBackendFeatureLoader.ts | 3 ++- .../src/wiring/describeParentCallSite.ts | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 packages/backend-plugin-api/src/wiring/describeParentCallSite.ts diff --git a/packages/backend-plugin-api/src/wiring/createBackendFeatureLoader.ts b/packages/backend-plugin-api/src/wiring/createBackendFeatureLoader.ts index de26b5154d..cfa3e96c65 100644 --- a/packages/backend-plugin-api/src/wiring/createBackendFeatureLoader.ts +++ b/packages/backend-plugin-api/src/wiring/createBackendFeatureLoader.ts @@ -16,6 +16,7 @@ import { ServiceRef } from '../services'; import { BackendFeature } from '../types'; +import { describeParentCallSite } from './describeParentCallSite'; import { InternalBackendFeatureLoader } from './types'; /** @@ -47,7 +48,7 @@ export function createBackendFeatureLoader< $$type: '@backstage/BackendFeature', version: 'v1', featureType: 'loader', - description: `created at '${new Date().toISOString()}'`, + description: `created at '${describeParentCallSite()}'`, deps: options.deps, async loader(deps: TDeps) { const it = await options.loader(deps); diff --git a/packages/backend-plugin-api/src/wiring/describeParentCallSite.ts b/packages/backend-plugin-api/src/wiring/describeParentCallSite.ts new file mode 100644 index 0000000000..35603e33b0 --- /dev/null +++ b/packages/backend-plugin-api/src/wiring/describeParentCallSite.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2023 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. + */ + +// Single re-export to avoid doing this import in multiple places, but still +// avoid duplicate declarations because this one is a bit tricky. +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +export { describeParentCallSite } from '../../../frontend-plugin-api/src/routing/describeParentCallSite'; From d4d048b3fffb61d76f47efb065919d7ff80da635 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 5 Aug 2024 12:16:19 +0200 Subject: [PATCH 05/11] backend-*-api: refactor for more explicit internal feature types Signed-off-by: Patrik Oldsberg --- .../src/wiring/BackendInitializer.ts | 73 ++++++++++++++----- .../src/wiring/createBackendModule.test.ts | 6 +- .../src/wiring/createBackendPlugin.test.ts | 6 +- .../backend-plugin-api/src/wiring/types.ts | 10 ++- .../src/next/wiring/TestBackend.ts | 48 +++++++----- 5 files changed, 96 insertions(+), 47 deletions(-) diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.ts b/packages/backend-app-api/src/wiring/BackendInitializer.ts index ddad94de14..902258b9b0 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.ts @@ -26,7 +26,13 @@ import { import { 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'; +import type { + InternalBackendFeature, + InternalBackendFeatureLoader, + InternalBackendRegistrations, +} from '@backstage/backend-plugin-api/src/wiring/types'; +// eslint-disable-next-line @backstage/no-forbidden-package-imports +import type { InternalServiceFactory } from '@backstage/backend-plugin-api/src/services/system/types'; import { ForwardedError, ConflictError } from '@backstage/errors'; import { featureDiscoveryServiceRef } from '@backstage/backend-plugin-api/alpha'; import { DependencyGraph } from '../lib/DependencyGraph'; @@ -44,7 +50,7 @@ export interface BackendRegisterInit { export class BackendInitializer { #startPromise?: Promise; - #features = new Array(); + #registrations = new Array(); #extensionPoints = new Map(); #serviceRegistry: ServiceRegistry; #registeredFeatures = new Array>(); @@ -101,21 +107,15 @@ export class BackendInitializer { } #addFeature(feature: BackendFeature) { - if (feature.$$type !== '@backstage/BackendFeature') { - throw new Error( - `Failed to add feature, invalid type '${feature.$$type}'`, - ); - } - if (isServiceFactory(feature)) { this.#serviceRegistry.add(feature); - } else if (isInternalBackendFeature(feature)) { + } else if (isBackendRegistrations(feature)) { if (feature.version !== 'v1') { throw new Error( `Failed to add feature, invalid version '${feature.version}'`, ); } - this.#features.push(feature); + this.#registrations.push(feature); } else { throw new Error( `Failed to add feature, invalid feature ${JSON.stringify(feature)}`, @@ -176,8 +176,8 @@ export class BackendInitializer { const pluginInits = new Map(); const moduleInits = new Map>(); - // Enumerate all features - for (const feature of this.#features) { + // Enumerate all registrations + for (const feature of this.#registrations) { for (const r of feature.getRegistrations()) { const provides = new Set>(); @@ -205,7 +205,7 @@ export class BackendInitializer { consumes: new Set(Object.values(r.init.deps)), init: r.init, }); - } else { + } else if (r.type === 'module') { let modules = moduleInits.get(r.pluginId); if (!modules) { modules = new Map(); @@ -221,6 +221,8 @@ export class BackendInitializer { consumes: new Set(Object.values(r.init.deps)), init: r.init, }); + } else { + throw new Error(`Invalid registration type '${(r as any).type}'`); } } } @@ -385,14 +387,45 @@ export class BackendInitializer { } } -function isServiceFactory(feature: BackendFeature): feature is ServiceFactory { - return !!(feature as ServiceFactory).service; +function toInternalBackendFeature( + feature: BackendFeature, +): InternalBackendFeature { + if (feature.$$type !== '@backstage/BackendFeature') { + throw new Error(`Invalid BackendFeature, bad type '${feature.$$type}'`); + } + const internal = feature as InternalBackendFeature; + if (internal.version !== 'v1') { + throw new Error( + `Invalid BackendFeature, bad version '${internal.version}'`, + ); + } + return internal; } -function isInternalBackendFeature( +function isServiceFactory( feature: BackendFeature, -): feature is InternalBackendFeature { - return ( - typeof (feature as InternalBackendFeature).getRegistrations === 'function' - ); +): feature is InternalServiceFactory { + const internal = toInternalBackendFeature(feature); + if (internal.featureType === 'service') { + return true; + } + // Backwards compatibility for v1 registrations that use duck typing + if ('service' in internal) { + return true; + } + return false; +} + +function isBackendRegistrations( + feature: BackendFeature, +): feature is InternalBackendRegistrations { + const internal = toInternalBackendFeature(feature); + if (internal.featureType === 'registrations') { + return true; + } + // Backwards compatibility for v1 registrations that use duck typing + if ('getRegistrations' in internal) { + return true; + } + return false; } diff --git a/packages/backend-plugin-api/src/wiring/createBackendModule.test.ts b/packages/backend-plugin-api/src/wiring/createBackendModule.test.ts index 31496020bf..e3c9f9007a 100644 --- a/packages/backend-plugin-api/src/wiring/createBackendModule.test.ts +++ b/packages/backend-plugin-api/src/wiring/createBackendModule.test.ts @@ -15,7 +15,7 @@ */ import { createBackendModule } from './createBackendModule'; -import { InternalBackendFeature } from './types'; +import { InternalBackendRegistrations } from './types'; describe('createBackendModule', () => { it('should create a BackendModule', () => { @@ -28,13 +28,13 @@ describe('createBackendModule', () => { }); // legacy form - const legacy = result() as unknown as InternalBackendFeature; + const legacy = result() as unknown as InternalBackendRegistrations; expect(legacy.$$type).toEqual('@backstage/BackendFeature'); expect(legacy.version).toEqual('v1'); expect(legacy.getRegistrations).toEqual(expect.any(Function)); // new form - const module = result as unknown as InternalBackendFeature; + const module = result as unknown as InternalBackendRegistrations; expect(module.$$type).toEqual('@backstage/BackendFeature'); expect(module.version).toEqual('v1'); expect(module.getRegistrations).toEqual(expect.any(Function)); diff --git a/packages/backend-plugin-api/src/wiring/createBackendPlugin.test.ts b/packages/backend-plugin-api/src/wiring/createBackendPlugin.test.ts index cd1ce164bf..db4d60ee19 100644 --- a/packages/backend-plugin-api/src/wiring/createBackendPlugin.test.ts +++ b/packages/backend-plugin-api/src/wiring/createBackendPlugin.test.ts @@ -15,7 +15,7 @@ */ import { createBackendPlugin } from './createBackendPlugin'; -import { InternalBackendFeature } from './types'; +import { InternalBackendRegistrations } from './types'; describe('createBackendPlugin', () => { it('should create a BackendPlugin', () => { @@ -27,13 +27,13 @@ describe('createBackendPlugin', () => { }); // legacy form - const legacy = result() as unknown as InternalBackendFeature; + const legacy = result() as unknown as InternalBackendRegistrations; expect(legacy.$$type).toEqual('@backstage/BackendFeature'); expect(legacy.version).toEqual('v1'); expect(legacy.getRegistrations).toEqual(expect.any(Function)); // new form - const plugin = result as unknown as InternalBackendFeature; + const plugin = result as unknown as InternalBackendRegistrations; expect(plugin.$$type).toEqual('@backstage/BackendFeature'); expect(plugin.version).toEqual('v1'); expect(plugin.getRegistrations).toEqual(expect.any(Function)); diff --git a/packages/backend-plugin-api/src/wiring/types.ts b/packages/backend-plugin-api/src/wiring/types.ts index df0ca93089..01ab8c2e3e 100644 --- a/packages/backend-plugin-api/src/wiring/types.ts +++ b/packages/backend-plugin-api/src/wiring/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ServiceRef } from '../services/system/types'; +import { InternalServiceFactory, ServiceRef } from '../services/system/types'; import { BackendFeature } from '../types'; /** @@ -73,7 +73,7 @@ export interface BackendModuleRegistrationPoints { } /** @internal */ -export interface InternalBackendFeature extends BackendFeature { +export interface InternalBackendRegistrations extends BackendFeature { version: 'v1'; featureType: 'registrations'; getRegistrations(): Array< @@ -114,3 +114,9 @@ export interface InternalBackendFeatureLoader extends BackendFeature { deps: Record>; loader(deps: Record): Promise; } + +/** @internal */ +export type InternalBackendFeature = + | InternalBackendRegistrations + | InternalBackendFeatureLoader + | InternalServiceFactory; diff --git a/packages/backend-test-utils/src/next/wiring/TestBackend.ts b/packages/backend-test-utils/src/next/wiring/TestBackend.ts index 3d662a8c12..cf98c25e4a 100644 --- a/packages/backend-test-utils/src/next/wiring/TestBackend.ts +++ b/packages/backend-test-utils/src/next/wiring/TestBackend.ts @@ -36,7 +36,10 @@ import { ConfigReader } from '@backstage/config'; import express from 'express'; // 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'; +import { + InternalBackendFeature, + InternalBackendRegistrations, +} from '@backstage/backend-plugin-api/src/wiring/types'; import { createHealthRouter } from '@backstage/backend-defaults/rootHttpRouter'; /** @public */ @@ -94,7 +97,7 @@ function createPluginsForOrphanModules(features: Array) { const modulePluginIds = new Set(); for (const feature of features) { - if (isInternalBackendFeature(feature)) { + if (isInternalBackendRegistrations(feature)) { const registrations = feature.getRegistrations(); for (const registration of registrations) { if (registration.type === 'plugin') { @@ -137,18 +140,7 @@ function createExtensionPointTestModules( } const registrations = features.flatMap(feature => { - if (feature.$$type !== '@backstage/BackendFeature') { - throw new Error( - `Failed to add feature, invalid type '${feature.$$type}'`, - ); - } - - if (isInternalBackendFeature(feature)) { - if (feature.version !== 'v1') { - throw new Error( - `Failed to add feature, invalid version '${feature.version}'`, - ); - } + if (isInternalBackendRegistrations(feature)) { return feature.getRegistrations(); } return []; @@ -361,10 +353,28 @@ function registerTestHooks() { registerTestHooks(); -function isInternalBackendFeature( +function toInternalBackendFeature( feature: BackendFeature, -): feature is InternalBackendFeature { - return ( - typeof (feature as InternalBackendFeature).getRegistrations === 'function' - ); +): InternalBackendFeature { + if (feature.$$type !== '@backstage/BackendFeature') { + throw new Error(`Invalid BackendFeature, bad type '${feature.$$type}'`); + } + const internal = feature as InternalBackendFeature; + if (internal.version !== 'v1') { + throw new Error( + `Invalid BackendFeature, bad version '${internal.version}'`, + ); + } + return internal; +} + +function isInternalBackendRegistrations( + feature: BackendFeature, +): feature is InternalBackendRegistrations { + const internal = toInternalBackendFeature(feature); + if (internal.featureType === 'registrations') { + return true; + } + // Backwards compatibility for v1 registrations that use duck typing + return 'getRegistrations' in internal; } From d3b49a60f2f96e33b04362f1d5f6dd89501cb8bd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 5 Aug 2024 13:03:16 +0200 Subject: [PATCH 06/11] backend-app-api: add support for feature loaders Signed-off-by: Patrik Oldsberg --- .../src/wiring/BackendInitializer.test.ts | 123 ++++++++++++++++++ .../src/wiring/BackendInitializer.ts | 89 +++++++++++-- 2 files changed, 199 insertions(+), 13 deletions(-) diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.test.ts b/packages/backend-app-api/src/wiring/BackendInitializer.test.ts index 3bbef5a4c0..6f60d65861 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.test.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.test.ts @@ -24,6 +24,7 @@ import { createBackendPlugin, createBackendModule, createExtensionPoint, + createBackendFeatureLoader, } from '@backstage/backend-plugin-api'; import { BackendInitializer } from './BackendInitializer'; @@ -105,6 +106,128 @@ describe('BackendInitializer', () => { expect(factory3).not.toHaveBeenCalled(); }); + it('should discover features from feature loader', async () => { + const ref1 = createServiceRef<{ x: number }>({ + id: '1', + scope: 'root', + }); + const ref2 = createServiceRef<{ x: number }>({ + id: '2', + scope: 'plugin', + }); + const factory1 = jest.fn(); + const factory2 = jest.fn(); + + const pluginInit = jest.fn(async () => {}); + const moduleInit = jest.fn(async () => {}); + + const init = new BackendInitializer(baseFactories); + init.add( + createBackendFeatureLoader({ + *loader() { + yield createServiceFactory({ + service: ref1, + deps: {}, + factory: factory1, + }); + yield createServiceFactory({ + service: ref2, + initialization: 'always', + deps: {}, + factory: factory2, + }); + yield createBackendPlugin({ + pluginId: 'test', + register(reg) { + reg.registerInit({ + deps: {}, + init: pluginInit, + }); + }, + }); + yield createBackendModule({ + pluginId: 'test', + moduleId: 'tester', + register(reg) { + reg.registerInit({ + deps: {}, + init: moduleInit, + }); + }, + }); + }, + }), + ); + await init.start(); + + expect(factory1).toHaveBeenCalled(); + expect(factory2).toHaveBeenCalled(); + expect(pluginInit).toHaveBeenCalled(); + expect(moduleInit).toHaveBeenCalled(); + }); + + it('should refuse to override already initialized services through loaded features', async () => { + const ref1 = createServiceRef<{ x: number }>({ + id: '1', + scope: 'root', + }); + + const init = new BackendInitializer([ + ...baseFactories, + createServiceFactory({ + service: ref1, + deps: {}, + factory: () => ({ x: 1 }), + }), + ]); + init.add( + createBackendFeatureLoader({ + deps: { service1: ref1 }, + *loader() { + yield createServiceFactory({ + service: ref1, + deps: {}, + factory: jest.fn(), + }); + }, + }), + ); + await expect(init.start()).rejects.toThrow( + 'Unable to set service factory with id 1, service has already been instantiated', + ); + }); + + it('should refuse feature loader that depends on a plugin scoped service', async () => { + const ref1 = createServiceRef<{ x: number }>({ + id: '1', + }); + + const init = new BackendInitializer([ + ...baseFactories, + createServiceFactory({ + service: ref1, + deps: {}, + factory: () => ({ x: 1 }), + }), + ]); + init.add( + createBackendFeatureLoader({ + // @ts-expect-error + deps: { service1: ref1 }, + *loader() { + yield createServiceFactory({ + service: ref1, + deps: {}, + factory: jest.fn(), + }); + }, + }), + ); + await expect(init.start()).rejects.toThrow( + /^Feature loaders can only depend on root scoped services, but 'service1' is scoped to 'plugin'. Offending loader is created at '.*'$/, + ); + }); + it('should initialize plugin scoped services with eager initialization', async () => { const ref1 = createServiceRef<{ x: number }>({ id: '1', diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.ts b/packages/backend-app-api/src/wiring/BackendInitializer.ts index 902258b9b0..4bfbf6244f 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.ts @@ -54,6 +54,7 @@ export class BackendInitializer { #extensionPoints = new Map(); #serviceRegistry: ServiceRegistry; #registeredFeatures = new Array>(); + #registeredFeatureLoaders = new Array(); constructor(defaultApiFactories: ServiceFactory[]) { this.#serviceRegistry = ServiceRegistry.create([...defaultApiFactories]); @@ -109,12 +110,9 @@ export class BackendInitializer { #addFeature(feature: BackendFeature) { if (isServiceFactory(feature)) { this.#serviceRegistry.add(feature); + } else if (isBackendFeatureLoader(feature)) { + this.#registeredFeatureLoaders.push(feature); } else if (isBackendRegistrations(feature)) { - if (feature.version !== 'v1') { - throw new Error( - `Failed to add feature, invalid version '${feature.version}'`, - ); - } this.#registrations.push(feature); } else { throw new Error( @@ -170,6 +168,8 @@ export class BackendInitializer { this.#serviceRegistry.checkForCircularDeps(); } + await this.#applyBackendFeatureLoaders(this.#registeredFeatureLoaders); + // Initialize all root scoped services await this.#serviceRegistry.initializeEagerServicesWithScope('root'); @@ -385,6 +385,69 @@ export class BackendInitializer { throw new Error('Unexpected plugin lifecycle service implementation'); } + + async #applyBackendFeatureLoaders(loaders: InternalBackendFeatureLoader[]) { + for (const loader of loaders) { + const deps = new Map(); + const missingRefs = new Set(); + + for (const [name, ref] of Object.entries(loader.deps ?? {})) { + if (ref.scope !== 'root') { + throw new Error( + `Feature loaders can only depend on root scoped services, but '${name}' is scoped to '${ref.scope}'. Offending loader is ${loader.description}`, + ); + } + const impl = await this.#serviceRegistry.get( + ref as ServiceRef, + 'root', + ); + if (impl) { + deps.set(name, impl); + } else { + missingRefs.add(ref); + } + } + + if (missingRefs.size > 0) { + const missing = Array.from(missingRefs).join(', '); + throw new Error( + `No service available for the following ref(s): ${missing}, depended on by feature loader ${loader.description}`, + ); + } + + const result = await loader + .loader(Object.fromEntries(deps)) + .catch(error => { + throw new ForwardedError( + `Feature loader ${loader.description} failed`, + error, + ); + }); + + let didAddServiceFactory = false; + const newLoaders = new Array(); + + for await (const feature of result) { + if (isBackendFeatureLoader(feature)) { + newLoaders.push(feature); + } else { + didAddServiceFactory = + didAddServiceFactory || isServiceFactory(feature); + this.#addFeature(feature); + } + } + + // Every time we add a new service factory we need to make sure that we don't have circular dependencies + if (didAddServiceFactory) { + this.#serviceRegistry.checkForCircularDeps(); + } + + // Apply loaders recursively, depth-first + if (newLoaders.length > 0) { + await this.#applyBackendFeatureLoaders(newLoaders); + } + } + } } function toInternalBackendFeature( @@ -410,10 +473,7 @@ function isServiceFactory( return true; } // Backwards compatibility for v1 registrations that use duck typing - if ('service' in internal) { - return true; - } - return false; + return 'service' in internal; } function isBackendRegistrations( @@ -424,8 +484,11 @@ function isBackendRegistrations( return true; } // Backwards compatibility for v1 registrations that use duck typing - if ('getRegistrations' in internal) { - return true; - } - return false; + return 'getRegistrations' in internal; +} + +function isBackendFeatureLoader( + feature: BackendFeature, +): feature is InternalBackendFeatureLoader { + return toInternalBackendFeature(feature).featureType === 'loader'; } From b5679af43cbfe8a78323e9a3a8aa1512d752e9b9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 5 Aug 2024 19:29:21 +0200 Subject: [PATCH 07/11] backend: add example usage of feature loader Signed-off-by: Patrik Oldsberg --- packages/backend/src/index.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 1174b7bf6d..952f3315c4 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -15,9 +15,21 @@ */ import { createBackend } from '@backstage/backend-defaults'; +import { createBackendFeatureLoader } from '@backstage/backend-plugin-api'; const backend = createBackend(); +// An example of how to group together and load multiple features. You can also +// access root-scoped services by adding `deps`. +const searchLoader = createBackendFeatureLoader({ + *loader() { + yield import('@backstage/plugin-search-backend/alpha'); + yield import('@backstage/plugin-search-backend-module-catalog/alpha'); + yield import('@backstage/plugin-search-backend-module-explore/alpha'); + yield import('@backstage/plugin-search-backend-module-techdocs/alpha'); + }, +}); + backend.add(import('@backstage/plugin-auth-backend')); backend.add(import('./authModuleGithubProvider')); backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); @@ -36,13 +48,10 @@ backend.add(import('@backstage/plugin-permission-backend/alpha')); backend.add(import('@backstage/plugin-proxy-backend/alpha')); backend.add(import('@backstage/plugin-scaffolder-backend/alpha')); backend.add(import('@backstage/plugin-scaffolder-backend-module-github')); -backend.add(import('@backstage/plugin-search-backend-module-catalog/alpha')); -backend.add(import('@backstage/plugin-search-backend-module-explore/alpha')); -backend.add(import('@backstage/plugin-search-backend-module-techdocs/alpha')); backend.add( import('@backstage/plugin-catalog-backend-module-backstage-openapi'), ); -backend.add(import('@backstage/plugin-search-backend/alpha')); +backend.add(searchLoader); backend.add(import('@backstage/plugin-techdocs-backend/alpha')); backend.add(import('@backstage/plugin-signals-backend')); backend.add(import('@backstage/plugin-notifications-backend')); From 8b1318318bda3fd794cbecd0201b6c1424fa4f86 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 5 Aug 2024 19:29:35 +0200 Subject: [PATCH 08/11] changesets: add changesets for feature loaders Signed-off-by: Patrik Oldsberg --- .changeset/curvy-pillows-joke.md | 5 +++++ .changeset/cyan-shrimps-push.md | 5 +++++ .changeset/khaki-lamps-peel.md | 26 ++++++++++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 .changeset/curvy-pillows-joke.md create mode 100644 .changeset/cyan-shrimps-push.md create mode 100644 .changeset/khaki-lamps-peel.md diff --git a/.changeset/curvy-pillows-joke.md b/.changeset/curvy-pillows-joke.md new file mode 100644 index 0000000000..25966a51c0 --- /dev/null +++ b/.changeset/curvy-pillows-joke.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-app-api': patch +--- + +Added support for the latest version of `BackendFeature`s from `@backstage/backend-plugin-api`, including feature loaders. diff --git a/.changeset/cyan-shrimps-push.md b/.changeset/cyan-shrimps-push.md new file mode 100644 index 0000000000..cb326b4da3 --- /dev/null +++ b/.changeset/cyan-shrimps-push.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': patch +--- + +Internal updates to support latest version of `BackendFeauture`s from `@backstage/backend-plugin-api`. diff --git a/.changeset/khaki-lamps-peel.md b/.changeset/khaki-lamps-peel.md new file mode 100644 index 0000000000..82a2cbc9de --- /dev/null +++ b/.changeset/khaki-lamps-peel.md @@ -0,0 +1,26 @@ +--- +'@backstage/backend-plugin-api': patch +--- + +Added `createBackendFeatureLoader`, which can be used to programmatically select and install backend features. + +A feature loader can return an list of features to be installed, for example in the form on an `Array` or other for of iterable, which allows for the loader to be defined as a generator function. Both synchronous and asynchronous loaders are supported. + +Additionally, a loader can depend on services in its implementation, with the restriction that it can only depend on root-scoped services, and it may not override services that have already been instantiated. + +```ts +const searchLoader = createBackendFeatureLoader({ + deps: { + config: coreServices.rootConfig, + }, + *loader({ config }) { + // Example of a custom config flag to enable search + if (config.getOptionalString('customFeatureToggle.search')) { + yield import('@backstage/plugin-search-backend/alpha'); + yield import('@backstage/plugin-search-backend-module-catalog/alpha'); + yield import('@backstage/plugin-search-backend-module-explore/alpha'); + yield import('@backstage/plugin-search-backend-module-techdocs/alpha'); + } + }, +}); +``` From 115697842b98d4d52964beb22f050943c2010ab9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 5 Aug 2024 20:48:20 +0200 Subject: [PATCH 09/11] docs/backend-system: move naming pattern docs Signed-off-by: Patrik Oldsberg --- docs/auth/identity-resolver.md | 2 +- docs/backend-system/architecture/03-services.md | 2 +- docs/backend-system/architecture/04-plugins.md | 2 +- .../{07-naming-patterns.md => 08-naming-patterns.md} | 0 docs/backend-system/building-plugins-and-modules/01-index.md | 4 ++-- 5 files changed, 5 insertions(+), 5 deletions(-) rename docs/backend-system/architecture/{07-naming-patterns.md => 08-naming-patterns.md} (100%) diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md index 6eb52e5708..dbebf3056c 100644 --- a/docs/auth/identity-resolver.md +++ b/docs/auth/identity-resolver.md @@ -193,7 +193,7 @@ backend.add(import('@backstage/plugin-auth-backend-module-github-provider')); backend.add(customAuth); ``` -Check out [the naming patterns article](../backend-system/architecture/07-naming-patterns.md) for what rules +Check out [the naming patterns article](../backend-system/architecture/08-naming-patterns.md) for what rules apply regarding how to form valid IDs. In this example we also put the module declaration directly in `packages/backend/src/index.ts` but that's just for simplicity. You can place it anywhere you like, including in other packages, and diff --git a/docs/backend-system/architecture/03-services.md b/docs/backend-system/architecture/03-services.md index 95e3d5305a..57f6b20b73 100644 --- a/docs/backend-system/architecture/03-services.md +++ b/docs/backend-system/architecture/03-services.md @@ -38,7 +38,7 @@ export const fooServiceRef = createServiceRef({ The `fooServiceRef` that we create above should be exported, and can then be used to declare a dependency on the `FooService` interface and receive an implementation of it at runtime. -When creating a service reference you need to give it an ID. This ID needs to be globally unique, and should generally be of the format `'.'`. For more naming patterns surrounding services, see the [naming patterns](./07-naming-patterns.md#services) page. +When creating a service reference you need to give it an ID. This ID needs to be globally unique, and should generally be of the format `'.'`. For more naming patterns surrounding services, see the [naming patterns](./08-naming-patterns.md#services) page. A note on naming: the frontend and backend systems intentionally use the separate names "APIs" and "Services" for concepts that are quite similar. This is to avoid confusion between the two, both in documentation and discussion, but also in code. While the two systems are quite similar, they are not identical, and they can't be used interchangeably. diff --git a/docs/backend-system/architecture/04-plugins.md b/docs/backend-system/architecture/04-plugins.md index af59c2a48a..5aef71e24f 100644 --- a/docs/backend-system/architecture/04-plugins.md +++ b/docs/backend-system/architecture/04-plugins.md @@ -10,7 +10,7 @@ Plugins provide the actual base features of a Backstage backend. Each plugin ope ## Defining a Plugin -Plugins are created using the `createBackendPlugin` function, and should typically be exported from a plugin package. All plugins must have an ID and a `register` method, where the ID matches the plugin ID in the package name, without the `-backend` suffix. See also the [dedicated section](./07-naming-patterns.md) about proper naming patterns. +Plugins are created using the `createBackendPlugin` function, and should typically be exported from a plugin package. All plugins must have an ID and a `register` method, where the ID matches the plugin ID in the package name, without the `-backend` suffix. See also the [dedicated section](./08-naming-patterns.md) about proper naming patterns. ```ts // plugins/example-backend/src/plugin.ts diff --git a/docs/backend-system/architecture/07-naming-patterns.md b/docs/backend-system/architecture/08-naming-patterns.md similarity index 100% rename from docs/backend-system/architecture/07-naming-patterns.md rename to docs/backend-system/architecture/08-naming-patterns.md diff --git a/docs/backend-system/building-plugins-and-modules/01-index.md b/docs/backend-system/building-plugins-and-modules/01-index.md index 379ce19587..4dfca00d4e 100644 --- a/docs/backend-system/building-plugins-and-modules/01-index.md +++ b/docs/backend-system/building-plugins-and-modules/01-index.md @@ -68,7 +68,7 @@ that's specific to your plugin. In the example above, the logger might tag messages with your plugin ID, and the HTTP router might prefix API routes with your plugin ID, depending on the implementation used. -See [the article on naming patterns](../architecture/07-naming-patterns.md) for +See [the article on naming patterns](../architecture/08-naming-patterns.md) for details on how to best choose names/IDs for plugins and related backend system items. @@ -124,7 +124,7 @@ export const catalogModuleExampleCustomProcessor = createBackendModule({ export { catalogModuleExampleCustomProcessor as default } from './module'; ``` -See [the article on naming patterns](../architecture/07-naming-patterns.md) for +See [the article on naming patterns](../architecture/08-naming-patterns.md) for details on how to best choose names/IDs for modules and related backend system items. From 25a4c77a2ec11914f61457560dc7b7f4ae417d47 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 5 Aug 2024 20:48:30 +0200 Subject: [PATCH 10/11] docs/backend-system: add feature loader docs Signed-off-by: Patrik Oldsberg --- .../architecture/07-feature-loaders.md | 101 ++++++++++++++++++ microsite/sidebars.json | 1 + 2 files changed, 102 insertions(+) create mode 100644 docs/backend-system/architecture/07-feature-loaders.md diff --git a/docs/backend-system/architecture/07-feature-loaders.md b/docs/backend-system/architecture/07-feature-loaders.md new file mode 100644 index 0000000000..cf52a1feb3 --- /dev/null +++ b/docs/backend-system/architecture/07-feature-loaders.md @@ -0,0 +1,101 @@ +--- +id: feature-loaders +title: Backend Feature Loaders +sidebar_label: Feature Loaders +# prettier-ignore +description: Backend feature loaders +--- + +Backend feature loaders are used to programmatically select and install features in a Backstage backend. They can service a wide range of use cases, such as enabling or disabling features based on static configuration, dynamically load features at runtime, or conditionally load features based on the state of a system. + +Feature loaders are defined using the `createBackendFeatureLoader` function, exported by `@backstage/backend-plugin-api`. It accepts a `loader` function, as well as an optional `deps` object for declaring service dependencies. Unlike plugins and modules, feature loaders are limited to only depending on root-scoped services, but that still allows access to for example the [root config](../core-services/root-config.md) and [root logger](../core-services/root-logger.md) services. + +The `loader` function can be defined in many different ways, with the main requirement being that it returns a list of `BackendFeature`s in some form. A backend feature is the kind of object that you can pass to `backend.add(...)`, for example services factories, plugins, modules, or even other feature loaders. The `loader` function can be synchronous or asynchronous, and can be defined as a generator function to allow for more complex logic. + +## Examples + +The following are a few example of how feature loaders can be used: + +### Simple list of features + +A feature loader can simply return a list of features to be installed: + +```ts +export default createBackendFeatureLoader({ + loader() { + return [ + import('@backstage/plugin-search-backend/alpha'), + import('@backstage/plugin-search-backend-module-catalog/alpha'), + import('@backstage/plugin-search-backend-module-explore/alpha'), + import('@backstage/plugin-search-backend-module-techdocs/alpha'), + ]; + }, +}); +``` + +It can also encapsulate a collection of custom features: + +```ts +export default createBackendFeatureLoader({ + // Async loader is fine too + async loader() { + return [ + createBackendPlugin({ + ... + }), + createBackendModule({ + ... + }), + ] + }, +}); +``` + +### Conditional loading + +A feature loader can access root-scoped services, such as the config service. This allows for conditional loading of features based on configuration. It is often convenient to use a generator function for this purpose: + +```ts +export default createBackendFeatureLoader({ + deps: { + config: coreServices.rootConfig, + }, + // The `*` in front of the function name makes it a generator function + *loader({ config }) { + // Example of a custom config flag to enable search + if (config.getOptionalString('customFeatureToggle.search')) { + yield import('@backstage/plugin-search-backend/alpha'); + yield import('@backstage/plugin-search-backend-module-catalog/alpha'); + yield import('@backstage/plugin-search-backend-module-explore/alpha'); + yield import('@backstage/plugin-search-backend-module-techdocs/alpha'); + } + }, +}); +``` + +### Dynamic logic + +A feature loader can also be asynchronous, and for example fetch data from an external source to determine which features to load: + +```ts +export default createBackendFeatureLoader({ + // The `async *` in front of the function name makes it an async generator function. + async *loader() { + const localMetadata = await readMetadataFromDisk(); + + if (localMetadata.enableSearch) { + yield import('@backstage/plugin-search-backend/alpha'); + yield import('@backstage/plugin-search-backend-module-catalog/alpha'); + + const remoteMetadata = await fetchMetadata(); + + if (remoteMetadata.enableExplore) { + yield import('@backstage/plugin-search-backend-module-explore/alpha'); + } + if (remoteMetadata.enableTechDocs) { + yield import('@backstage/plugin-search-backend-module-techdocs/alpha'); + } + } + }, +}); +``` diff --git a/microsite/sidebars.json b/microsite/sidebars.json index d13ebc71c6..dafd3d1d5e 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -393,6 +393,7 @@ "backend-system/architecture/plugins", "backend-system/architecture/extension-points", "backend-system/architecture/modules", + "backend-system/architecture/feature-loaders", "backend-system/architecture/naming-patterns" ] }, From c7126d657bfddba371f18f2dc975cc7f73dbd5e6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 6 Aug 2024 13:42:02 +0200 Subject: [PATCH 11/11] review fixes Signed-off-by: Patrik Oldsberg --- .../backend-app-api/src/wiring/BackendInitializer.ts | 11 +++++------ .../src/routing/describeParentCallSite.ts | 2 ++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.ts b/packages/backend-app-api/src/wiring/BackendInitializer.ts index 4bfbf6244f..c0da9eda62 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.ts @@ -25,14 +25,14 @@ import { } from '@backstage/backend-plugin-api'; import { ServiceOrExtensionPoint } from './types'; // Direct internal import to avoid duplication -// eslint-disable-next-line @backstage/no-forbidden-package-imports +// eslint-disable-next-line @backstage/no-relative-monorepo-imports import type { InternalBackendFeature, InternalBackendFeatureLoader, InternalBackendRegistrations, -} from '@backstage/backend-plugin-api/src/wiring/types'; -// eslint-disable-next-line @backstage/no-forbidden-package-imports -import type { InternalServiceFactory } from '@backstage/backend-plugin-api/src/services/system/types'; +} from '../../../backend-plugin-api/src/wiring/types'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import type { InternalServiceFactory } from '../../../backend-plugin-api/src/services/system/types'; import { ForwardedError, ConflictError } from '@backstage/errors'; import { featureDiscoveryServiceRef } from '@backstage/backend-plugin-api/alpha'; import { DependencyGraph } from '../lib/DependencyGraph'; @@ -431,8 +431,7 @@ export class BackendInitializer { if (isBackendFeatureLoader(feature)) { newLoaders.push(feature); } else { - didAddServiceFactory = - didAddServiceFactory || isServiceFactory(feature); + didAddServiceFactory ||= isServiceFactory(feature); this.#addFeature(feature); } } diff --git a/packages/frontend-plugin-api/src/routing/describeParentCallSite.ts b/packages/frontend-plugin-api/src/routing/describeParentCallSite.ts index 72997e07db..ab5fc2f034 100644 --- a/packages/frontend-plugin-api/src/routing/describeParentCallSite.ts +++ b/packages/frontend-plugin-api/src/routing/describeParentCallSite.ts @@ -16,6 +16,8 @@ const MESSAGE_MARKER = 'eHgtF5hmbrXyiEvo'; +// NOTE: This function is also imported and used in backend code + /** * Internal helper that describes the location of the parent caller. * @internal