Merge pull request #15462 from backstage/mob/root-http-router
backend-next: introduce new root http router service
This commit is contained in:
@@ -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`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-plugin-api': patch
|
||||
---
|
||||
|
||||
Added a new `rootHttpRouterServiceRef` and `RootHttpRouterService` interface.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-test-utils': patch
|
||||
---
|
||||
|
||||
The `startTestBackend` setup now includes default implementations for all core services.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-defaults': patch
|
||||
---
|
||||
|
||||
The new root HTTP router service is now installed by default.
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user