Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PIMS-1328 Express Error Handling #2197

Merged
merged 15 commits into from
Feb 21, 2024
20 changes: 20 additions & 0 deletions express-api/src/constants/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ErrorWithCode } from '@/utilities/customErrors/ErrorWithCode';

// 4xx Error Codes
export const BadRequest400 = new ErrorWithCode('Bad request.', 400);

export const Unauthorized401 = new ErrorWithCode('User unauthorized.', 401);

export const Forbidden403 = new ErrorWithCode('Forbidden request.', 403);

export const NotFound404 = new ErrorWithCode('Resource not found.', 404);

export const EndpointNotFound404 = new ErrorWithCode('Requested endpoint not found', 404);

export const NotAllowed405 = new ErrorWithCode('Method not allowed.', 405);

// 5xx Error Codes
export const ServerError500 = new ErrorWithCode(
'The server has encountered a situation it does not know how to handle.',
500,
);
2 changes: 2 additions & 0 deletions express-api/src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import networking from '@/constants/networking';
import switches from '@/constants/switches';
import urls from '@/constants/urls';
import * as errors from '@/constants/errors';

const constants = {
...networking,
...switches,
...urls,
...errors,
};
export default constants;
8 changes: 8 additions & 0 deletions express-api/src/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { KEYCLOAK_OPTIONS } from '@/middleware/keycloak/keycloakOptions';
import swaggerUi from 'swagger-ui-express';
import { Roles } from '@/constants/roles';
import swaggerJSON from '@/swagger/swagger-output.json';
import errorHandler from '@/middleware/errorHandler';
import { EndpointNotFound404 } from '@/constants/errors';

const app: Application = express();

Expand Down Expand Up @@ -78,4 +80,10 @@ app.use(`/api/v2/projects`, protectedRoute(), router.projectsRouter);
app.use(`/api/v2/reports`, protectedRoute(), router.reportsRouter);
app.use(`/api/v2/tools`, protectedRoute(), router.toolsRouter);

// If a non-existent route is called. Must go after other routes.
app.use('*', (_req, _res, next) => next(EndpointNotFound404));

// Request error handler. Must go last.
app.use(errorHandler);

export default app;
40 changes: 40 additions & 0 deletions express-api/src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { ErrorWithCode } from '@/utilities/customErrors/ErrorWithCode';
import logger from '@/utilities/winstonLogger';
import { NextFunction, Request, Response } from 'express';

/**
* Handles errors and sends appropriate response.
* Use this as the last middleware in express.ts
*
* @param err - The error message or Error object.
* @param req - The Express request object.
* @param res - The Express response object.
* @param next - The Express next function.
*/
const errorHandler = (
err: string | Error | ErrorWithCode,
req: Request,
res: Response,
next: NextFunction,
) => {
// Is this one of the valid input options?
if (!(typeof err === 'string' || err instanceof Error)) {
const message = `Unknown server error.`;
logger.error(message);
return res.status(500).send(message);
}
// Determine what message and status should be
const message = err instanceof Error ? err.message : err;
const code = err instanceof ErrorWithCode ? err.code : 500;
// Report through logger
if (code === 500) {
logger.error(message);
} else {
logger.warn(message);
}
// Return status and message
res.status(code).send(`Error: ${message}`);
next();
};

export default errorHandler;
77 changes: 66 additions & 11 deletions express-api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,74 @@ import logger from '@/utilities/winstonLogger';
import constants from '@/constants';
import app from '@/express';
import { AppDataSource } from '@/appDataSource';
import { Application } from 'express';
import { IncomingMessage, Server, ServerResponse } from 'http';

const { API_PORT } = constants;

app.listen(API_PORT, (err?: Error) => {
if (err) logger.error(err);
logger.info(`Server started on port ${API_PORT}.`);
});
/**
* Starts the application server and initializes the database connection.
*
* @param app - The Express application instance.
* @returns The server instance.
*/
const startApp = async (app: Application) => {
const server = app.listen(API_PORT, (err?: Error) => {
if (err) logger.error(err);
logger.info(`Server started on port ${API_PORT}.`);
});

// creating connection to database
await AppDataSource.initialize()
.then(() => {
logger.info('Database connection has been initialized');
})
.catch((err?: Error) => {
logger.error('Error during data source initialization. With error: ', err);
});

// creating connection to database
AppDataSource.initialize()
.then(() => {
logger.info('Database connection has been initialized');
})
.catch((err?: Error) => {
logger.error('Error during data source initialization. With error: ', err);
return server;
};

// Start the server here.
// Set up in a why that the server could be restarted (reassigned) if needed.
let server: Server<typeof IncomingMessage, typeof ServerResponse>;
(async () => {
server = await startApp(app);
})();

/**
* Stops the application gracefully.
*
* @param exitCode - The exit code to be used when terminating the process.
* @param e - Optional. The error message to be logged.
*
* @returns void
*/
const stopApp = async (exitCode: number, e?: string) => {
logger.warn('Closing database connection.');
await AppDataSource.destroy();
logger.warn('Closing user connections.');
server.closeAllConnections();
server.close(() => {
logger.error(`Express application terminated. ${e ?? 'unknown'}`);
process.exit(exitCode);
});
};

// Error catching listeners
process.on('uncaughtException', (err: Error) => {
logger.warn('Uncaught exception received. Shutting down gracefully.');
stopApp(1, `Uncaught exception: ${err.stack}`);
});

process.on('unhandledRejection', (err: Error) => {
logger.warn('Unhandled rejection received. Shutting down gracefully.');
stopApp(1, `Unhandled rejection: ${err.stack}`);
});

// When termination call is received.
process.on('SIGTERM', () => {
logger.warn('SIGTERM received. Shutting down gracefully.');
stopApp(0, 'SIGTERM request.');
});
20 changes: 19 additions & 1 deletion express-api/src/utilities/customErrors/ErrorWithCode.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
/**
* Represents an error with an associated error code.
*
* @class ErrorWithCode
* @extends Error
*
* @param {string} message - The error message.
* @param {number} code - The error code. Defaults to 400.
*
* @example
* const err = new ErrorWithCode('test');
* console.log(err.code); // 400
*
* @example
* const err = new ErrorWithCode('test', 401);
* console.log(err.code); // 401
* console.log(err.message); // 'test'
*/
export class ErrorWithCode extends Error {
public code: number;

constructor(message: string, code?: number) {
constructor(message: string, code: number = 400) {
super(message);
this.code = code;
}
Expand Down
39 changes: 39 additions & 0 deletions express-api/tests/unit/middleware/errorHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Request, Response } from 'express';
import errorHandler from '@/middleware/errorHandler';
import { MockReq, MockRes, getRequestHandlerMocks } from 'tests/testUtils/factories';
import { ErrorWithCode } from '@/utilities/customErrors/ErrorWithCode';

let mockRequest: Request & MockReq, mockResponse: Response & MockRes;

describe('UNIT - errorHandler middleware', () => {
beforeEach(() => {
const { mockReq, mockRes } = getRequestHandlerMocks();
mockRequest = mockReq;
mockResponse = mockRes;
});
const nextFunction = jest.fn();

it('should give error code 500 and send the passed error message when given a string', async () => {
errorHandler('string', mockRequest, mockResponse, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.send).toHaveBeenCalledWith('Error: string');
});

it('should give code 500 and send the error message when given an Error', async () => {
errorHandler(new Error('error'), mockRequest, mockResponse, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.send).toHaveBeenCalledWith('Error: error');
});

it('should give expected code and send the error message when given an ErrorWithCode', async () => {
errorHandler(new ErrorWithCode('withCode', 403), mockRequest, mockResponse, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(403);
expect(mockResponse.send).toHaveBeenCalledWith('Error: withCode');
});

it('should give code 500 and generic error message when given something unexpected', async () => {
errorHandler(undefined, mockRequest, mockResponse, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.send).toHaveBeenCalledWith('Unknown server error.');
});
});
13 changes: 13 additions & 0 deletions express-api/tests/unit/utilities/ErrorWithCode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ErrorWithCode } from '@/utilities/customErrors/ErrorWithCode';
describe('UNIT - ErrorWithCode', () => {
it('should have a default 400 error code', () => {
const err = new ErrorWithCode('test');
expect(err.code).toEqual(400);
});

it('should use the error code provided', () => {
const err = new ErrorWithCode('test', 401);
expect(err.code).toEqual(401);
expect(err.message).toBe('test');
});
});
Loading