Merge pull request #15462 from backstage/mob/root-http-router

backend-next: introduce new root http router service
This commit is contained in:
Patrik Oldsberg
2023-01-04 14:34:40 +01:00
committed by GitHub
18 changed files with 321 additions and 24 deletions
+7
View File
@@ -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`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-plugin-api': patch
---
Added a new `rootHttpRouterServiceRef` and `RootHttpRouterService` interface.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-test-utils': patch
---
The `startTestBackend` setup now includes default implementations for all core services.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-defaults': patch
---
The new root HTTP router service is now installed by default.
+14 -1
View File
@@ -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<PermissionsService>;
// @public (undocumented)
export const rootHttpRouterFactory: (
options?: RootHttpRouterFactoryOptions | undefined,
) => ServiceFactory<RootHttpRouterService>;
// @public (undocumented)
export type RootHttpRouterFactoryOptions = {
indexPath?: string | false;
middleware?: Handler[];
};
// @public
export const rootLifecycleFactory: (
options?: undefined,
+1
View File
@@ -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"
@@ -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<HttpRouterService>,
{ 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<ServiceFactory<HttpRouterService>, { 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,
);
});
});
@@ -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);
},
};
};
@@ -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';
@@ -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);
});
});
@@ -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<void>((resolve, reject) => {
stoppableServer.stop((error?: Error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
},
labels: { service: 'rootHttpRouter' },
});
const existingPaths = new Array<string>();
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;
}
@@ -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,
];
@@ -80,6 +80,7 @@ export namespace coreServices {
const logger: ServiceRef<LoggerService, 'plugin'>;
const permissions: ServiceRef<PermissionsService, 'plugin'>;
const pluginMetadata: ServiceRef<PluginMetadataService, 'plugin'>;
const rootHttpRouter: ServiceRef<RootHttpRouterService, 'root'>;
const rootLifecycle: ServiceRef<RootLifecycleService, 'root'>;
const rootLogger: ServiceRef<RootLoggerService, 'root'>;
const scheduler: ServiceRef<SchedulerService, 'plugin'>;
@@ -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 {}
@@ -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;
}
@@ -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}.
*
@@ -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';
@@ -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({
+1
View File
@@ -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