backend-app-api: extract RestrictedIndexedRouter out of root router

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-01-03 17:40:45 +01:00
parent 6eb86a13af
commit b777869011
4 changed files with 130 additions and 79 deletions
@@ -0,0 +1,51 @@
/*
* 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 { RestrictedIndexedRouter } from './RestrictedIndexedRouter';
describe('RestrictedIndexedRouter', () => {
it.each([
[['/b'], '/a'],
[['/a'], '/aa/b'],
[['/aa'], '/a/b'],
[['/a/b'], '/aa'],
[['/b/a'], '/a'],
[['/a'], '/aa'],
])(`with existing paths %s, adds %s without conflict`, (existing, added) => {
const router = new RestrictedIndexedRouter(false);
for (const path of existing) {
router.use(path, () => {});
}
expect(() => router.use(added, () => {})).not.toThrow();
});
it.each([
[['/a'], '/a', '/a'],
[['/a'], '/a/b', '/a'],
[['/a/b'], '/a', '/a/b'],
])(
`find conflict when existing paths %s, adds %s`,
(existing, added, conflict) => {
const router = new RestrictedIndexedRouter(false);
for (const path of existing) {
router.use(path, () => {});
}
expect(() => router.use(added, () => {})).toThrow(
`Path ${added} conflicts with the existing path ${conflict}`,
);
},
);
});
@@ -0,0 +1,73 @@
/*
* 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 { RootHttpRouterService } from '@backstage/backend-plugin-api';
import { Handler, Router } from 'express';
function normalizePath(path: string): string {
return path.replace(/\/*$/, '/');
}
export class RestrictedIndexedRouter implements RootHttpRouterService {
#indexPath?: false | string;
#router = Router();
#namedRoutes = Router();
#indexRouter = Router();
#existingPaths = new Array<string>();
constructor(indexPath?: false | string) {
this.#indexPath = indexPath;
this.#router.use(this.#namedRoutes);
this.#router.use(this.#indexRouter);
}
use(path: string, handler: Handler) {
if (path.match(/^[/\s]*$/)) {
throw new Error(`Root router path may not be empty`);
}
const conflictingPath = this.#findConflictingPath(path);
if (conflictingPath) {
throw new Error(
`Path ${path} conflicts with the existing path ${conflictingPath}`,
);
}
this.#existingPaths.push(path);
this.#namedRoutes.use(path, handler);
if (this.#indexPath === path) {
this.#indexRouter.use(handler);
}
}
handler(): Handler {
return this.#router;
}
#findConflictingPath(newPath: string): string | undefined {
const normalizedNewPath = normalizePath(newPath);
for (const path of this.#existingPaths) {
const normalizedPath = normalizePath(path);
if (normalizedPath.startsWith(normalizedNewPath)) {
return path;
}
if (normalizedNewPath.startsWith(normalizedPath)) {
return path;
}
}
return undefined;
}
}
@@ -1,31 +0,0 @@
/*
* 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 './rootHttpRouterFactory';
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);
});
});
@@ -18,9 +18,9 @@ import {
createServiceFactory,
coreServices,
} from '@backstage/backend-plugin-api';
import Router from 'express-promise-router';
import { Handler } from 'express';
import { createServiceBuilder } from '@backstage/backend-common';
import { RestrictedIndexedRouter } from './RestrictedIndexedRouter';
/**
* @public
@@ -45,10 +45,9 @@ export const rootHttpRouterFactory = createServiceFactory({
lifecycle: coreServices.rootLifecycle,
},
async factory({ config, lifecycle }, options?: RootHttpRouterFactoryOptions) {
const indexPath = options?.indexPath ?? '/api/app';
const namedRouter = Router();
const indexRouter = Router();
const router = new RestrictedIndexedRouter(
options?.indexPath ?? '/api/app',
);
const service = createServiceBuilder(module).loadConfig(config);
@@ -56,7 +55,7 @@ export const rootHttpRouterFactory = createServiceFactory({
service.addRouter('', middleware);
}
service.addRouter('', namedRouter).addRouter('', indexRouter);
service.addRouter('', router.handler());
const server = await service.start();
// Stop method isn't part of the public API, let's fix that once we move the implementation here.
@@ -79,47 +78,6 @@ export const rootHttpRouterFactory = createServiceFactory({
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);
}
},
};
return router;
},
});
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;
}