diff --git a/.changeset/fair-ants-work.md b/.changeset/fair-ants-work.md new file mode 100644 index 0000000000..4cf3c80488 --- /dev/null +++ b/.changeset/fair-ants-work.md @@ -0,0 +1,7 @@ +--- +'@backstage/backend-app-api': minor +--- + +**BREAKING**: The `httpRouterFactory` now accepts a `getPath` option rather than `indexPlugin`. To set up custom index path, configure the new `rootHttpRouterFactory` with a custom `indexPath` instead. + +Added an implementation for the new `rootHttpRouterServiceRef`. diff --git a/.changeset/gorgeous-days-applaud.md b/.changeset/gorgeous-days-applaud.md new file mode 100644 index 0000000000..a34307d720 --- /dev/null +++ b/.changeset/gorgeous-days-applaud.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-plugin-api': patch +--- + +Added a new `rootHttpRouterServiceRef` and `RootHttpRouterService` interface. diff --git a/.changeset/little-beans-hammer.md b/.changeset/little-beans-hammer.md new file mode 100644 index 0000000000..d998f5f5ba --- /dev/null +++ b/.changeset/little-beans-hammer.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': patch +--- + +The `startTestBackend` setup now includes default implementations for all core services. diff --git a/.changeset/tiny-cooks-hide.md b/.changeset/tiny-cooks-hide.md new file mode 100644 index 0000000000..feab4b65d2 --- /dev/null +++ b/.changeset/tiny-cooks-hide.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +The new root HTTP router service is now installed by default. diff --git a/packages/backend-app-api/api-report.md b/packages/backend-app-api/api-report.md index fa3f3bec2e..dcf3ced5e8 100644 --- a/packages/backend-app-api/api-report.md +++ b/packages/backend-app-api/api-report.md @@ -8,11 +8,13 @@ import { CacheService } from '@backstage/backend-plugin-api'; import { ConfigService } from '@backstage/backend-plugin-api'; import { DatabaseService } from '@backstage/backend-plugin-api'; import { ExtensionPoint } from '@backstage/backend-plugin-api'; +import { Handler } from 'express'; import { HttpRouterService } from '@backstage/backend-plugin-api'; import { LifecycleService } from '@backstage/backend-plugin-api'; import { LoggerService } from '@backstage/backend-plugin-api'; import { PermissionsService } from '@backstage/backend-plugin-api'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { RootHttpRouterService } from '@backstage/backend-plugin-api'; import { RootLifecycleService } from '@backstage/backend-plugin-api'; import { RootLoggerService } from '@backstage/backend-plugin-api'; import { SchedulerService } from '@backstage/backend-plugin-api'; @@ -69,7 +71,7 @@ export const httpRouterFactory: ( // @public (undocumented) export type HttpRouterFactoryOptions = { - indexPlugin?: string; + getPath(pluginId: string): string; }; // @public @@ -87,6 +89,17 @@ export const permissionsFactory: ( options?: undefined, ) => ServiceFactory; +// @public (undocumented) +export const rootHttpRouterFactory: ( + options?: RootHttpRouterFactoryOptions | undefined, +) => ServiceFactory; + +// @public (undocumented) +export type RootHttpRouterFactoryOptions = { + indexPath?: string | false; + middleware?: Handler[]; +}; + // @public export const rootLifecycleFactory: ( options?: undefined, diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json index 93230341df..80598ec93d 100644 --- a/packages/backend-app-api/package.json +++ b/packages/backend-app-api/package.json @@ -38,6 +38,7 @@ "@backstage/backend-tasks": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/plugin-permission-node": "workspace:^", + "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", "winston": "^3.2.1" diff --git a/packages/backend-app-api/src/services/implementations/httpRouterService.test.ts b/packages/backend-app-api/src/services/implementations/httpRouterService.test.ts new file mode 100644 index 0000000000..89f6db2ac8 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/httpRouterService.test.ts @@ -0,0 +1,70 @@ +/* + * 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 { + HttpRouterService, + ServiceFactory, +} from '@backstage/backend-plugin-api'; +import { httpRouterFactory } from './httpRouterService'; + +describe('httpRouterFactory', () => { + it('should register plugin paths', async () => { + const rootHttpRouter = { use: jest.fn() }; + const factory = httpRouterFactory() as Exclude< + ServiceFactory, + { scope: 'root' } + >; + const innerFactory = await factory.factory({ rootHttpRouter }); + + const handler1 = () => {}; + const router1 = await innerFactory({ plugin: { getId: () => 'test1' } }); + router1.use(handler1); + expect(rootHttpRouter.use).toHaveBeenCalledTimes(1); + expect(rootHttpRouter.use).toHaveBeenCalledWith('/api/test1', handler1); + + const handler2 = () => {}; + const router2 = await innerFactory({ plugin: { getId: () => 'test2' } }); + router2.use(handler2); + expect(rootHttpRouter.use).toHaveBeenCalledTimes(2); + expect(rootHttpRouter.use).toHaveBeenCalledWith('/api/test2', handler2); + }); + + it('should use custom path generator', async () => { + const rootHttpRouter = { use: jest.fn() }; + const factory = httpRouterFactory({ + getPath: id => `/some/${id}/path`, + }) as Exclude, { scope: 'root' }>; + const innerFactory = await factory.factory({ rootHttpRouter }); + + const handler1 = () => {}; + const router1 = await innerFactory({ plugin: { getId: () => 'test1' } }); + router1.use(handler1); + expect(rootHttpRouter.use).toHaveBeenCalledTimes(1); + expect(rootHttpRouter.use).toHaveBeenCalledWith( + '/some/test1/path', + handler1, + ); + + const handler2 = () => {}; + const router2 = await innerFactory({ plugin: { getId: () => 'test2' } }); + router2.use(handler2); + expect(rootHttpRouter.use).toHaveBeenCalledTimes(2); + expect(rootHttpRouter.use).toHaveBeenCalledWith( + '/some/test2/path', + handler2, + ); + }); +}); diff --git a/packages/backend-app-api/src/services/implementations/httpRouterService.ts b/packages/backend-app-api/src/services/implementations/httpRouterService.ts index 2021589a50..f1fc089ea3 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouterService.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouterService.ts @@ -18,49 +18,33 @@ import { createServiceFactory, coreServices, } from '@backstage/backend-plugin-api'; -import Router from 'express-promise-router'; import { Handler } from 'express'; -import { createServiceBuilder } from '@backstage/backend-common'; /** * @public */ export type HttpRouterFactoryOptions = { /** - * The plugin ID used for the index route. Defaults to 'app' + * A callback used to generate the path for each plugin, defaults to `/api/{pluginId}`. */ - indexPlugin?: string; + getPath(pluginId: string): string; }; /** @public */ export const httpRouterFactory = createServiceFactory({ service: coreServices.httpRouter, deps: { - config: coreServices.config, plugin: coreServices.pluginMetadata, + rootHttpRouter: coreServices.rootHttpRouter, }, - async factory({ config }, options?: HttpRouterFactoryOptions) { - const defaultPluginId = options?.indexPlugin ?? 'app'; - - const apiRouter = Router(); - const rootRouter = Router(); - - const service = createServiceBuilder(module) - .loadConfig(config) - .addRouter('/api', apiRouter) - .addRouter('', rootRouter); - - await service.start(); + async factory({ rootHttpRouter }, options?: HttpRouterFactoryOptions) { + const getPath = options?.getPath ?? (id => `/api/${id}`); return async ({ plugin }) => { - const pluginId = plugin.getId(); + const path = getPath(plugin.getId()); return { use(handler: Handler) { - if (pluginId === defaultPluginId) { - rootRouter.use(handler); - } else { - apiRouter.use(`/${pluginId}`, handler); - } + rootHttpRouter.use(path, handler); }, }; }; diff --git a/packages/backend-app-api/src/services/implementations/index.ts b/packages/backend-app-api/src/services/implementations/index.ts index c87011d570..2365ff36f4 100644 --- a/packages/backend-app-api/src/services/implementations/index.ts +++ b/packages/backend-app-api/src/services/implementations/index.ts @@ -25,6 +25,8 @@ export { schedulerFactory } from './schedulerService'; export { tokenManagerFactory } from './tokenManagerService'; export { urlReaderFactory } from './urlReaderService'; export { httpRouterFactory } from './httpRouterService'; +export { rootHttpRouterFactory } from './rootHttpRouterService'; export { lifecycleFactory } from './lifecycleService'; export { rootLifecycleFactory } from './rootLifecycleService'; export type { HttpRouterFactoryOptions } from './httpRouterService'; +export type { RootHttpRouterFactoryOptions } from './rootHttpRouterService'; diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouterService.test.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouterService.test.ts new file mode 100644 index 0000000000..fea99b0df9 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/rootHttpRouterService.test.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 { findConflictingPath } from './rootHttpRouterService'; + +describe('findConflictingPath', () => { + it('finds conflicts when present', () => { + expect(findConflictingPath(['/a'], '/a')).toBe('/a'); + expect(findConflictingPath(['/b'], '/a')).toBe(undefined); + expect(findConflictingPath(['/a'], '/a/b')).toBe('/a'); + expect(findConflictingPath(['/a'], '/aa/b')).toBe(undefined); + expect(findConflictingPath(['/aa'], '/a/b')).toBe(undefined); + expect(findConflictingPath(['/a/b'], '/a')).toBe('/a/b'); + expect(findConflictingPath(['/a/b'], '/aa')).toBe(undefined); + expect(findConflictingPath(['/b/a'], '/a')).toBe(undefined); + expect(findConflictingPath(['/a'], '/aa')).toBe(undefined); + }); +}); diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouterService.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouterService.ts new file mode 100644 index 0000000000..dae7474793 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/rootHttpRouterService.ts @@ -0,0 +1,125 @@ +/* + * 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 { + createServiceFactory, + coreServices, +} from '@backstage/backend-plugin-api'; +import Router from 'express-promise-router'; +import { Handler } from 'express'; +import { createServiceBuilder } from '@backstage/backend-common'; + +/** + * @public + */ +export type RootHttpRouterFactoryOptions = { + /** + * The path to forward all unmatched requests to. Defaults to '/api/app' + */ + indexPath?: string | false; + + /** + * Middlewares that are added before all other routes. + */ + middleware?: Handler[]; +}; + +/** @public */ +export const rootHttpRouterFactory = createServiceFactory({ + service: coreServices.rootHttpRouter, + deps: { + config: coreServices.config, + lifecycle: coreServices.rootLifecycle, + }, + async factory({ config, lifecycle }, options?: RootHttpRouterFactoryOptions) { + const indexPath = options?.indexPath ?? '/api/app'; + + const namedRouter = Router(); + const indexRouter = Router(); + + const service = createServiceBuilder(module).loadConfig(config); + + for (const middleware of options?.middleware ?? []) { + service.addRouter('', middleware); + } + + service.addRouter('', namedRouter).addRouter('', indexRouter); + + const server = await service.start(); + // Stop method isn't part of the public API, let's fix that once we move the implementation here. + const stoppableServer = server as typeof server & { + stop: (cb: (error?: Error) => void) => void; + }; + + lifecycle.addShutdownHook({ + async fn() { + await new Promise((resolve, reject) => { + stoppableServer.stop((error?: Error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + }, + labels: { service: 'rootHttpRouter' }, + }); + + const existingPaths = new Array(); + + return { + use: (path: string, handler: Handler) => { + if (path.match(/^[/\s]*$/)) { + throw new Error(`Root router path may not be empty`); + } + const conflictingPath = findConflictingPath(existingPaths, path); + if (conflictingPath) { + throw new Error( + `Path ${path} conflicts with the existing path ${conflictingPath}`, + ); + } + existingPaths.push(path); + namedRouter.use(path, handler); + + if (indexPath === path) { + indexRouter.use(handler); + } + }, + }; + }, +}); + +function normalizePath(path: string): string { + return path.replace(/\/*$/, '/'); +} + +export function findConflictingPath( + paths: string[], + newPath: string, +): string | undefined { + const normalizedNewPath = normalizePath(newPath); + for (const path of paths) { + const normalizedPath = normalizePath(path); + if (normalizedPath.startsWith(normalizedNewPath)) { + return path; + } + if (normalizedNewPath.startsWith(normalizedPath)) { + return path; + } + } + return undefined; +} diff --git a/packages/backend-defaults/src/CreateBackend.ts b/packages/backend-defaults/src/CreateBackend.ts index a37a8c32b2..20edfc85a8 100644 --- a/packages/backend-defaults/src/CreateBackend.ts +++ b/packages/backend-defaults/src/CreateBackend.ts @@ -22,6 +22,7 @@ import { databaseFactory, discoveryFactory, httpRouterFactory, + rootHttpRouterFactory, lifecycleFactory, rootLifecycleFactory, loggerFactory, @@ -45,6 +46,7 @@ export const defaultServiceFactories = [ tokenManagerFactory, urlReaderFactory, httpRouterFactory, + rootHttpRouterFactory, lifecycleFactory, rootLifecycleFactory, ]; diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 16f00ad89f..3ec60bde46 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -80,6 +80,7 @@ export namespace coreServices { const logger: ServiceRef; const permissions: ServiceRef; const pluginMetadata: ServiceRef; + const rootHttpRouter: ServiceRef; const rootLifecycle: ServiceRef; const rootLogger: ServiceRef; const scheduler: ServiceRef; @@ -257,6 +258,11 @@ export type ReadUrlResponse = { etag?: string; }; +// @public (undocumented) +export interface RootHttpRouterService { + use(path: string, handler: Handler): void; +} + // @public (undocumented) export interface RootLifecycleService extends LifecycleService {} diff --git a/packages/backend-plugin-api/src/services/definitions/RootHttpRouterService.ts b/packages/backend-plugin-api/src/services/definitions/RootHttpRouterService.ts new file mode 100644 index 0000000000..e583e978fa --- /dev/null +++ b/packages/backend-plugin-api/src/services/definitions/RootHttpRouterService.ts @@ -0,0 +1,28 @@ +/* + * 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 { Handler } from 'express'; + +/** + * @public + */ +export interface RootHttpRouterService { + /** + * Registers a handler at the root of the backend router. + * The path is required and may not be empty. + */ + use(path: string, handler: Handler): void; +} diff --git a/packages/backend-plugin-api/src/services/definitions/coreServices.ts b/packages/backend-plugin-api/src/services/definitions/coreServices.ts index fc96ec8be9..7de92d6d01 100644 --- a/packages/backend-plugin-api/src/services/definitions/coreServices.ts +++ b/packages/backend-plugin-api/src/services/definitions/coreServices.ts @@ -103,6 +103,15 @@ export namespace coreServices { import('./PluginMetadataService').PluginMetadataService >({ id: 'core.pluginMetadata' }); + /** + * The service reference for the root scoped {@link RootHttpRouterService}. + * + * @public + */ + export const rootHttpRouter = createServiceRef< + import('./RootHttpRouterService').RootHttpRouterService + >({ id: 'core.rootHttpRouter', scope: 'root' }); + /** * The service reference for the root scoped {@link RootLifecycleService}. * diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index dedfd6223e..82b16efda2 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -27,6 +27,7 @@ export type { export type { LoggerService, LogMeta } from './LoggerService'; export type { PermissionsService } from './PermissionsService'; export type { PluginMetadataService } from './PluginMetadataService'; +export type { RootHttpRouterService } from './RootHttpRouterService'; export type { RootLifecycleService } from './RootLifecycleService'; export type { RootLoggerService } from './RootLoggerService'; export type { SchedulerService } from './SchedulerService'; diff --git a/plugins/app-backend/src/service/appPlugin.test.ts b/plugins/app-backend/src/service/appPlugin.test.ts index 14921622c9..80408694d6 100644 --- a/plugins/app-backend/src/service/appPlugin.test.ts +++ b/plugins/app-backend/src/service/appPlugin.test.ts @@ -23,6 +23,7 @@ import { appPlugin } from './appPlugin'; import { databaseFactory, httpRouterFactory, + rootHttpRouterFactory, loggerFactory, rootLoggerFactory, } from '@backstage/backend-app-api'; @@ -63,6 +64,7 @@ describe('appPlugin', () => { rootLoggerFactory(), databaseFactory(), httpRouterFactory(), + rootHttpRouterFactory(), ], features: [ appPlugin({ diff --git a/yarn.lock b/yarn.lock index 44a2b9f9b2..6c74158f04 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3382,6 +3382,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/plugin-permission-node": "workspace:^" + "@types/express": ^4.17.6 express: ^4.17.1 express-promise-router: ^4.1.0 winston: ^3.2.1