Merge pull request #676 from spotify/freben/backend-common

Add a backend-common package with common concerns such as logging
This commit is contained in:
Fredrik Adelöw
2020-04-30 14:35:58 +02:00
committed by GitHub
19 changed files with 773 additions and 24 deletions
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
+38
View File
@@ -0,0 +1,38 @@
# @backstage/backend-common
Common functionality library for Backstage backends, implementing logging,
error handling and similar.
## Usage
Add the library to your backend package:
```sh
yarn add @backstage/backend-common
```
then make use of the handlers and logger as necessary:
```typescript
import {
errorHandler,
getRootLogger,
notFoundHandler,
requestLoggingHandler,
} from '@backstage/backend-common';
const app = express();
app.use(requestLoggingHandler());
app.use('/home', myHomeRouter);
app.use(errorHandler());
app.use(notFoundHandler());
app.listen(PORT, () => {
getRootLogger().info(`Listening on port ${PORT}`);
});
```
## Documentation
- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md)
- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md)
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@backstage/backend-common",
"description": "Common functionality library for Backstage backends",
"version": "0.1.1-alpha.4",
"main": "dist",
"private": false,
"publishConfig": {
"access": "public"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/spotify/backstage",
"directory": "packages/backend-common"
},
"keywords": [
"backstage"
],
"license": "Apache-2.0",
"scripts": {
"build": "backstage-cli build-cache -- tsc",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"clean": "backstage-cli clean"
},
"dependencies": {
"express": "^4.17.1",
"morgan": "^1.10.0",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.4",
"@types/express": "^4.17.6",
"@types/http-errors": "^1.6.3",
"@types/morgan": "^1.9.0",
"@types/supertest": "^2.0.8",
"get-port": "^5.1.1",
"http-errors": "^1.7.3",
"jest": "^25.1.0",
"jest-fetch-mock": "^3.0.3",
"supertest": "^4.0.2",
"typescript": "^3.8.3"
},
"files": [
"dist"
]
}
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* 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 * from './logging';
export * from './middleware';
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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 * from './rootLogger';
@@ -0,0 +1,37 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { PassThrough } from 'stream';
import winston from 'winston';
import { getRootLogger, setRootLogger } from './rootLogger';
describe('rootLogger', () => {
it('can replace the default logger', () => {
const logger = winston.createLogger({
transports: [
new winston.transports.Stream({ stream: new PassThrough() }),
],
});
jest.spyOn(logger, 'info');
setRootLogger(logger);
getRootLogger().info('testing');
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('testing'),
);
});
});
@@ -0,0 +1,44 @@
/*
* Copyright 2020 Spotify AB
*
* 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 winston, { Logger } from 'winston';
let rootLogger: Logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format:
process.env.NODE_ENV === 'production'
? winston.format.json()
: winston.format.combine(
winston.format.colorize(),
winston.format.timestamp(),
winston.format.simple(),
),
defaultMeta: { service: 'backstage' },
transports: [
new winston.transports.Console({
silent:
process.env.JEST_WORKER_ID !== undefined && !process.env.LOG_LEVEL,
}),
],
});
export function getRootLogger(): Logger {
return rootLogger;
}
export function setRootLogger(newLogger: Logger) {
rootLogger = newLogger;
}
@@ -0,0 +1,48 @@
/*
* Copyright 2020 Spotify AB
*
* 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 express from 'express';
import createError from 'http-errors';
import request from 'supertest';
import { errorHandler } from './errorHandler';
describe('errorHandler', () => {
it('gives default code and message', async () => {
const app = express();
app.use('/breaks', () => {
throw new Error('some message');
});
app.use(errorHandler());
const response = await request(app).get('/breaks');
expect(response.status).toBe(500);
expect(response.text).toBe('some message');
});
it('takes code from StatusCodeError', async () => {
const app = express();
app.use('/breaks', () => {
throw createError(432, 'Some Message');
});
app.use(errorHandler());
const response = await request(app).get('/breaks');
expect(response.status).toBe(432);
expect(response.text).toContain('Some Message');
});
});
@@ -0,0 +1,63 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { ErrorRequestHandler, NextFunction, Request, Response } from 'express';
/**
* Express middleware to handle errors during request processing.
*
* This is commonly the second to last middleware in the chain (before the
* notFoundHandler).
*
* Its primary purpose is not to do translation of business logic exceptions,
* but rather to be a gobal 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 returns the enclosed status
* code accordingly.
*
* @returns An Express error request handler
*/
export function errorHandler(): ErrorRequestHandler {
/* eslint-disable @typescript-eslint/no-unused-vars */
return (
error: Error,
_request: Request,
response: Response,
_next: NextFunction,
) => {
const status = getStatusCode(error);
const message = error.message;
response.status(status).send(message);
};
}
function getStatusCode(error: Error): number {
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;
}
}
return 500;
}
@@ -0,0 +1,19 @@
/*
* Copyright 2020 Spotify AB
*
* 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 * from './errorHandler';
export * from './notFoundHandler';
export * from './requestLoggingHandler';
@@ -0,0 +1,33 @@
/*
* Copyright 2020 Spotify AB
*
* 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 express from 'express';
import request from 'supertest';
import { notFoundHandler } from './notFoundHandler';
describe('notFoundHandler', () => {
it('handles only missing routes', async () => {
const app = express();
app.use('/exists', (_, res) => res.status(200).send());
app.use(notFoundHandler());
const existsResponse = await request(app).get('/exists');
const doesNotExistResponse = await request(app).get('/doesNotExist');
expect(existsResponse.status).toBe(200);
expect(doesNotExistResponse.status).toBe(404);
});
});
@@ -0,0 +1,32 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { NextFunction, Request, RequestHandler, Response } from 'express';
/**
* Express middleware to handle requests for missing routes.
*
* Should be used as the very last handler in the chain, as it unconditionally
* returns a 404 status.
*
* @returns An Express request handler
*/
export function notFoundHandler(): RequestHandler {
/* eslint-disable @typescript-eslint/no-unused-vars */
return (_request: Request, response: Response, _next: NextFunction) => {
response.status(404).send();
};
}
@@ -0,0 +1,51 @@
/*
* Copyright 2020 Spotify AB
*
* 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 express from 'express';
import { PassThrough } from 'stream';
import request from 'supertest';
import winston from 'winston';
import { requestLoggingHandler } from './requestLoggingHandler';
describe('requestLoggingHandler', () => {
it('emits logs for each request', async () => {
const logger = winston.createLogger({
transports: [
new winston.transports.Stream({ stream: new PassThrough() }),
],
});
jest.spyOn(logger, 'info');
const app = express();
app.use(requestLoggingHandler(logger));
app.use('/exists1', (_, res) => res.status(200).send());
app.use('/exists2', (_, res) => res.status(201).send());
const r = request(app);
await r.get('/exists1');
await r.get('/exists2');
expect(logger.info).toHaveBeenCalledTimes(2);
expect(logger.info).toHaveBeenNthCalledWith(
1,
expect.stringContaining('200'),
);
expect(logger.info).toHaveBeenNthCalledWith(
2,
expect.stringContaining('201'),
);
});
});
@@ -0,0 +1,40 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { RequestHandler } from 'express';
import { Logger } from 'winston';
import morgan from 'morgan';
import { getRootLogger } from '../logging';
/**
* Logs incoming requests.
*
* @param logger An optional logger to use. If not specified, the root logger will be used.
* @returns An Express request handler
*/
export function requestLoggingHandler(logger?: Logger): RequestHandler {
const actualLogger = (logger || getRootLogger()).child({
type: 'incomingRequest',
});
return morgan('combined', {
stream: {
write(message: String) {
actualLogger.info(message);
},
},
});
}
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
require('jest-fetch-mock').enableMocks();
+15
View File
@@ -0,0 +1,15 @@
{
"include": ["src"],
"compilerOptions": {
"baseUrl": "src",
"outDir": "dist",
"incremental": true,
"sourceMap": true,
"declaration": true,
"strict": true,
"target": "es5",
"module": "commonjs",
"esModuleInterop": true,
"types": ["node", "jest"]
}
}
+2
View File
@@ -15,6 +15,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "0.1.1-alpha.4",
"@backstage/plugin-inventory-backend": "0.1.1-alpha.4",
"compression": "^1.7.4",
"cors": "^2.8.5",
@@ -28,6 +29,7 @@
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5",
"@types/helmet": "^0.0.45",
"jest": "^25.1.0",
"tsc-watch": "^4.2.3",
"typescript": "^3.8.3"
},
+15 -6
View File
@@ -22,12 +22,18 @@
* Happy hacking!
*/
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import { testRouter } from './test';
import {
errorHandler,
getRootLogger,
notFoundHandler,
requestLoggingHandler,
} from '@backstage/backend-common';
import { router as inventoryRouter } from '@backstage/plugin-inventory-backend';
import compression from 'compression';
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
import { testRouter } from './test';
const DEFAULT_PORT = 7000;
@@ -38,9 +44,12 @@ app.use(helmet());
app.use(cors());
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
app.use('/test', testRouter);
app.use('/inventory', inventoryRouter);
app.use(errorHandler());
app.use(notFoundHandler());
app.listen(PORT, () => {
console.log(`Listening on port ${PORT}`);
getRootLogger().info(`Listening on port ${PORT}`);
});