diff --git a/express-api/src/constants/errors.ts b/express-api/src/constants/errors.ts new file mode 100644 index 000000000..892bcf5a8 --- /dev/null +++ b/express-api/src/constants/errors.ts @@ -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, +); diff --git a/express-api/src/constants/index.ts b/express-api/src/constants/index.ts index 47c111f17..25344efdf 100644 --- a/express-api/src/constants/index.ts +++ b/express-api/src/constants/index.ts @@ -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; diff --git a/express-api/src/express.ts b/express-api/src/express.ts index 29d9a551b..ad8a4b522 100644 --- a/express-api/src/express.ts +++ b/express-api/src/express.ts @@ -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(); @@ -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; diff --git a/express-api/src/middleware/errorHandler.ts b/express-api/src/middleware/errorHandler.ts new file mode 100644 index 000000000..ad13a0ea7 --- /dev/null +++ b/express-api/src/middleware/errorHandler.ts @@ -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; diff --git a/express-api/src/server.ts b/express-api/src/server.ts index 476df87b1..fa1e58a1f 100644 --- a/express-api/src/server.ts +++ b/express-api/src/server.ts @@ -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; +(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.'); +}); diff --git a/express-api/src/utilities/customErrors/ErrorWithCode.ts b/express-api/src/utilities/customErrors/ErrorWithCode.ts index 2e84bd71c..707944385 100644 --- a/express-api/src/utilities/customErrors/ErrorWithCode.ts +++ b/express-api/src/utilities/customErrors/ErrorWithCode.ts @@ -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; } diff --git a/express-api/tests/unit/middleware/errorHandler.test.ts b/express-api/tests/unit/middleware/errorHandler.test.ts new file mode 100644 index 000000000..602f6d859 --- /dev/null +++ b/express-api/tests/unit/middleware/errorHandler.test.ts @@ -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.'); + }); +}); diff --git a/express-api/tests/unit/utilities/ErrorWithCode.test.ts b/express-api/tests/unit/utilities/ErrorWithCode.test.ts new file mode 100644 index 000000000..8e39b8c88 --- /dev/null +++ b/express-api/tests/unit/utilities/ErrorWithCode.test.ts @@ -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'); + }); +});