diff --git a/.changeset/fast-spoons-relax.md b/.changeset/fast-spoons-relax.md new file mode 100644 index 0000000000..5a94a2c1ed --- /dev/null +++ b/.changeset/fast-spoons-relax.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-plugin-api': patch +--- + +Added `createSharedEnvironment` for creating a shared environment containing commonly used services in a split backend setup of the backend. diff --git a/.changeset/many-dogs-sin.md b/.changeset/many-dogs-sin.md new file mode 100644 index 0000000000..7b50498b19 --- /dev/null +++ b/.changeset/many-dogs-sin.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +Added support to supply a shared environment to `createBackend`, which can be created using `createSharedEnvironment` from `@backstage/backend-plugin-api`. diff --git a/packages/backend-defaults/api-report.md b/packages/backend-defaults/api-report.md index 6cc58de663..07218f610e 100644 --- a/packages/backend-defaults/api-report.md +++ b/packages/backend-defaults/api-report.md @@ -5,12 +5,15 @@ ```ts import { Backend } from '@backstage/backend-app-api'; import { ServiceFactoryOrFunction } from '@backstage/backend-plugin-api'; +import { SharedBackendEnvironment } from '@backstage/backend-plugin-api'; // @public (undocumented) export function createBackend(options?: CreateBackendOptions): Backend; // @public (undocumented) export interface CreateBackendOptions { + // (undocumented) + env?: SharedBackendEnvironment; // (undocumented) services?: ServiceFactoryOrFunction[]; } diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index d2bfd7038d..401c507ae2 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -36,6 +36,7 @@ "@backstage/backend-plugin-api": "workspace:^" }, "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^" }, "files": [ diff --git a/packages/backend-defaults/src/CreateBackend.test.ts b/packages/backend-defaults/src/CreateBackend.test.ts index 971ddc67ff..35b3a7decf 100644 --- a/packages/backend-defaults/src/CreateBackend.test.ts +++ b/packages/backend-defaults/src/CreateBackend.test.ts @@ -16,10 +16,17 @@ import { coreServices, + createBackendPlugin, createServiceFactory, + createServiceRef, + createSharedEnvironment, } from '@backstage/backend-plugin-api'; +import { mockConfigFactory } from '@backstage/backend-test-utils'; import { createBackend } from './CreateBackend'; +const fooServiceRef = createServiceRef({ id: 'foo', scope: 'root' }); +const barServiceRef = createServiceRef({ id: 'bar', scope: 'root' }); + describe('createBackend', () => { it('should not throw when overriding a default service implementation', () => { expect(() => @@ -69,4 +76,91 @@ describe('createBackend', () => { }), ).toThrow('The core.pluginMetadata service cannot be overridden'); }); + + it('should throw if an unsupported InternalSharedEnvironment version is passed in', () => { + expect(() => + createBackend({ + env: {} as any, + }), + ).toThrow( + "Shared environment version 'undefined' is invalid or not supported", + ); + expect(() => + createBackend({ + env: { version: {} } as any, + }), + ).toThrow( + "Shared environment version '[object Object]' is invalid or not supported", + ); + expect(() => + createBackend({ + env: { version: 'v2' } as any, + }), + ).toThrow("Shared environment version 'v2' is invalid or not supported"); + }); + + it('should prioritize services correctly', async () => { + const backend = createBackend({ + env: createSharedEnvironment({ + services: [ + createServiceFactory({ + service: coreServices.rootHttpRouter, + deps: {}, + async factory() { + return { + use() {}, + }; + }, + }), + mockConfigFactory({ + data: { root: 'root-env' }, + }), + createServiceFactory({ + service: fooServiceRef, + deps: {}, + async factory() { + return 'foo-env'; + }, + }), + createServiceFactory({ + service: barServiceRef, + deps: {}, + async factory() { + return 'bar-env'; + }, + }), + ], + })(), + services: [ + createServiceFactory({ + service: fooServiceRef, + deps: {}, + factory: async () => 'foo-backend', + }), + ], + }); + + expect.assertions(3); + backend.add( + createBackendPlugin({ + id: 'test', + register(reg) { + reg.registerInit({ + deps: { + config: coreServices.config, + foo: fooServiceRef, + bar: barServiceRef, + }, + async init({ config, foo, bar }) { + expect(config.get('root')).toBe('root-env'); + expect(foo).toBe('foo-backend'); + expect(bar).toBe('bar-env'); + }, + }); + }, + })(), + ); + + await backend.start(); + }); }); diff --git a/packages/backend-defaults/src/CreateBackend.ts b/packages/backend-defaults/src/CreateBackend.ts index fe82fb671c..e6b5a15336 100644 --- a/packages/backend-defaults/src/CreateBackend.ts +++ b/packages/backend-defaults/src/CreateBackend.ts @@ -33,7 +33,15 @@ import { urlReaderFactory, identityFactory, } from '@backstage/backend-app-api'; -import { ServiceFactoryOrFunction } from '@backstage/backend-plugin-api'; +import { + ServiceFactory, + ServiceFactoryOrFunction, + SharedBackendEnvironment, +} from '@backstage/backend-plugin-api'; + +// Internal import of the type to avoid needing to export this. +// eslint-disable-next-line monorepo/no-internal-import +import type { InternalSharedBackendEnvironment } from '@backstage/backend-plugin-api/src/wiring/createSharedEnvironment'; export const defaultServiceFactories = [ cacheFactory(), @@ -57,6 +65,7 @@ export const defaultServiceFactories = [ * @public */ export interface CreateBackendOptions { + env?: SharedBackendEnvironment; services?: ServiceFactoryOrFunction[]; } @@ -64,15 +73,35 @@ export interface CreateBackendOptions { * @public */ export function createBackend(options?: CreateBackendOptions): Backend { + const services = new Array(); + + // Highest priority: Services passed directly to createBackend const providedServices = (options?.services ?? []).map(sf => typeof sf === 'function' ? sf() : sf, ); - const providedIds = new Set(providedServices.map(sf => sf.service.id)); - const neededDefaultFactories = defaultServiceFactories.filter( - sf => !providedIds.has(sf.service.id), - ); + services.push(...providedServices); - return createSpecializedBackend({ - services: [...neededDefaultFactories, ...providedServices], - }); + // Middle priority: Services from the shared environment + if (options?.env) { + const env = options.env as unknown as InternalSharedBackendEnvironment; + if (env.version !== 'v1') { + throw new Error( + `Shared environment version '${env.version}' is invalid or not supported`, + ); + } + + const environmentServices = + env.services?.filter( + sf => !services.some(({ service }) => sf.service.id === service.id), + ) ?? []; + services.push(...environmentServices); + } + + // Lowest priority: Default services that are not already provided by environment or directly to createBackend + const defaultServices = defaultServiceFactories.filter( + sf => !services.some(({ service }) => service.id === sf.service.id), + ); + services.push(...defaultServices); + + return createSpecializedBackend({ services }); } diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 931aba62a6..86917dae11 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -217,6 +217,15 @@ export function createServiceRef( config: ServiceRefConfig, ): ServiceRef; +// @public +export function createSharedEnvironment< + TOptions extends [options?: object] = [], +>( + config: + | SharedBackendEnvironmentConfig + | ((...params: TOptions) => SharedBackendEnvironmentConfig), +): (...options: TOptions) => SharedBackendEnvironment; + // @public export interface DatabaseService { getClient(): Promise; @@ -467,6 +476,18 @@ export interface ServiceRefConfig { scope?: TScope; } +// @public (undocumented) +export interface SharedBackendEnvironment { + // (undocumented) + $$type: 'SharedBackendEnvironment'; +} + +// @public (undocumented) +export interface SharedBackendEnvironmentConfig { + // (undocumented) + services?: ServiceFactoryOrFunction[]; +} + // @public export interface TokenManagerService { authenticate(token: string): Promise; diff --git a/packages/backend-plugin-api/src/wiring/createSharedEnvironment.test.ts b/packages/backend-plugin-api/src/wiring/createSharedEnvironment.test.ts new file mode 100644 index 0000000000..ec2cf1fe49 --- /dev/null +++ b/packages/backend-plugin-api/src/wiring/createSharedEnvironment.test.ts @@ -0,0 +1,100 @@ +/* + * 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. + */ + +import { + createServiceFactory, + createServiceRef, + ServiceFactoryOrFunction, +} from '../services'; +import { + createSharedEnvironment, + InternalSharedBackendEnvironment, +} from './createSharedEnvironment'; + +const fooService = createServiceRef({ id: 'foo', scope: 'root' }); +const fooFactory = createServiceFactory({ + service: fooService, + deps: {}, + async factory() { + return 'foo'; + }, +}); + +const barService = createServiceRef({ id: 'bar', scope: 'root' }); +const barFactory = createServiceFactory({ + service: barService, + deps: {}, + async factory() { + return 0xba5; + }, +}); + +describe('createSharedEnvironment', () => { + it('should create an empty shared environment', () => { + const env = createSharedEnvironment({}); + expect(env).toBeDefined(); + const internalEnv = env() as unknown as InternalSharedBackendEnvironment; + expect(internalEnv).toEqual({ + $$type: 'SharedBackendEnvironment', + version: 'v1', + services: undefined, + }); + }); + + it('should create a shared environment with services', () => { + const env = createSharedEnvironment({ + services: [fooFactory, barFactory()], + }); + const internalEnv = env() as unknown as InternalSharedBackendEnvironment; + expect(internalEnv.version).toBe('v1'); + expect(internalEnv.services?.length).toBe(2); + expect(internalEnv.services?.[0]?.service.id).toBe('foo'); + expect(internalEnv.services?.[1]?.service.id).toBe('bar'); + }); + + it('should create a shared environment with options', () => { + const env = createSharedEnvironment((options?: { withFoo?: boolean }) => { + const services = new Array(); + if (options?.withFoo) { + services.push(fooFactory()); + } + services.push(barFactory); + return { services }; + }); + const internalEnv1 = env() as unknown as InternalSharedBackendEnvironment; + expect(internalEnv1.version).toBe('v1'); + expect(internalEnv1.services?.length).toBe(1); + expect(internalEnv1.services?.[0]?.service.id).toBe('bar'); + + const internalEnv2 = env({ + withFoo: true, + }) as unknown as InternalSharedBackendEnvironment; + expect(internalEnv2.version).toBe('v1'); + expect(internalEnv2.services?.length).toBe(2); + expect(internalEnv2.services?.[0]?.service.id).toBe('foo'); + expect(internalEnv2.services?.[1]?.service.id).toBe('bar'); + }); + + it('should not allow duplicate service factories', () => { + expect(() => + createSharedEnvironment({ + services: [fooFactory, fooFactory()], + })(), + ).toThrow( + "Duplicate service implementations provided in shared environment for 'foo'", + ); + }); +}); diff --git a/packages/backend-plugin-api/src/wiring/createSharedEnvironment.ts b/packages/backend-plugin-api/src/wiring/createSharedEnvironment.ts new file mode 100644 index 0000000000..3258188772 --- /dev/null +++ b/packages/backend-plugin-api/src/wiring/createSharedEnvironment.ts @@ -0,0 +1,90 @@ +/* + * 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. + */ + +import { ServiceFactory, ServiceFactoryOrFunction } from '../services'; + +/** @public */ +export interface SharedBackendEnvironmentConfig { + services?: ServiceFactoryOrFunction[]; +} + +// This type is opaque in order to allow for future API evolution without +// cluttering the external API. For example we might want to add support +// for more powerful callback based backend modifications. +// +// By making this opaque we also ensure that the type doesn't become an input +// type that we need to care about, as it would otherwise be possible to pass +// a custom environment definition to `createBackend`, which we don't want. +/** + * @public + */ +export interface SharedBackendEnvironment { + $$type: 'SharedBackendEnvironment'; +} + +/** + * This type is NOT supposed to be used by anyone except internally by the backend-app-api package. + * @internal */ +export interface InternalSharedBackendEnvironment { + version: 'v1'; + services?: ServiceFactory[]; +} + +/** + * Creates a shared backend environment which can be used to create multiple backends + * @public + */ +export function createSharedEnvironment< + TOptions extends [options?: object] = [], +>( + config: + | SharedBackendEnvironmentConfig + | ((...params: TOptions) => SharedBackendEnvironmentConfig), +): (...options: TOptions) => SharedBackendEnvironment { + const configCallback = typeof config === 'function' ? config : () => config; + + return (...options) => { + const actualConfig = configCallback(...options); + const services = actualConfig?.services?.map(sf => + typeof sf === 'function' ? sf() : sf, + ); + + const exists = new Set(); + const duplicates = new Set(); + for (const { service } of services ?? []) { + if (exists.has(service.id)) { + duplicates.add(service.id); + } else { + exists.add(service.id); + } + } + + if (duplicates.size > 0) { + const dupStr = [...duplicates].map(id => `'${id}'`).join(', '); + throw new Error( + `Duplicate service implementations provided in shared environment for ${dupStr}`, + ); + } + + // Here to ensure type safety in this internal implementation. + const env: SharedBackendEnvironment & InternalSharedBackendEnvironment = { + $$type: 'SharedBackendEnvironment', + version: 'v1', + services, + }; + return env; + }; +} diff --git a/packages/backend-plugin-api/src/wiring/index.ts b/packages/backend-plugin-api/src/wiring/index.ts index 6d8de91080..0d5999ad12 100644 --- a/packages/backend-plugin-api/src/wiring/index.ts +++ b/packages/backend-plugin-api/src/wiring/index.ts @@ -14,6 +14,11 @@ * limitations under the License. */ +export { createSharedEnvironment } from './createSharedEnvironment'; +export type { + SharedBackendEnvironment, + SharedBackendEnvironmentConfig, +} from './createSharedEnvironment'; export type { BackendModuleConfig, BackendPluginConfig, diff --git a/yarn.lock b/yarn.lock index d3a4070936..360c97251c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3515,6 +3515,7 @@ __metadata: dependencies: "@backstage/backend-app-api": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" "@backstage/cli": "workspace:^" languageName: unknown linkType: soft