diff --git a/.changeset/hungry-weeks-flash.md b/.changeset/hungry-weeks-flash.md new file mode 100644 index 0000000000..19cb6cede8 --- /dev/null +++ b/.changeset/hungry-weeks-flash.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-tasks': patch +--- + +Added a new static `TaskScheduler.forPlugin` method. diff --git a/.changeset/proud-cobras-chew.md b/.changeset/proud-cobras-chew.md new file mode 100644 index 0000000000..ae08378715 --- /dev/null +++ b/.changeset/proud-cobras-chew.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': patch +--- + +The backend started by `startTestBackend` now has default implementations of all core services. It now also returns a `TestBackend` instance, which provides access to the underlying `server` that can be used with testing libraries such as `supertest`. diff --git a/.changeset/shaggy-apricots-camp.md b/.changeset/shaggy-apricots-camp.md new file mode 100644 index 0000000000..705dcef0f0 --- /dev/null +++ b/.changeset/shaggy-apricots-camp.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-app-api': patch +--- + +Exported the default root HTTP router implementation as `DefaultRootHttpRouter`. It only implements the routing layer and needs to be exposed via an HTTP server similar to the built-in setup in the `rootHttpRouterFactory`. diff --git a/packages/backend-app-api/api-report.md b/packages/backend-app-api/api-report.md index a8f077b760..b1356e93e3 100644 --- a/packages/backend-app-api/api-report.md +++ b/packages/backend-app-api/api-report.md @@ -12,6 +12,7 @@ import { CorsOptions } from 'cors'; import { ErrorRequestHandler } from 'express'; import { Express as Express_2 } from 'express'; import { ExtensionPoint } from '@backstage/backend-plugin-api'; +import { Handler } from 'express'; import { HelmetOptions } from 'helmet'; import * as http from 'http'; import { HttpRouterService } from '@backstage/backend-plugin-api'; @@ -79,6 +80,21 @@ export const databaseFactory: ( options?: undefined, ) => ServiceFactory; +// @public +export class DefaultRootHttpRouter implements RootHttpRouterService { + // (undocumented) + static create(options?: DefaultRootHttpRouterOptions): DefaultRootHttpRouter; + // (undocumented) + handler(): Handler; + // (undocumented) + use(path: string, handler: Handler): void; +} + +// @public +export interface DefaultRootHttpRouterOptions { + indexPath?: string | false; +} + // @public (undocumented) export const discoveryFactory: ( options?: undefined, diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json index a6ecb5f455..01eaeaecaf 100644 --- a/packages/backend-app-api/package.json +++ b/packages/backend-app-api/package.json @@ -48,6 +48,7 @@ "express-promise-router": "^4.1.0", "fs-extra": "10.1.0", "helmet": "^6.0.0", + "lodash": "^4.17.21", "minimatch": "^5.0.0", "morgan": "^1.10.0", "node-forge": "^1.3.1", diff --git a/packages/backend-app-api/src/services/implementations/database/databaseFactory.ts b/packages/backend-app-api/src/services/implementations/database/databaseFactory.ts index f33e825c9b..52cecdbc4d 100644 --- a/packages/backend-app-api/src/services/implementations/database/databaseFactory.ts +++ b/packages/backend-app-api/src/services/implementations/database/databaseFactory.ts @@ -19,6 +19,7 @@ import { coreServices, createServiceFactory, } from '@backstage/backend-plugin-api'; +import { ConfigReader } from '@backstage/config'; /** @public */ export const databaseFactory = createServiceFactory({ @@ -28,7 +29,16 @@ export const databaseFactory = createServiceFactory({ plugin: coreServices.pluginMetadata, }, async factory({ config }) { - const databaseManager = DatabaseManager.fromConfig(config); + const databaseManager = config.getOptional('backend.database') + ? DatabaseManager.fromConfig(config) + : DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { client: 'better-sqlite3', connection: ':memory:' }, + }, + }), + ); + return async ({ plugin }) => { return databaseManager.forPlugin(plugin.getId()); }; diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouter/RestrictedIndexedRouter.test.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouter/DefaultRootHttpRouter.test.ts similarity index 76% rename from packages/backend-app-api/src/services/implementations/rootHttpRouter/RestrictedIndexedRouter.test.ts rename to packages/backend-app-api/src/services/implementations/rootHttpRouter/DefaultRootHttpRouter.test.ts index b72e87f77b..b72b30e02e 100644 --- a/packages/backend-app-api/src/services/implementations/rootHttpRouter/RestrictedIndexedRouter.test.ts +++ b/packages/backend-app-api/src/services/implementations/rootHttpRouter/DefaultRootHttpRouter.test.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import { RestrictedIndexedRouter } from './RestrictedIndexedRouter'; +import { DefaultRootHttpRouter } from './DefaultRootHttpRouter'; -describe('RestrictedIndexedRouter', () => { +describe('DefaultRootHttpRouter', () => { it.each([ [['/b'], '/a'], [['/a'], '/aa/b'], @@ -25,7 +25,7 @@ describe('RestrictedIndexedRouter', () => { [['/b/a'], '/a'], [['/a'], '/aa'], ])(`with existing paths %s, adds %s without conflict`, (existing, added) => { - const router = new RestrictedIndexedRouter(false); + const router = DefaultRootHttpRouter.create(); for (const path of existing) { router.use(path, () => {}); } @@ -39,7 +39,7 @@ describe('RestrictedIndexedRouter', () => { ])( `find conflict when existing paths %s, adds %s`, (existing, added, conflict) => { - const router = new RestrictedIndexedRouter(false); + const router = DefaultRootHttpRouter.create(); for (const path of existing) { router.use(path, () => {}); } @@ -48,4 +48,10 @@ describe('RestrictedIndexedRouter', () => { ); }, ); + + it('should not be possible to supply an empty indexPath', () => { + expect(() => DefaultRootHttpRouter.create({ indexPath: '' })).toThrow( + 'indexPath option may not be an empty string', + ); + }); }); diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouter/RestrictedIndexedRouter.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouter/DefaultRootHttpRouter.ts similarity index 60% rename from packages/backend-app-api/src/services/implementations/rootHttpRouter/RestrictedIndexedRouter.ts rename to packages/backend-app-api/src/services/implementations/rootHttpRouter/DefaultRootHttpRouter.ts index 961277f34d..0a144af494 100644 --- a/packages/backend-app-api/src/services/implementations/rootHttpRouter/RestrictedIndexedRouter.ts +++ b/packages/backend-app-api/src/services/implementations/rootHttpRouter/DefaultRootHttpRouter.ts @@ -16,23 +16,59 @@ import { RootHttpRouterService } from '@backstage/backend-plugin-api'; import { Handler, Router } from 'express'; +import trimEnd from 'lodash/trimEnd'; function normalizePath(path: string): string { - return path.replace(/\/*$/, '/'); + return `${trimEnd(path, '/')}/`; } -export class RestrictedIndexedRouter implements RootHttpRouterService { - #indexPath?: false | string; +/** + * Options for the {@link DefaultRootHttpRouter} class. + * + * @public + */ +export interface DefaultRootHttpRouterOptions { + /** + * The path to forward all unmatched requests to. Defaults to '/api/app' if + * not given. Disables index path behavior if false is given. + */ + indexPath?: string | false; +} + +/** + * The default implementation of the {@link @backstage/backend-plugin-api#RootHttpRouterService} interface for + * {@link @backstage/backend-plugin-api#coreServices.rootHttpRouter}. + * + * @public + */ +export class DefaultRootHttpRouter implements RootHttpRouterService { + #indexPath?: string; #router = Router(); #namedRoutes = Router(); #indexRouter = Router(); #existingPaths = new Array(); - constructor(indexPath?: false | string) { + static create(options?: DefaultRootHttpRouterOptions) { + let indexPath; + if (options?.indexPath === false) { + indexPath = undefined; + } else if (options?.indexPath === undefined) { + indexPath = '/api/app'; + } else if (options?.indexPath === '') { + throw new Error('indexPath option may not be an empty string'); + } else { + indexPath = options.indexPath; + } + return new DefaultRootHttpRouter(indexPath); + } + + private constructor(indexPath?: string) { this.#indexPath = indexPath; this.#router.use(this.#namedRoutes); - this.#router.use(this.#indexRouter); + if (this.#indexPath) { + this.#router.use(this.#indexRouter); + } } use(path: string, handler: Handler) { diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouter/index.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouter/index.ts index 1dfd72273d..e24662df05 100644 --- a/packages/backend-app-api/src/services/implementations/rootHttpRouter/index.ts +++ b/packages/backend-app-api/src/services/implementations/rootHttpRouter/index.ts @@ -14,8 +14,12 @@ * limitations under the License. */ -export { rootHttpRouterFactory } from './rootHttpRouterFactory'; -export type { - RootHttpRouterFactoryOptions, - RootHttpRouterConfigureOptions, +export { + rootHttpRouterFactory, + type RootHttpRouterFactoryOptions, + type RootHttpRouterConfigureOptions, } from './rootHttpRouterFactory'; +export { + DefaultRootHttpRouter, + type DefaultRootHttpRouterOptions, +} from './DefaultRootHttpRouter'; diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouter/rootHttpRouterFactory.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouter/rootHttpRouterFactory.ts index 45cb826b4d..58f8cc2db1 100644 --- a/packages/backend-app-api/src/services/implementations/rootHttpRouter/rootHttpRouterFactory.ts +++ b/packages/backend-app-api/src/services/implementations/rootHttpRouter/rootHttpRouterFactory.ts @@ -27,7 +27,7 @@ import { MiddlewareFactory, readHttpServerOptions, } from '../../../http'; -import { RestrictedIndexedRouter } from './RestrictedIndexedRouter'; +import { DefaultRootHttpRouter } from './DefaultRootHttpRouter'; /** * @public @@ -46,7 +46,8 @@ export interface RootHttpRouterConfigureOptions { */ export type RootHttpRouterFactoryOptions = { /** - * The path to forward all unmatched requests to. Defaults to '/api/app' + * The path to forward all unmatched requests to. Defaults to '/api/app' if + * not given. Disables index path behavior if false is given. */ indexPath?: string | false; @@ -82,11 +83,10 @@ export const rootHttpRouterFactory = createServiceFactory({ configure = defaultConfigure, }: RootHttpRouterFactoryOptions = {}, ) { - const router = new RestrictedIndexedRouter(indexPath ?? '/api/app'); const logger = rootLogger.child({ service: 'rootHttpRouter' }); - const app = express(); + const router = DefaultRootHttpRouter.create({ indexPath }); const middleware = MiddlewareFactory.create({ config, logger }); configure({ diff --git a/packages/backend-app-api/src/services/implementations/scheduler/schedulerFactory.ts b/packages/backend-app-api/src/services/implementations/scheduler/schedulerFactory.ts index a6edec868a..4e472b78f4 100644 --- a/packages/backend-app-api/src/services/implementations/scheduler/schedulerFactory.ts +++ b/packages/backend-app-api/src/services/implementations/scheduler/schedulerFactory.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { loggerToWinstonLogger } from '@backstage/backend-common'; import { coreServices, createServiceFactory, @@ -24,13 +25,17 @@ import { TaskScheduler } from '@backstage/backend-tasks'; export const schedulerFactory = createServiceFactory({ service: coreServices.scheduler, deps: { - config: coreServices.config, plugin: coreServices.pluginMetadata, + databaseManager: coreServices.database, + logger: coreServices.logger, }, - async factory({ config }) { - const taskScheduler = TaskScheduler.fromConfig(config); - return async ({ plugin }) => { - return taskScheduler.forPlugin(plugin.getId()); + async factory() { + return async ({ plugin, databaseManager, logger }) => { + return TaskScheduler.forPlugin({ + pluginId: plugin.getId(), + databaseManager, + logger: loggerToWinstonLogger(logger), + }); }; }, }); diff --git a/packages/backend-tasks/api-report.md b/packages/backend-tasks/api-report.md index f7b4782263..44ec4addbe 100644 --- a/packages/backend-tasks/api-report.md +++ b/packages/backend-tasks/api-report.md @@ -8,6 +8,7 @@ import { DatabaseManager } from '@backstage/backend-common'; import { Duration } from 'luxon'; import { HumanDuration as HumanDuration_2 } from '@backstage/types'; import { Logger } from 'winston'; +import { PluginDatabaseManager } from '@backstage/backend-common'; // @public @deprecated export type HumanDuration = HumanDuration_2; @@ -74,6 +75,12 @@ export class TaskScheduler { constructor(databaseManager: DatabaseManager, logger: Logger); forPlugin(pluginId: string): PluginTaskScheduler; // (undocumented) + static forPlugin(opts: { + pluginId: string; + databaseManager: PluginDatabaseManager; + logger: Logger; + }): PluginTaskScheduler; + // (undocumented) static fromConfig( config: Config, options?: { diff --git a/packages/backend-tasks/src/tasks/TaskScheduler.ts b/packages/backend-tasks/src/tasks/TaskScheduler.ts index f14fb9fc2b..fe81a0054e 100644 --- a/packages/backend-tasks/src/tasks/TaskScheduler.ts +++ b/packages/backend-tasks/src/tasks/TaskScheduler.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { DatabaseManager, getRootLogger } from '@backstage/backend-common'; +import { + DatabaseManager, + getRootLogger, + PluginDatabaseManager, +} from '@backstage/backend-common'; import { Config } from '@backstage/config'; import { once } from 'lodash'; import { Duration } from 'luxon'; @@ -57,27 +61,35 @@ export class TaskScheduler { * @returns A {@link PluginTaskScheduler} instance */ forPlugin(pluginId: string): PluginTaskScheduler { - const databaseFactory = once(async () => { - const databaseManager = this.databaseManager.forPlugin(pluginId); - const knex = await databaseManager.getClient(); + return TaskScheduler.forPlugin({ + pluginId, + databaseManager: this.databaseManager.forPlugin(pluginId), + logger: this.logger, + }); + } - if (!databaseManager.migrations?.skip) { + static forPlugin(opts: { + pluginId: string; + databaseManager: PluginDatabaseManager; + logger: Logger; + }): PluginTaskScheduler { + const databaseFactory = once(async () => { + const knex = await opts.databaseManager.getClient(); + + if (!opts.databaseManager.migrations?.skip) { await migrateBackendTasks(knex); } const janitor = new PluginTaskSchedulerJanitor({ knex, waitBetweenRuns: Duration.fromObject({ minutes: 1 }), - logger: this.logger, + logger: opts.logger, }); janitor.start(); return knex; }); - return new PluginTaskSchedulerImpl( - databaseFactory, - this.logger.child({ plugin: pluginId }), - ); + return new PluginTaskSchedulerImpl(databaseFactory, opts.logger); } } diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md index 3df3f3cb21..85cdb201ef 100644 --- a/packages/backend-test-utils/api-report.md +++ b/packages/backend-test-utils/api-report.md @@ -5,7 +5,10 @@ ```ts import { Backend } from '@backstage/backend-app-api'; import { BackendFeature } from '@backstage/backend-plugin-api'; +import { ConfigService } from '@backstage/backend-plugin-api'; +import { ExtendedHttpServer } from '@backstage/backend-app-api'; import { ExtensionPoint } from '@backstage/backend-plugin-api'; +import { JsonObject } from '@backstage/types'; import { Knex } from 'knex'; import { ServiceFactory } from '@backstage/backend-plugin-api'; import { ServiceRef } from '@backstage/backend-plugin-api'; @@ -13,6 +16,15 @@ import { ServiceRef } from '@backstage/backend-plugin-api'; // @public (undocumented) export function isDockerDisabledForTests(): boolean; +// @public (undocumented) +export const mockConfigFactory: ( + options?: + | { + data?: JsonObject | undefined; + } + | undefined, +) => ServiceFactory; + // @public export function setupRequestMockHandlers(worker: { listen: (t: any) => void; @@ -24,7 +36,14 @@ export function setupRequestMockHandlers(worker: { export function startTestBackend< TServices extends any[], TExtensionPoints extends any[], ->(options: TestBackendOptions): Promise; +>( + options: TestBackendOptions, +): Promise; + +// @alpha (undocumented) +export interface TestBackend extends Backend { + readonly server: ExtendedHttpServer; +} // @alpha (undocumented) export interface TestBackendOptions< diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index 38db9d6413..725be0d905 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -39,7 +39,10 @@ "@backstage/backend-plugin-api": "workspace:^", "@backstage/cli": "workspace:^", "@backstage/config": "workspace:^", + "@backstage/types": "workspace:^", "better-sqlite3": "^8.0.0", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", "knex": "^2.0.0", "msw": "^0.49.0", "mysql2": "^2.2.5", @@ -48,7 +51,9 @@ "uuid": "^8.0.0" }, "devDependencies": { - "@backstage/cli": "workspace:^" + "@backstage/cli": "workspace:^", + "@types/supertest": "^2.0.8", + "supertest": "^6.1.3" }, "files": [ "dist", diff --git a/packages/backend-test-utils/src/next/implementations/index.ts b/packages/backend-test-utils/src/next/implementations/index.ts new file mode 100644 index 0000000000..55f417f8df --- /dev/null +++ b/packages/backend-test-utils/src/next/implementations/index.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ +export { mockConfigFactory } from './mockConfigService'; diff --git a/packages/backend-test-utils/src/next/implementations/mockConfigService.ts b/packages/backend-test-utils/src/next/implementations/mockConfigService.ts new file mode 100644 index 0000000000..78a670585d --- /dev/null +++ b/packages/backend-test-utils/src/next/implementations/mockConfigService.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; +import { ConfigReader } from '@backstage/config'; +import { JsonObject } from '@backstage/types'; + +/** @public */ +export const mockConfigFactory = createServiceFactory({ + service: coreServices.config, + deps: {}, + async factory(_, options?: { data?: JsonObject }) { + return new ConfigReader(options?.data, 'mock-config'); + }, +}); diff --git a/packages/backend-test-utils/src/next/implementations/mockTokenManagerService.ts b/packages/backend-test-utils/src/next/implementations/mockTokenManagerService.ts new file mode 100644 index 0000000000..dd1d6b16f7 --- /dev/null +++ b/packages/backend-test-utils/src/next/implementations/mockTokenManagerService.ts @@ -0,0 +1,41 @@ +/* + * 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 { TokenManager } from '@backstage/backend-common'; +import { + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; + +class TokenManagerMock implements TokenManager { + async getToken(): Promise<{ token: string }> { + return { token: 'mock-token' }; + } + async authenticate(token: string): Promise { + if (token !== 'mock-token') { + throw new Error('Invalid token'); + } + } +} + +export const mockTokenManagerFactory = createServiceFactory({ + service: coreServices.tokenManager, + deps: {}, + async factory() { + return async () => { + return new TokenManagerMock(); + }; + }, +}); diff --git a/packages/backend-test-utils/src/next/index.ts b/packages/backend-test-utils/src/next/index.ts index 9bb5431772..9f9edfd837 100644 --- a/packages/backend-test-utils/src/next/index.ts +++ b/packages/backend-test-utils/src/next/index.ts @@ -15,3 +15,4 @@ */ export * from './wiring'; +export * from './implementations'; diff --git a/packages/backend-test-utils/src/next/wiring/TestBackend.test.ts b/packages/backend-test-utils/src/next/wiring/TestBackend.test.ts index 5ea37e9106..6358051c34 100644 --- a/packages/backend-test-utils/src/next/wiring/TestBackend.test.ts +++ b/packages/backend-test-utils/src/next/wiring/TestBackend.test.ts @@ -20,7 +20,11 @@ import { createServiceFactory, createServiceRef, coreServices, + createBackendPlugin, } from '@backstage/backend-plugin-api'; +import { Router } from 'express'; +import request from 'supertest'; + import { startTestBackend } from './TestBackend'; // This bit makes sure that test backends are cleaned up properly @@ -156,4 +160,66 @@ describe('TestBackend', () => { await backend.stop(); expect(shutdownSpy).toHaveBeenCalled(); }); + + it('should provide a set of default services', async () => { + expect.assertions(2); + + const testPlugin = createBackendPlugin({ + id: 'test', + register(env) { + env.registerInit({ + deps: { + cache: coreServices.cache, + config: coreServices.config, + database: coreServices.database, + discovery: coreServices.discovery, + httpRouter: coreServices.httpRouter, + lifecycle: coreServices.lifecycle, + logger: coreServices.logger, + permissions: coreServices.permissions, + pluginMetadata: coreServices.pluginMetadata, + rootHttpRouter: coreServices.rootHttpRouter, + rootLifecycle: coreServices.rootLifecycle, + rootLogger: coreServices.rootLogger, + scheduler: coreServices.scheduler, + tokenManager: coreServices.tokenManager, + urlReader: coreServices.urlReader, + }, + async init(deps) { + expect(Object.keys(deps)).toHaveLength(15); + expect(Object.values(deps)).not.toContain(undefined); + }, + }); + }, + }); + + await startTestBackend({ + services: [], + features: [testPlugin()], + }); + }); + + it('should allow making requests via supertest', async () => { + const testPlugin = createBackendPlugin({ + id: 'test', + register(env) { + env.registerInit({ + deps: { + httpRouter: coreServices.httpRouter, + }, + async init({ httpRouter }) { + const router = Router(); + router.use('/ping-me', (_, res) => res.json({ message: 'pong' })); + httpRouter.use(router); + }, + }); + }, + }); + + const { server } = await startTestBackend({ features: [testPlugin()] }); + + const res = await request(server).get('/api/test/ping-me'); + expect(res.status).toEqual(200); + expect(res.body).toEqual({ message: 'pong' }); + }); }); diff --git a/packages/backend-test-utils/src/next/wiring/TestBackend.ts b/packages/backend-test-utils/src/next/wiring/TestBackend.ts index 0295332855..8fe489750a 100644 --- a/packages/backend-test-utils/src/next/wiring/TestBackend.ts +++ b/packages/backend-test-utils/src/next/wiring/TestBackend.ts @@ -21,15 +21,32 @@ import { rootLifecycleFactory, loggerFactory, rootLoggerFactory, + cacheFactory, + permissionsFactory, + schedulerFactory, + urlReaderFactory, + databaseFactory, + httpRouterFactory, + MiddlewareFactory, + createHttpServer, + ExtendedHttpServer, + DefaultRootHttpRouter, } from '@backstage/backend-app-api'; +import { SingleHostDiscovery } from '@backstage/backend-common'; import { ServiceFactory, ServiceRef, createServiceFactory, BackendFeature, ExtensionPoint, + coreServices, } from '@backstage/backend-plugin-api'; +import { mockConfigFactory } from '../implementations/mockConfigService'; +import { mockTokenManagerFactory } from '../implementations/mockTokenManagerService'; +import { ConfigReader } from '@backstage/config'; +import express from 'express'; + /** @alpha */ export interface TestBackendOptions< TServices extends any[], @@ -54,11 +71,30 @@ export interface TestBackendOptions< features?: BackendFeature[]; } +/** @alpha */ +export interface TestBackend extends Backend { + /** + * Provides access to the underling HTTP server for use with utilities + * such as `supertest`. + * + * If the root http router service has been replaced, this will throw an error. + */ + readonly server: ExtendedHttpServer; +} + const defaultServiceFactories = [ - rootLoggerFactory(), - loggerFactory(), + cacheFactory(), + databaseFactory(), + httpRouterFactory(), lifecycleFactory(), + loggerFactory(), + mockConfigFactory(), + mockTokenManagerFactory(), + permissionsFactory(), rootLifecycleFactory(), + rootLoggerFactory(), + schedulerFactory(), + urlReaderFactory(), ]; const backendInstancesToCleanUp = new Array(); @@ -67,7 +103,9 @@ const backendInstancesToCleanUp = new Array(); export async function startTestBackend< TServices extends any[], TExtensionPoints extends any[], ->(options: TestBackendOptions): Promise { +>( + options: TestBackendOptions, +): Promise { const { services = [], extensionPoints = [], @@ -75,6 +113,65 @@ export async function startTestBackend< ...otherOptions } = options; + let server: ExtendedHttpServer; + + const rootHttpRouterFactory = createServiceFactory({ + service: coreServices.rootHttpRouter, + deps: { + config: coreServices.config, + lifecycle: coreServices.rootLifecycle, + rootLogger: coreServices.rootLogger, + }, + async factory({ config, lifecycle, rootLogger }) { + const router = DefaultRootHttpRouter.create(); + const logger = rootLogger.child({ service: 'rootHttpRouter' }); + + const app = express(); + + const middleware = MiddlewareFactory.create({ config, logger }); + + app.use(router.handler()); + app.use(middleware.notFound()); + app.use(middleware.error()); + + server = await createHttpServer( + app, + { listen: { host: '', port: 0 } }, + { logger }, + ); + + lifecycle.addShutdownHook({ + async fn() { + await server.stop(); + }, + logger, + }); + + await server.start(); + + return router; + }, + }); + + const discoveryFactory = createServiceFactory({ + service: coreServices.discovery, + deps: { + rootHttpRouter: coreServices.rootHttpRouter, + }, + async factory() { + if (!server) { + throw new Error('Test server not started yet'); + } + const port = server.port(); + const discovery = SingleHostDiscovery.fromConfig( + new ConfigReader({ + backend: { baseUrl: `http://localhost:${port}`, listen: { port } }, + }), + ); + return async () => discovery; + }, + }); + const factories = services.map(serviceDef => { if (Array.isArray(serviceDef)) { // if type is ExtensionPoint? @@ -107,7 +204,7 @@ export async function startTestBackend< const backend = createSpecializedBackend({ ...otherOptions, - services: factories, + services: [...factories, rootHttpRouterFactory, discoveryFactory], }); backendInstancesToCleanUp.push(backend); @@ -129,7 +226,14 @@ export async function startTestBackend< await backend.start(); - return backend; + return Object.assign(backend, { + get server() { + if (!server) { + throw new Error('TestBackend server is not available'); + } + return server; + }, + }); } let registered = false; diff --git a/packages/backend-test-utils/src/next/wiring/index.ts b/packages/backend-test-utils/src/next/wiring/index.ts index eb7b773e33..7c39474d4c 100644 --- a/packages/backend-test-utils/src/next/wiring/index.ts +++ b/packages/backend-test-utils/src/next/wiring/index.ts @@ -15,4 +15,4 @@ */ export { startTestBackend } from './TestBackend'; -export type { TestBackendOptions } from './TestBackend'; +export type { TestBackend, TestBackendOptions } from './TestBackend'; diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index 1f58f64796..d5fbf9f239 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -56,7 +56,6 @@ "@backstage/cli": "workspace:^", "@backstage/types": "workspace:^", "@types/supertest": "^2.0.8", - "get-port": "^6.1.2", "mock-fs": "^5.1.0", "msw": "^0.49.0", "node-fetch": "^2.6.7", diff --git a/plugins/app-backend/src/service/appPlugin.test.ts b/plugins/app-backend/src/service/appPlugin.test.ts index 80408694d6..7c57a5d685 100644 --- a/plugins/app-backend/src/service/appPlugin.test.ts +++ b/plugins/app-backend/src/service/appPlugin.test.ts @@ -17,18 +17,14 @@ import mockFs from 'mock-fs'; import { resolve as resolvePath } from 'path'; import fetch from 'node-fetch'; -import { coreServices } from '@backstage/backend-plugin-api'; import { startTestBackend } from '@backstage/backend-test-utils'; import { appPlugin } from './appPlugin'; import { databaseFactory, httpRouterFactory, - rootHttpRouterFactory, loggerFactory, rootLoggerFactory, } from '@backstage/backend-app-api'; -import { ConfigReader } from '@backstage/config'; -import getPort from 'get-port'; describe('appPlugin', () => { beforeEach(() => { @@ -48,23 +44,12 @@ describe('appPlugin', () => { }); it('boots', async () => { - const port = await getPort(); - await startTestBackend({ + const { server } = await startTestBackend({ services: [ - [ - coreServices.config, - new ConfigReader({ - backend: { - listen: { port }, - database: { client: 'better-sqlite3', connection: ':memory:' }, - }, - }), - ], loggerFactory(), rootLoggerFactory(), databaseFactory(), httpRouterFactory(), - rootHttpRouterFactory(), ], features: [ appPlugin({ @@ -75,12 +60,12 @@ describe('appPlugin', () => { }); await expect( - fetch(`http://localhost:${port}/api/app/derp.html`).then(res => + fetch(`http://localhost:${server.port()}/api/app/derp.html`).then(res => res.text(), ), ).resolves.toBe('winning'); await expect( - fetch(`http://localhost:${port}`).then(res => res.text()), + fetch(`http://localhost:${server.port()}`).then(res => res.text()), ).resolves.toBe('winning'); }); }); diff --git a/plugins/catalog-backend-module-bitbucket-cloud/src/service/BitbucketCloudEntityProviderCatalogModule.test.ts b/plugins/catalog-backend-module-bitbucket-cloud/src/service/BitbucketCloudEntityProviderCatalogModule.test.ts index 83e657bcfe..d44281600c 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/src/service/BitbucketCloudEntityProviderCatalogModule.test.ts +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/service/BitbucketCloudEntityProviderCatalogModule.test.ts @@ -14,18 +14,15 @@ * limitations under the License. */ -import { ConfigReader } from '@backstage/config'; -import { - getVoidLogger, - PluginEndpointDiscovery, - TokenManager, -} from '@backstage/backend-common'; import { coreServices } from '@backstage/backend-plugin-api'; import { PluginTaskScheduler, TaskScheduleDefinition, } from '@backstage/backend-tasks'; -import { startTestBackend } from '@backstage/backend-test-utils'; +import { + startTestBackend, + mockConfigFactory, +} from '@backstage/backend-test-utils'; import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node'; import { eventsExtensionPoint } from '@backstage/plugin-events-node'; import { Duration } from 'luxon'; @@ -55,22 +52,6 @@ describe('bitbucketCloudEntityProviderCatalogModule', () => { return runner; }, } as unknown as PluginTaskScheduler; - const discovery = jest.fn() as any as PluginEndpointDiscovery; - const tokenManager = jest.fn() as any as TokenManager; - - const config = new ConfigReader({ - catalog: { - providers: { - bitbucketCloud: { - schedule: { - frequency: 'P1M', - timeout: 'PT3M', - }, - workspace: 'test-ws', - }, - }, - }, - }); await startTestBackend({ extensionPoints: [ @@ -78,11 +59,22 @@ describe('bitbucketCloudEntityProviderCatalogModule', () => { [eventsExtensionPoint, eventsExtensionPointImpl], ], services: [ - [coreServices.config, config], - [coreServices.discovery, discovery], - [coreServices.logger, getVoidLogger()], + mockConfigFactory({ + data: { + catalog: { + providers: { + bitbucketCloud: { + schedule: { + frequency: 'P1M', + timeout: 'PT3M', + }, + workspace: 'test-ws', + }, + }, + }, + }, + }), [coreServices.scheduler, scheduler], - [coreServices.tokenManager, tokenManager], ], features: [bitbucketCloudEntityProviderCatalogModule()], }); diff --git a/plugins/catalog-backend-module-incremental-ingestion/package.json b/plugins/catalog-backend-module-incremental-ingestion/package.json index 06a0ab1c89..a86e27fae4 100644 --- a/plugins/catalog-backend-module-incremental-ingestion/package.json +++ b/plugins/catalog-backend-module-incremental-ingestion/package.json @@ -56,8 +56,7 @@ "devDependencies": { "@backstage/backend-app-api": "workspace:^", "@backstage/cli": "workspace:^", - "@backstage/plugin-catalog-backend": "workspace:^", - "get-port": "^6.1.2" + "@backstage/plugin-catalog-backend": "workspace:^" }, "files": [ "alpha", diff --git a/plugins/catalog-node/src/catalogService.test.ts b/plugins/catalog-node/src/catalogService.test.ts index 230af79a88..ec3e5ccf1b 100644 --- a/plugins/catalog-node/src/catalogService.test.ts +++ b/plugins/catalog-node/src/catalogService.test.ts @@ -14,11 +14,7 @@ * limitations under the License. */ -import { PluginEndpointDiscovery } from '@backstage/backend-common'; -import { - createBackendModule, - coreServices, -} from '@backstage/backend-plugin-api'; +import { createBackendModule } from '@backstage/backend-plugin-api'; import { startTestBackend } from '@backstage/backend-test-utils'; import { CatalogClient } from '@backstage/catalog-client'; import { catalogServiceRef } from './catalogService'; @@ -42,9 +38,6 @@ describe('catalogServiceRef', () => { }); await startTestBackend({ - services: [ - [coreServices.discovery, {} as unknown as PluginEndpointDiscovery], - ], features: [testModule()], }); }); diff --git a/yarn.lock b/yarn.lock index 14835db18d..96c319f937 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3400,6 +3400,7 @@ __metadata: fs-extra: 10.1.0 helmet: ^6.0.0 http-errors: ^2.0.0 + lodash: ^4.17.21 minimatch: ^5.0.0 morgan: ^1.10.0 node-forge: ^1.3.1 @@ -3558,11 +3559,16 @@ __metadata: "@backstage/backend-plugin-api": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" + "@backstage/types": "workspace:^" + "@types/supertest": ^2.0.8 better-sqlite3: ^8.0.0 + express: ^4.17.1 + express-promise-router: ^4.1.0 knex: ^2.0.0 msw: ^0.49.0 mysql2: ^2.2.5 pg: ^8.3.0 + supertest: ^6.1.3 testcontainers: ^8.1.2 uuid: ^8.0.0 languageName: unknown @@ -4513,7 +4519,6 @@ __metadata: express: ^4.17.1 express-promise-router: ^4.1.0 fs-extra: 10.1.0 - get-port: ^6.1.2 globby: ^11.0.0 helmet: ^6.0.0 knex: ^2.0.0 @@ -5112,7 +5117,6 @@ __metadata: "@types/luxon": ^3.0.0 express: ^4.17.1 express-promise-router: ^4.1.0 - get-port: ^6.1.2 knex: ^2.0.0 lodash: ^4.17.21 luxon: ^3.0.0 @@ -23809,13 +23813,6 @@ __metadata: languageName: node linkType: hard -"get-port@npm:^6.1.2": - version: 6.1.2 - resolution: "get-port@npm:6.1.2" - checksum: e3c3d591492a11393455ef220f24c812a28f7da56ec3e4a2512d931a1f196d42850b50ac6138349a44622eda6dc3c0ccd8495cd91376d968e2d9e6f6f849e0a9 - languageName: node - linkType: hard - "get-stdin@npm:^8.0.0": version: 8.0.0 resolution: "get-stdin@npm:8.0.0"