backend-app-api: add MiddlewareFactory + configure

Co-authored-by: blam <ben@blam.sh>
Co-authored-by: Fredrik Adelöw <freben@gmail.com>
Co-authored-by: Johan Haals <johan.haals@gmail.com>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-01-09 15:51:32 +01:00
parent 15f15e4a71
commit eb62b5be74
6 changed files with 531 additions and 22 deletions
+6 -1
View File
@@ -48,6 +48,7 @@
"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",
@@ -57,8 +58,12 @@
"@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"
"@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,260 @@
/*
* 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 } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import compression from 'compression';
import { readHelmetOptions } from '../../services/implementations/rootHttpRouter/readHelmetOptions';
import { readCorsOptions } from '../../services/implementations/rootHttpRouter/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 errorHandler} 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() {
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() {
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() {
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() {
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() {
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;
}
@@ -23,3 +23,5 @@ export type {
HttpServerCertificateOptions,
ExtendedHttpServer,
} from './types';
export { MiddlewareFactory } from './MiddlewareFactory';
export type { MiddlewareFactoryOptions } from './MiddlewareFactory';
@@ -15,22 +15,24 @@
*/
import {
createServiceFactory,
ConfigService,
coreServices,
createServiceFactory,
LifecycleService,
LoggerService,
} from '@backstage/backend-plugin-api';
import express from 'express';
import compression from 'compression';
import cors from 'cors';
import helmet from 'helmet';
import express, { RequestHandler, Express } from 'express';
import { MiddlewareFactory, startHttpServer } from '../../../lib/http';
import { RestrictedIndexedRouter } from './RestrictedIndexedRouter';
import { readCorsOptions } from './readCorsOptions';
import { startHttpServer } from '../../../lib/http';
import { readHelmetOptions } from './readHelmetOptions';
import {
errorHandler,
notFoundHandler,
requestLoggingHandler,
} from '@backstage/backend-common';
interface RootHttpRouterConfigureOptions {
app: Express;
middleware: MiddlewareFactory;
routes: RequestHandler;
config: ConfigService;
logger: LoggerService;
lifecycle: LifecycleService;
}
/**
* @public
@@ -40,8 +42,24 @@ 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,
@@ -52,19 +70,25 @@ export const rootHttpRouterFactory = createServiceFactory({
},
async factory(
{ config, logger, lifecycle },
{ indexPath }: RootHttpRouterFactoryOptions = {},
{
indexPath,
configure = defaultConfigure,
}: RootHttpRouterFactoryOptions = {},
) {
const router = new RestrictedIndexedRouter(indexPath ?? '/api/app');
const app = express();
app.use(helmet(readHelmetOptions(config.getOptionalConfig('backend'))));
app.use(cors(readCorsOptions(config.getOptionalConfig('backend'))));
app.use(compression());
app.use(requestLoggingHandler(logger));
app.use(router.handler());
app.use(notFoundHandler());
app.use(errorHandler({ logger }));
const middleware = MiddlewareFactory.create({ config, logger });
configure({
app,
routes: router.handler(),
middleware,
config,
logger,
lifecycle,
});
await startHttpServer(app, { config, logger, lifecycle });
+5
View File
@@ -3388,6 +3388,8 @@ __metadata:
"@types/cors": ^2.8.6
"@types/express": ^4.17.6
"@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
compression: ^1.7.4
@@ -3396,10 +3398,13 @@ __metadata:
express-promise-router: ^4.1.0
fs-extra: 10.1.0
helmet: ^6.0.0
http-errors: ^2.0.0
minimatch: ^5.0.0
morgan: ^1.10.0
node-forge: ^1.3.1
selfsigned: ^2.0.0
stoppable: ^1.1.0
supertest: ^6.1.3
winston: ^3.2.1
languageName: unknown
linkType: soft