Merge pull request #15589 from backstage/rugvip/httpmove

backand-app-api: move over HTTP server implementation from backend-common
This commit is contained in:
Patrik Oldsberg
2023-01-10 13:57:24 +01:00
committed by GitHub
38 changed files with 1767 additions and 840 deletions
+103 -2
View File
@@ -3,10 +3,17 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="node" />
import { BackendFeature } from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import { ConfigService } from '@backstage/backend-plugin-api';
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';
import { LifecycleService } from '@backstage/backend-plugin-api';
import { LoggerService } from '@backstage/backend-plugin-api';
@@ -14,6 +21,8 @@ import { PermissionsService } from '@backstage/backend-plugin-api';
import { PluginCacheManager } from '@backstage/backend-common';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { RequestHandler } from 'express';
import { RequestListener } from 'http';
import { RootHttpRouterService } from '@backstage/backend-plugin-api';
import { RootLifecycleService } from '@backstage/backend-plugin-api';
import { RootLoggerService } from '@backstage/backend-plugin-api';
@@ -44,6 +53,15 @@ export const configFactory: (
options?: undefined,
) => ServiceFactory<ConfigService>;
// @public
export function createHttpServer(
listener: RequestListener,
options: HttpServerOptions,
deps: {
logger: LoggerService;
},
): Promise<ExtendedHttpServer>;
// @public (undocumented)
export function createSpecializedBackend(
options: CreateSpecializedBackendOptions,
@@ -65,6 +83,16 @@ export const discoveryFactory: (
options?: undefined,
) => ServiceFactory<PluginEndpointDiscovery>;
// @public
export interface ExtendedHttpServer extends http.Server {
// (undocumented)
port(): number;
// (undocumented)
start(): Promise<void>;
// (undocumented)
stop(): Promise<void>;
}
// @public (undocumented)
export const httpRouterFactory: (
options?: HttpRouterFactoryOptions | undefined,
@@ -75,6 +103,29 @@ export type HttpRouterFactoryOptions = {
getPath(pluginId: string): string;
};
// @public
export type HttpServerCertificateOptions =
| {
type: 'plain';
key: string;
cert: string;
}
| {
type: 'generated';
hostname: string;
};
// @public
export type HttpServerOptions = {
listen: {
port: number;
host: string;
};
https?: {
certificate: HttpServerCertificateOptions;
};
};
// @public
export const lifecycleFactory: (
options?: undefined,
@@ -85,11 +136,61 @@ export const loggerFactory: (
options?: undefined,
) => ServiceFactory<LoggerService>;
// @public
export class MiddlewareFactory {
compression(): RequestHandler;
cors(): RequestHandler;
static create(options: MiddlewareFactoryOptions): MiddlewareFactory;
error(options?: MiddlewareFactoryErrorOptions): ErrorRequestHandler;
helmet(): RequestHandler;
logging(): RequestHandler;
notFound(): RequestHandler;
}
// @public
export interface MiddlewareFactoryErrorOptions {
logAllErrors?: boolean;
showStackTraces?: boolean;
}
// @public
export interface MiddlewareFactoryOptions {
// (undocumented)
config: ConfigService;
// (undocumented)
logger: LoggerService;
}
// @public (undocumented)
export const permissionsFactory: (
options?: undefined,
) => ServiceFactory<PermissionsService>;
// @public
export function readCorsOptions(config?: Config): CorsOptions;
// @public
export function readHelmetOptions(config?: Config): HelmetOptions;
// @public
export function readHttpServerOptions(config?: Config): HttpServerOptions;
// @public (undocumented)
export interface RootHttpRouterConfigureOptions {
// (undocumented)
app: Express_2;
// (undocumented)
config: ConfigService;
// (undocumented)
lifecycle: LifecycleService;
// (undocumented)
logger: LoggerService;
// (undocumented)
middleware: MiddlewareFactory;
// (undocumented)
routes: RequestHandler;
}
// @public (undocumented)
export const rootHttpRouterFactory: (
options?: RootHttpRouterFactoryOptions | undefined,
@@ -98,7 +199,7 @@ export const rootHttpRouterFactory: (
// @public (undocumented)
export type RootHttpRouterFactoryOptions = {
indexPath?: string | false;
middleware?: Handler[];
configure?(options: RootHttpRouterConfigureOptions): void;
};
// @public
+20 -1
View File
@@ -36,15 +36,34 @@
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/backend-tasks": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-permission-node": "workspace:^",
"@types/cors": "^2.8.6",
"@types/express": "^4.17.6",
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"fs-extra": "10.1.0",
"helmet": "^6.0.0",
"minimatch": "^5.0.0",
"morgan": "^1.10.0",
"node-forge": "^1.3.1",
"selfsigned": "^2.0.0",
"stoppable": "^1.1.0",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "workspace:^"
"@backstage/cli": "workspace:^",
"@types/compression": "^1.7.0",
"@types/fs-extra": "^9.0.3",
"@types/http-errors": "^2.0.0",
"@types/morgan": "^1.9.0",
"@types/node-forge": "^1.3.0",
"@types/stoppable": "^1.1.0",
"http-errors": "^2.0.0",
"supertest": "^6.1.3"
},
"files": [
"dist",
@@ -0,0 +1,213 @@
/*
* Copyright 2020 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 {
AuthenticationError,
ConflictError,
InputError,
NotAllowedError,
NotFoundError,
NotModifiedError,
} from '@backstage/errors';
import express from 'express';
import createError from 'http-errors';
import request from 'supertest';
import { MiddlewareFactory } from './MiddlewareFactory';
import { ConfigReader } from '@backstage/config';
describe('MiddlewareFactory', () => {
describe('middleware.error', () => {
const childLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
child: jest.fn(),
};
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
child: () => childLogger,
};
const middleware = MiddlewareFactory.create({
logger,
config: new ConfigReader({}),
});
beforeEach(() => {
jest.resetAllMocks();
});
it('gives default code and message', async () => {
const app = express();
app.use('/breaks', () => {
throw new Error('some message');
});
app.use(middleware.error());
const response = await request(app).get('/breaks');
expect(response.status).toBe(500);
expect(response.body).toEqual({
error: expect.objectContaining({
name: 'Error',
message: 'some message',
}),
request: { method: 'GET', url: '/breaks' },
response: { statusCode: 500 },
});
});
it('does not try to send the response again if its already been sent', async () => {
const app = express();
const mockSend = jest.fn();
app.use('/works_with_async_fail', (_, res) => {
res.status(200).send('hello');
// mutate the response object to test the middleware.
// it's hard to catch errors inside middleware from the outside.
res.send = mockSend;
throw new Error('some message');
});
app.use(middleware.error());
const response = await request(app).get('/works_with_async_fail');
expect(response.status).toBe(200);
expect(response.text).toBe('hello');
expect(mockSend).not.toHaveBeenCalled();
});
it('takes code from http-errors library errors', async () => {
const app = express();
app.use('/breaks', () => {
throw createError(432, 'Some Message');
});
app.use(middleware.error());
const response = await request(app).get('/breaks');
expect(response.status).toBe(432);
expect(response.body).toEqual({
error: {
expose: true,
name: 'BadRequestError',
message: 'Some Message',
status: 432,
statusCode: 432,
},
request: {
method: 'GET',
url: '/breaks',
},
response: { statusCode: 432 },
});
});
it('handles well-known error classes', async () => {
const app = express();
app.use('/NotModifiedError', () => {
throw new NotModifiedError();
});
app.use('/InputError', () => {
throw new InputError();
});
app.use('/AuthenticationError', () => {
throw new AuthenticationError();
});
app.use('/NotAllowedError', () => {
throw new NotAllowedError();
});
app.use('/NotFoundError', () => {
throw new NotFoundError();
});
app.use('/ConflictError', () => {
throw new ConflictError();
});
app.use(middleware.error());
const r = request(app);
expect((await r.get('/NotModifiedError')).status).toBe(304);
expect((await r.get('/InputError')).status).toBe(400);
expect((await r.get('/InputError')).body.error.name).toBe('InputError');
expect((await r.get('/AuthenticationError')).status).toBe(401);
expect((await r.get('/AuthenticationError')).body.error.name).toBe(
'AuthenticationError',
);
expect((await r.get('/NotAllowedError')).status).toBe(403);
expect((await r.get('/NotAllowedError')).body.error.name).toBe(
'NotAllowedError',
);
expect((await r.get('/NotFoundError')).status).toBe(404);
expect((await r.get('/NotFoundError')).body.error.name).toBe(
'NotFoundError',
);
expect((await r.get('/ConflictError')).status).toBe(409);
expect((await r.get('/ConflictError')).body.error.name).toBe(
'ConflictError',
);
});
it('logs all 500 errors', async () => {
const app = express();
const thrownError = new Error('some error');
app.use('/breaks', () => {
throw thrownError;
});
app.use(middleware.error());
await request(app).get('/breaks');
expect(childLogger.error).toHaveBeenCalledWith(
'Request failed with status 500',
thrownError,
);
});
it('does not log 400 errors', async () => {
const app = express();
app.use('/NotFound', () => {
throw new NotFoundError();
});
app.use(middleware.error());
await request(app).get('/NotFound');
expect(childLogger.error).not.toHaveBeenCalled();
});
it('log 400 errors when logAllErrors is true', async () => {
const app = express();
app.use('/NotFound', () => {
throw new NotFoundError();
});
app.use(middleware.error({ logAllErrors: true }));
await request(app).get('/NotFound');
expect(childLogger.error).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,266 @@
/*
* 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 { ConfigService, LoggerService } from '@backstage/backend-plugin-api';
import {
Request,
Response,
ErrorRequestHandler,
NextFunction,
RequestHandler,
} from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import compression from 'compression';
import { readHelmetOptions } from './readHelmetOptions';
import { readCorsOptions } from './readCorsOptions';
import {
AuthenticationError,
ConflictError,
ErrorResponseBody,
InputError,
NotAllowedError,
NotFoundError,
NotModifiedError,
serializeError,
} from '@backstage/errors';
/**
* Options used to create a {@link MiddlewareFactory}.
*
* @public
*/
export interface MiddlewareFactoryOptions {
config: ConfigService;
logger: LoggerService;
}
/**
* Options passed to the {@link MiddlewareFactory.error} middleware.
*
* @public
*/
export interface MiddlewareFactoryErrorOptions {
/**
* Whether error response bodies should show error stack traces or not.
*
* If not specified, by default shows stack traces only in development mode.
*/
showStackTraces?: boolean;
/**
* Whether any 4xx errors should be logged or not.
*
* If not specified, default to only logging 5xx errors.
*/
logAllErrors?: boolean;
}
/**
* A utility to configure common middleware.
*
* @public
*/
export class MiddlewareFactory {
#config: ConfigService;
#logger: LoggerService;
/**
* Creates a new {@link MiddlewareFactory}.
*/
static create(options: MiddlewareFactoryOptions) {
return new MiddlewareFactory(options);
}
private constructor(options: MiddlewareFactoryOptions) {
this.#config = options.config;
this.#logger = options.logger;
}
/**
* Returns a middleware that unconditionally produces a 404 error response.
*
* @remarks
*
* Typically you want to place this middleware at the end of the chain, such
* that it's the last one attempted after no other routes matched.
*
* @returns An Express request handler
*/
notFound(): RequestHandler {
return (_req: Request, res: Response) => {
res.status(404).end();
};
}
/**
* Returns the compression middleware.
*
* @remarks
*
* The middleware will attempt to compress response bodies for all requests
* that traverse through the middleware.
*/
compression(): RequestHandler {
return compression();
}
/**
* Returns a request logging middleware.
*
* @remarks
*
* Typically you want to place this middleware at the start of the chain, such
* that it always logs requests whether they are "caught" by handlers farther
* down or not.
*
* @returns An Express request handler
*/
logging(): RequestHandler {
const logger = this.#logger.child({
type: 'incomingRequest',
});
return morgan('combined', {
stream: {
write(message: string) {
logger.info(message.trimEnd());
},
},
});
}
/**
* Returns a middleware that implements the helmet library.
*
* @remarks
*
* This middleware applies security policies to incoming requests and outgoing
* responses. It is configured using config keys such as `backend.csp`.
*
* @see {@link https://helmetjs.github.io/}
*
* @returns An Express request handler
*/
helmet(): RequestHandler {
return helmet(readHelmetOptions(this.#config.getOptionalConfig('backend')));
}
/**
* Returns a middleware that implements the cors library.
*
* @remarks
*
* This middleware handles CORS. It is configured using the config key
* `backend.cors`.
*
* @see {@link https://github.com/expressjs/cors}
*
* @returns An Express request handler
*/
cors(): RequestHandler {
return cors(readCorsOptions(this.#config.getOptionalConfig('backend')));
}
/**
* Express middleware to handle errors during request processing.
*
* @remarks
*
* This is commonly the very last middleware in the chain.
*
* Its primary purpose is not to do translation of business logic exceptions,
* but rather to be a global catch-all for uncaught "fatal" errors that are
* expected to result in a 500 error. However, it also does handle some common
* error types (such as http-error exceptions, and the well-known error types
* in the `@backstage/errors` package) and returns the enclosed status code
* accordingly.
*
* It will also produce a response body with a serialized form of the error,
* unless a previous handler already did send a body. See
* {@link @backstage/errors#ErrorResponseBody} for the response shape used.
*
* @returns An Express error request handler
*/
error(options: MiddlewareFactoryErrorOptions = {}): ErrorRequestHandler {
const showStackTraces =
options.showStackTraces ?? process.env.NODE_ENV === 'development';
const logger = this.#logger.child({
type: 'errorHandler',
});
return (error: Error, req: Request, res: Response, next: NextFunction) => {
const statusCode = getStatusCode(error);
if (options.logAllErrors || statusCode >= 500) {
logger.error(`Request failed with status ${statusCode}`, error);
}
if (res.headersSent) {
// If the headers have already been sent, do not send the response again
// as this will throw an error in the backend.
next(error);
return;
}
const body: ErrorResponseBody = {
error: serializeError(error, { includeStack: showStackTraces }),
request: { method: req.method, url: req.url },
response: { statusCode },
};
res.status(statusCode).json(body);
};
}
}
function getStatusCode(error: Error): number {
// Look for common http library status codes
const knownStatusCodeFields = ['statusCode', 'status'];
for (const field of knownStatusCodeFields) {
const statusCode = (error as any)[field];
if (
typeof statusCode === 'number' &&
(statusCode | 0) === statusCode && // is whole integer
statusCode >= 100 &&
statusCode <= 599
) {
return statusCode;
}
}
// Handle well-known error types
switch (error.name) {
case NotModifiedError.name:
return 304;
case InputError.name:
return 400;
case AuthenticationError.name:
return 401;
case NotAllowedError.name:
return 403;
case NotFoundError.name:
return 404;
case ConflictError.name:
return 409;
default:
break;
}
// Fall back to internal server error
return 500;
}
@@ -0,0 +1,87 @@
/*
* Copyright 2020 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 { ConfigReader } from '@backstage/config';
import { readHttpServerOptions } from './config';
describe('readHttpServerOptions', () => {
it('should return defaults', () => {
expect(readHttpServerOptions()).toEqual({
listen: { host: '', port: 7007 },
});
});
it.each([
[{}, { listen: { host: '', port: 7007 } }],
[{ listen: ':80' }, { listen: { host: '', port: 80 } }],
[{ listen: '80' }, { listen: { host: '', port: 80 } }],
[{ listen: '1.2.3.4:80' }, { listen: { host: '1.2.3.4', port: 80 } }],
[{ listen: { host: '' } }, { listen: { host: '', port: 7007 } }],
[
{ listen: { host: '0.0.0.0' } },
{ listen: { host: '0.0.0.0', port: 7007 } },
],
[
{ listen: { host: '0.0.0.0', port: '80' } },
{ listen: { host: '0.0.0.0', port: 80 } },
],
[{ listen: { port: '80' } }, { listen: { host: '', port: 80 } }],
[{ listen: { port: 80 } }, { listen: { host: '', port: 80 } }],
[{ listen: { port: 80 } }, { listen: { host: '', port: 80 } }],
[
{ baseUrl: 'http://example.com:8080', https: true },
{
listen: { host: '', port: 7007 },
https: { certificate: { type: 'generated', hostname: 'example.com' } },
},
],
[
{ https: { certificate: { cert: 'my-cert', key: 'my-key' } } },
{
listen: { host: '', port: 7007 },
https: {
certificate: { type: 'plain', cert: 'my-cert', key: 'my-key' },
},
},
],
])('should read http server options %#', (input, output) => {
expect(readHttpServerOptions(new ConfigReader(input))).toEqual(output);
});
it.each([
[
{ listen: { port: 'not-a-number' } },
"Unable to convert config value for key 'listen.port' in 'mock-config' to a number",
],
[
{ listen: { port: {} } },
"Invalid type in config for key 'listen.port' in 'mock-config', got object, wanted number",
],
[
{ listen: { host: false } },
"Invalid type in config for key 'listen.host' in 'mock-config', got boolean, wanted string",
],
[{ https: {} }, "Missing required config value at 'https.certificate.cert"],
[
{ https: { certificate: { cert: 'x' } } },
"Missing required config value at 'https.certificate.key",
],
])('should throw on bad options %#', (input, message) => {
expect(() => readHttpServerOptions(new ConfigReader(input))).toThrow(
message,
);
});
});
+101
View File
@@ -0,0 +1,101 @@
/*
* 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 { Config } from '@backstage/config';
import { HttpServerOptions } from './types';
const DEFAULT_PORT = 7007;
const DEFAULT_HOST = '';
/**
* Reads {@link HttpServerOptions} from a {@link @backstage/config#Config} object.
*
* @public
* @remarks
*
* The provided configuration object should contain the `listen` and
* additional keys directly.
*
* @example
* ```ts
* const opts = readHttpServerOptions(config.getConfig('backend'));
* ```
*/
export function readHttpServerOptions(config?: Config): HttpServerOptions {
return {
listen: readHttpListenOptions(config),
https: readHttpsOptions(config),
};
}
function readHttpListenOptions(config?: Config): HttpServerOptions['listen'] {
const listen = config?.getOptional('listen');
if (typeof listen === 'string') {
const parts = String(listen).split(':');
const port = parseInt(parts[parts.length - 1], 10);
if (!isNaN(port)) {
if (parts.length === 1) {
return { port, host: DEFAULT_HOST };
}
if (parts.length === 2) {
return { host: parts[0], port };
}
}
throw new Error(
`Unable to parse listen address ${listen}, expected <port> or <host>:<port>`,
);
}
// Workaround to allow empty string
const host = config?.getOptional('listen.host') ?? DEFAULT_HOST;
if (typeof host !== 'string') {
config?.getOptionalString('listen.host'); // will throw
throw new Error('unreachable');
}
return {
port: config?.getOptionalNumber('listen.port') ?? DEFAULT_PORT,
host,
};
}
function readHttpsOptions(config?: Config): HttpServerOptions['https'] {
const https = config?.getOptional('https');
if (https === true) {
const baseUrl = config!.getString('baseUrl');
let hostname;
try {
hostname = new URL(baseUrl).hostname;
} catch (error) {
throw new Error(`Invalid baseUrl "${baseUrl}"`);
}
return { certificate: { type: 'generated', hostname } };
}
const cc = config?.getOptionalConfig('https');
if (!cc) {
return undefined;
}
return {
certificate: {
type: 'plain',
cert: cc.getString('certificate.cert'),
key: cc.getString('certificate.key'),
},
};
}
@@ -0,0 +1,102 @@
/*
* Copyright 2020 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 * as http from 'http';
import * as https from 'https';
import stoppableServer from 'stoppable';
import { RequestListener } from 'http';
import { LoggerService } from '@backstage/backend-plugin-api';
import { HttpServerOptions, ExtendedHttpServer } from './types';
import { getGeneratedCertificate } from './getGeneratedCertificate';
/**
* Creates a Node.js HTTP or HTTPS server instance.
*
* @public
*/
export async function createHttpServer(
listener: RequestListener,
options: HttpServerOptions,
deps: { logger: LoggerService },
): Promise<ExtendedHttpServer> {
const server = await createServer(listener, options, deps);
const stopper = stoppableServer(server, 0);
// The stopper here is actually the server itself, so if we try
// to call stopper.stop() down in the stop implementation, we'll
// be calling ourselves.
const stopServer = stopper.stop.bind(stopper);
return Object.assign(server, {
start() {
return new Promise<void>((resolve, reject) => {
const handleStartupError = (error: Error) => {
server.close();
reject(error);
};
server.on('error', handleStartupError);
const { host, port } = options.listen;
server.listen(port, host, () => {
server.off('error', handleStartupError);
deps.logger.info(`Listening on ${host}:${port}`);
resolve();
});
});
},
stop() {
return new Promise<void>((resolve, reject) => {
stopServer((error?: Error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
},
port() {
const address = server.address();
if (typeof address === 'string' || address === null) {
throw new Error(`Unexpected server address '${address}'`);
}
return address.port;
},
});
}
async function createServer(
listener: RequestListener,
options: HttpServerOptions,
deps: { logger: LoggerService },
): Promise<http.Server> {
if (options.https) {
const { certificate } = options.https;
if (certificate.type === 'generated') {
const credentials = await getGeneratedCertificate(
certificate.hostname,
deps.logger,
);
return https.createServer(credentials, listener);
}
return https.createServer(certificate, listener);
}
return http.createServer(listener);
}
@@ -0,0 +1,151 @@
/*
* Copyright 2020 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 fs from 'fs-extra';
import { resolve as resolvePath, dirname } from 'path';
import { LoggerService } from '@backstage/backend-plugin-api';
import forge from 'node-forge';
const FIVE_DAYS_IN_MS = 5 * 24 * 60 * 60 * 1000;
const IP_HOSTNAME_REGEX = /:|^\d+\.\d+\.\d+\.\d+$/;
export async function getGeneratedCertificate(
hostname: string,
logger: LoggerService,
) {
const hasModules = await fs.pathExists('node_modules');
let certPath;
if (hasModules) {
certPath = resolvePath(
'node_modules/.cache/backstage-backend/dev-cert.pem',
);
await fs.ensureDir(dirname(certPath));
} else {
certPath = resolvePath('.dev-cert.pem');
}
if (await fs.pathExists(certPath)) {
try {
const cert = await fs.readFile(certPath);
const crt = forge.pki.certificateFromPem(cert.toString());
const remainingMs = crt.validity.notAfter.getTime() - Date.now();
if (remainingMs > FIVE_DAYS_IN_MS) {
logger.info('Using existing self-signed certificate');
return {
key: cert,
cert,
};
}
} catch (error) {
logger.warn(`Unable to use existing self-signed certificate, ${error}`);
}
}
logger.info('Generating new self-signed certificate');
const newCert = await generateCertificate(hostname);
await fs.writeFile(certPath, newCert.cert + newCert.key, 'utf8');
return newCert;
}
async function generateCertificate(hostname: string) {
const attributes = [
{
name: 'commonName',
value: 'dev-cert',
},
];
const sans = [
{
type: 2, // DNS
value: 'localhost',
},
{
type: 2,
value: 'localhost.localdomain',
},
{
type: 2,
value: '[::1]',
},
{
type: 7, // IP
ip: '127.0.0.1',
},
{
type: 7,
ip: 'fe80::1',
},
];
// Add hostname from backend.baseUrl if it doesn't already exist in our list of SANs
if (!sans.find(({ value, ip }) => value === hostname || ip === hostname)) {
sans.push(
IP_HOSTNAME_REGEX.test(hostname)
? {
type: 7,
ip: hostname,
}
: {
type: 2,
value: hostname,
},
);
}
const params = {
algorithm: 'sha256',
keySize: 2048,
days: 30,
extensions: [
{
name: 'keyUsage',
keyCertSign: true,
digitalSignature: true,
nonRepudiation: true,
keyEncipherment: true,
dataEncipherment: true,
},
{
name: 'extKeyUsage',
serverAuth: true,
clientAuth: true,
codeSigning: true,
timeStamping: true,
},
{
name: 'subjectAltName',
altNames: sans,
},
],
};
return new Promise<{ key: string; cert: string }>((resolve, reject) =>
require('selfsigned').generate(
attributes,
params,
(err: Error, bundle: { private: string; cert: string }) => {
if (err) {
reject(err);
} else {
resolve({ key: bundle.private, cert: bundle.cert });
}
},
),
);
}
@@ -0,0 +1,30 @@
/*
* 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 { readHttpServerOptions } from './config';
export { createHttpServer } from './createHttpServer';
export { MiddlewareFactory } from './MiddlewareFactory';
export type {
MiddlewareFactoryErrorOptions,
MiddlewareFactoryOptions,
} from './MiddlewareFactory';
export { readCorsOptions } from './readCorsOptions';
export { readHelmetOptions } from './readHelmetOptions';
export type {
ExtendedHttpServer,
HttpServerCertificateOptions,
HttpServerOptions,
} from './types';
@@ -0,0 +1,87 @@
/*
* Copyright 2020 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 { ConfigReader } from '@backstage/config';
import { readCorsOptions } from './readCorsOptions';
describe('readCorsOptions', () => {
it('should be disabled by default', () => {
expect(readCorsOptions()).toEqual({
origin: false,
});
});
it('reads single string', () => {
const mockCallback = jest.fn();
const config = new ConfigReader({ cors: { origin: 'https://*.value*' } });
const cors = readCorsOptions(config);
expect(cors).toEqual(
expect.objectContaining({
origin: expect.any(Function),
}),
);
const origin = cors?.origin as Function;
origin('https://a.value', mockCallback); // valid origin
origin('http://a.value', mockCallback); // invalid origin
origin(undefined, mockCallback); // when not origin needs to reject the call
expect(mockCallback.mock.calls[0][0]).toBe(null);
expect(mockCallback.mock.calls[1][0]).toBe(null);
expect(mockCallback.mock.calls[0][1]).toBe(true);
expect(mockCallback.mock.calls[1][1]).toBe(false);
expect(mockCallback.mock.calls[2][1]).toBe(false);
});
it('reads string array', () => {
const mockCallback = jest.fn();
const config = new ConfigReader({
cors: {
origin: ['http?(s)://*.value?(-+([0-9])).com', 'http://*.value'],
},
});
const cors = readCorsOptions(config);
expect(cors).toEqual(
expect.objectContaining({
origin: expect.any(Function),
}),
);
const origin = cors?.origin as Function;
origin('https://a.b.c.value-9.com', mockCallback);
origin('http://a.value-999.com', mockCallback);
origin('http://a.value', mockCallback);
origin('http://a.valuex', mockCallback);
expect(mockCallback.mock.calls[0][0]).toBe(null);
expect(mockCallback.mock.calls[1][0]).toBe(null);
expect(mockCallback.mock.calls[2][0]).toBe(null);
expect(mockCallback.mock.calls[3][0]).toBe(null);
expect(mockCallback.mock.calls[0][1]).toBe(true);
expect(mockCallback.mock.calls[1][1]).toBe(true);
expect(mockCallback.mock.calls[2][1]).toBe(true);
expect(mockCallback.mock.calls[3][1]).toBe(false);
});
it('reads undefined origin', () => {
const config = new ConfigReader({
cors: {},
});
const cors = readCorsOptions(config);
expect(cors).toEqual(expect.objectContaining({}));
expect(cors?.origin).toBeUndefined();
});
});
@@ -0,0 +1,82 @@
/*
* 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 { Config } from '@backstage/config';
import { CorsOptions } from 'cors';
import { Minimatch } from 'minimatch';
/**
* Attempts to read a CORS options object from the backend configuration object.
*
* @public
* @param config - The backend configuration object.
* @returns A CORS options object, or undefined if no cors configuration is present.
*
* @example
* ```ts
* const corsOptions = readCorsOptions(config.getConfig('backend'));
* ```
*/
export function readCorsOptions(config?: Config): CorsOptions {
const cc = config?.getOptionalConfig('cors');
if (!cc) {
return { origin: false }; // Disable CORS
}
return {
origin: createCorsOriginMatcher(readStringArray(cc, 'origin')),
methods: readStringArray(cc, 'methods'),
allowedHeaders: readStringArray(cc, 'allowedHeaders'),
exposedHeaders: readStringArray(cc, 'exposedHeaders'),
credentials: cc.getOptionalBoolean('credentials'),
maxAge: cc.getOptionalNumber('maxAge'),
preflightContinue: cc.getOptionalBoolean('preflightContinue'),
optionsSuccessStatus: cc.getOptionalNumber('optionsSuccessStatus'),
};
}
function readStringArray(config: Config, key: string): string[] | undefined {
const value = config.getOptional(key);
if (typeof value === 'string') {
return [value];
} else if (!value) {
return undefined;
}
return config.getStringArray(key);
}
function createCorsOriginMatcher(allowedOriginPatterns: string[] | undefined) {
if (!allowedOriginPatterns) {
return undefined;
}
const allowedOriginMatchers = allowedOriginPatterns.map(
pattern => new Minimatch(pattern, { nocase: true, noglobstar: true }),
);
return (
origin: string | undefined,
callback: (
err: Error | null,
origin: boolean | string | RegExp | (boolean | string | RegExp)[],
) => void,
) => {
return callback(
null,
allowedOriginMatchers.some(pattern => pattern.match(origin ?? '')),
);
};
}
@@ -0,0 +1,80 @@
/*
* Copyright 2020 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 { ConfigReader } from '@backstage/config';
import { readHelmetOptions } from './readHelmetOptions';
describe('readHelmetOptions', () => {
it('should return defaults', () => {
expect(readHelmetOptions()).toEqual({
contentSecurityPolicy: {
useDefaults: false,
directives: {
'default-src': ["'self'"],
'base-uri': ["'self'"],
'font-src': ["'self'", 'https:', 'data:'],
'frame-ancestors': ["'self'"],
'img-src': ["'self'", 'data:'],
'object-src': ["'none'"],
'script-src': ["'self'", "'unsafe-eval'"],
'style-src': ["'self'", 'https:', "'unsafe-inline'"],
'script-src-attr': ["'none'"],
'upgrade-insecure-requests': [],
},
},
crossOriginEmbedderPolicy: false,
crossOriginOpenerPolicy: false,
crossOriginResourcePolicy: false,
originAgentCluster: false,
});
});
it('should add additional directives', () => {
const config = new ConfigReader({
csp: {
key: ['value'],
'img-src': false,
'script-src-attr': ['custom'],
},
});
expect(readHelmetOptions(config)).toEqual({
contentSecurityPolicy: {
useDefaults: false,
directives: {
'default-src': ["'self'"],
'base-uri': ["'self'"],
'font-src': ["'self'", 'https:', 'data:'],
'frame-ancestors': ["'self'"],
'object-src': ["'none'"],
'script-src': ["'self'", "'unsafe-eval'"],
'style-src': ["'self'", 'https:', "'unsafe-inline'"],
'script-src-attr': ['custom'],
'upgrade-insecure-requests': [],
key: ['value'],
},
},
crossOriginEmbedderPolicy: false,
crossOriginOpenerPolicy: false,
crossOriginResourcePolicy: false,
originAgentCluster: false,
});
});
it('rejects invalid value types', () => {
const config = new ConfigReader({ csp: { key: [4] } });
expect(() => readHelmetOptions(config)).toThrow(/wanted string-array/);
});
});
@@ -0,0 +1,109 @@
/*
* Copyright 2020 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 { Config } from '@backstage/config';
import helmet from 'helmet';
import { HelmetOptions } from 'helmet';
import { ContentSecurityPolicyOptions } from 'helmet/dist/types/middlewares/content-security-policy';
/**
* Attempts to read Helmet options from the backend configuration object.
*
* @public
* @param config - The backend configuration object.
* @returns A Helmet options object, or undefined if no Helmet configuration is present.
*
* @example
* ```ts
* const helmetOptions = readHelmetOptions(config.getConfig('backend'));
* ```
*/
export function readHelmetOptions(config?: Config): HelmetOptions {
const cspOptions = readCspDirectives(config);
return {
contentSecurityPolicy: {
useDefaults: false,
directives: applyCspDirectives(cspOptions),
},
// These are all disabled in order to maintain backwards compatibility
// when bumping helmet v5. We can't enable these by default because
// there is no way for users to configure them.
// TODO(Rugvip): We should give control of this setup to consumers
crossOriginEmbedderPolicy: false,
crossOriginOpenerPolicy: false,
crossOriginResourcePolicy: false,
originAgentCluster: false,
};
}
type CspDirectives = Record<string, string[] | false> | undefined;
/**
* Attempts to read a CSP directives from the backend configuration object.
*
* @example
* ```yaml
* backend:
* csp:
* connect-src: ["'self'", 'http:', 'https:']
* upgrade-insecure-requests: false
* ```
*/
function readCspDirectives(config?: Config): CspDirectives {
const cc = config?.getOptionalConfig('csp');
if (!cc) {
return undefined;
}
const result: Record<string, string[] | false> = {};
for (const key of cc.keys()) {
if (cc.get(key) === false) {
result[key] = false;
} else {
result[key] = cc.getStringArray(key);
}
}
return result;
}
export function applyCspDirectives(
directives: CspDirectives,
): ContentSecurityPolicyOptions['directives'] {
const result: ContentSecurityPolicyOptions['directives'] =
helmet.contentSecurityPolicy.getDefaultDirectives();
// TODO(Rugvip): We currently use non-precompiled AJV for validation in the frontend, which uses eval.
// It should be replaced by any other solution that doesn't require unsafe-eval.
result['script-src'] = ["'self'", "'unsafe-eval'"];
// TODO(Rugvip): This is removed so that we maintained backwards compatibility
// when bumping to helmet v5, we could remove this as well as
// skip setting `useDefaults: false` in the future.
delete result['form-action'];
if (directives) {
for (const [key, value] of Object.entries(directives)) {
if (value === false) {
delete result[key];
} else {
result[key] = value;
}
}
}
return result;
}
@@ -0,0 +1,61 @@
/*
* 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 * as http from 'http';
/**
* An HTTP server extended with utility methods.
*
* @public
*/
export interface ExtendedHttpServer extends http.Server {
start(): Promise<void>;
stop(): Promise<void>;
port(): number;
}
/**
* Options for starting up an HTTP server.
*
* @public
*/
export type HttpServerOptions = {
listen: {
port: number;
host: string;
};
https?: {
certificate: HttpServerCertificateOptions;
};
};
/**
* Options for configuring HTTPS for an HTTP server.
*
* @public
*/
export type HttpServerCertificateOptions =
| {
type: 'plain';
key: string;
cert: string;
}
| {
type: 'generated';
hostname: string;
};
+1
View File
@@ -20,5 +20,6 @@
* @packageDocumentation
*/
export * from './http';
export * from './wiring';
export * from './services/implementations';
@@ -18,7 +18,7 @@ import {
HttpRouterService,
ServiceFactory,
} from '@backstage/backend-plugin-api';
import { httpRouterFactory } from './httpRouterService';
import { httpRouterFactory } from './httpRouterFactory';
describe('httpRouterFactory', () => {
it('should register plugin paths', async () => {
@@ -0,0 +1,18 @@
/*
* 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 { httpRouterFactory } from './httpRouterFactory';
export type { HttpRouterFactoryOptions } from './httpRouterFactory';
@@ -14,6 +14,9 @@
* limitations under the License.
*/
export * from './httpRouter';
export * from './rootHttpRouter';
export { cacheFactory } from './cacheService';
export { configFactory } from './configService';
export { databaseFactory } from './databaseService';
@@ -24,9 +27,5 @@ export { permissionsFactory } from './permissionsService';
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,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;
}
}
@@ -0,0 +1,21 @@
/*
* 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 { rootHttpRouterFactory } from './rootHttpRouterFactory';
export type {
RootHttpRouterFactoryOptions,
RootHttpRouterConfigureOptions,
} from './rootHttpRouterFactory';
@@ -0,0 +1,118 @@
/*
* 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 {
ConfigService,
coreServices,
createServiceFactory,
LifecycleService,
LoggerService,
} from '@backstage/backend-plugin-api';
import express, { RequestHandler, Express } from 'express';
import {
createHttpServer,
MiddlewareFactory,
readHttpServerOptions,
} from '../../../http';
import { RestrictedIndexedRouter } from './RestrictedIndexedRouter';
/**
* @public
*/
export interface RootHttpRouterConfigureOptions {
app: Express;
middleware: MiddlewareFactory;
routes: RequestHandler;
config: ConfigService;
logger: LoggerService;
lifecycle: LifecycleService;
}
/**
* @public
*/
export type RootHttpRouterFactoryOptions = {
/**
* The path to forward all unmatched requests to. Defaults to '/api/app'
*/
indexPath?: string | false;
configure?(options: RootHttpRouterConfigureOptions): void;
};
function defaultConfigure({
app,
routes,
middleware,
}: RootHttpRouterConfigureOptions) {
app.use(middleware.helmet());
app.use(middleware.cors());
app.use(middleware.compression());
app.use(middleware.logging());
app.use(routes);
app.use(middleware.notFound());
app.use(middleware.error());
}
/** @public */
export const rootHttpRouterFactory = createServiceFactory({
service: coreServices.rootHttpRouter,
deps: {
config: coreServices.config,
rootLogger: coreServices.rootLogger,
lifecycle: coreServices.rootLifecycle,
},
async factory(
{ config, rootLogger, lifecycle },
{
indexPath,
configure = defaultConfigure,
}: RootHttpRouterFactoryOptions = {},
) {
const router = new RestrictedIndexedRouter(indexPath ?? '/api/app');
const logger = rootLogger.child({ service: 'rootHttpRouter' });
const app = express();
const middleware = MiddlewareFactory.create({ config, logger });
configure({
app,
routes: router.handler(),
middleware,
config,
logger,
lifecycle,
});
const server = await createHttpServer(
app,
readHttpServerOptions(config.getOptionalConfig('backend')),
{ logger },
);
lifecycle.addShutdownHook({
async fn() {
await server.stop();
},
labels: { service: 'rootHttpRouter' },
});
await server.start();
return router;
},
});
@@ -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 './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);
});
});
@@ -1,125 +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 {
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;
}