Skip to content

Commit

Permalink
add health check API
Browse files Browse the repository at this point in the history
  • Loading branch information
youngbryanyu committed Feb 15, 2024
1 parent f115a59 commit 4261ba3
Show file tree
Hide file tree
Showing 9 changed files with 204 additions and 5 deletions.
6 changes: 6 additions & 0 deletions backend/config/development.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
"THRESHOLD": 100,
"WINDOW": 3600000
}
},
"HEALTH_CHECKS": {
"HEALTH_CHECK": {
"THRESHOLD": 120,
"WINDOW": 60
}
}
},
"AUTH": {
Expand Down
6 changes: 6 additions & 0 deletions backend/config/testing.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
"THRESHOLD": 5,
"WINDOW": 5000
}
},
"HEALTH_CHECKS": {
"HEALTH_CHECK": {
"THRESHOLD": 5,
"WINDOW": 5000
}
}
},
"AUTH": {
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import express, { Express } from 'express';
import mongoose from 'mongoose';
import authRoute from './routes/authRoutes';
import healthCheckRoute from './routes/healthCheckRoutes';
import { API_URLS_V1 } from './constants';
import logger from './logging/logger';
import Config from 'simple-app-config';
Expand Down Expand Up @@ -67,6 +68,7 @@ class App {
*/
private mountRoutes(): void {
this.expressApp.use(API_URLS_V1.AUTH, authRoute);
this.expressApp.use(API_URLS_V1.HEALTH_CHECK, healthCheckRoute);
}

/**
Expand Down
14 changes: 10 additions & 4 deletions backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ export const ENVIRONMENTS = {
// -----------------

/* API URLs version 1 */
const API_URLS_V1_PREFIX = '/fitnesse/v1';
export const API_URLS_V1 = {
PREFIX: '/fitness/v1' /* prefix for the API endpoints */,
AUTH: '/fitnesse/v1/auth'
AUTH: `${API_URLS_V1_PREFIX}/auth`,
HEALTH_CHECK: `${API_URLS_V1_PREFIX}/healthCheck`
};

// ------------------------------
Expand Down Expand Up @@ -49,6 +50,12 @@ export const AUTH_RESPONSES = {
'User is locked out due to too many failed login attempts, please try again later.'
};

/* Response messages for auth endpoints */
export const HEALTH_CHECK_RESPONSES = {
_200_SUCCESS: 'Health check successful',
_503_FAILURE: 'Health check failed'
};

// ----------------------------
// CUSTOM HTTP RESPONSE HEADERS
// ----------------------------
Expand All @@ -57,8 +64,7 @@ export const AUTH_RESPONSES = {
export const HEADERS = {
NEW_ACCESS_TOKEN: 'x-new-access-token',
ACCESS_TOKEN: 'authorization',
REFRESH_TOKEN: 'x-refresh-token',
USER_ID: 'x-user-id' // TODO: delete
REFRESH_TOKEN: 'x-refresh-token'
};

// ---------------------------
Expand Down
39 changes: 39 additions & 0 deletions backend/src/controllers/healthCheckController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/* Business logic for application health checks */
import { Request, Response } from 'express';
import mongoose, { STATES } from 'mongoose';
import logger from '../logging/logger';
import { HEALTH_CHECK_RESPONSES } from '../constants';

/**
* Business logic for application health checks
*/
class HealthCheckController {
/**
* Performs sanity checks to determine if the app is healthy
* @param req incoming request from client.
* @param res response to return to client.
* @returns Returns a promise indicating completion of the async function.
*/
static async checkHealth(req: Request, res: Response): Promise<void> {
try {
/* Check if all MongoDB connections pass the ping check */
for (const connection of mongoose.connections) {
if (connection.readyState === STATES.connected) {
await connection.db.admin().ping();
}
}

/* Response to client */
res.status(200).json({
message: HEALTH_CHECK_RESPONSES._200_SUCCESS
});
} catch (error) {
logger.error('Error occured during database health check (failure): ' + error);
res.status(503).json({
message: HEALTH_CHECK_RESPONSES._503_FAILURE
});
}
}
}

export default HealthCheckController;
2 changes: 1 addition & 1 deletion backend/src/routes/authRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const rateLimitRegister = rateLimit({
windowMs: Config.get('RATE_LIMITING.AUTH.REGISTER.WINDOW'),
max: Config.get('RATE_LIMITING.AUTH.REGISTER.THRESHOLD'),
handler: (req, res) => {
logger.info(`The register rate limit has been reached for IP ${req.socket.remoteAddress}`);
logger.info(`The register rate limit has been reached for IP ${req.ip}`);
res.status(429).json({
message: AUTH_RESPONSES._429_RATE_LIMIT_EXCEEDED
});
Expand Down
26 changes: 26 additions & 0 deletions backend/src/routes/healthCheckRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/* Routes for application health checks */
import express from 'express';
import rateLimit from 'express-rate-limit';
import { AUTH_RESPONSES } from '../constants';
import logger from '../logging/logger';
import Config from 'simple-app-config';
import HealthCheckController from '../controllers/healthCheckController';

const router = express.Router();

/* Rate limit register API based on IP */
const rateLimitHealthCheck = rateLimit({
windowMs: Config.get('RATE_LIMITING.HEALTH_CHECKS.HEALTH_CHECK.WINDOW'),
max: Config.get('RATE_LIMITING.HEALTH_CHECKS.HEALTH_CHECK.THRESHOLD'),
handler: (req, res) => {
logger.info(`The register rate limit has been reached for IP ${req.ip}`);
res.status(429).json({
message: AUTH_RESPONSES._429_RATE_LIMIT_EXCEEDED
});
}
});

/* Register route */
router.get('/healthCheck', rateLimitHealthCheck, HealthCheckController.checkHealth);

export default router;
59 changes: 59 additions & 0 deletions backend/tests/controllers/healthCheckController.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Request, Response } from 'express';
import { MockRequest, MockResponse, createRequest, createResponse } from 'node-mocks-http';
import HealthCheckController from '../../src/controllers/healthCheckController';
import { HEALTH_CHECK_RESPONSES } from '../../src/constants';
import mongoose from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';

describe('Health Check Controller Tests', () => {
let request: MockRequest<Request>;
let response: MockResponse<Response>;
let mongoServer: MongoMemoryServer;
let uri: string;

beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
uri = mongoServer.getUri();
await mongoose.connect(uri);
});

beforeEach(() => {
response = createResponse();
jest.restoreAllMocks();
});

afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});

describe('checkHealth', () => {
it('should succeed if all DB connections are healthy', async () => {
/* Create mock request */
request = createRequest();

/* Set up mocks */
mongoose.mongo.Admin.prototype.ping = jest.fn().mockResolvedValueOnce(true);

/* Test against expected */
await HealthCheckController.checkHealth(request, response);
expect(response.statusCode).toBe(200);
expect(response._getJSONData().message).toBe(HEALTH_CHECK_RESPONSES._200_SUCCESS);
});

it("should fail if any DB connections aren't healthy", async () => {
/* Create mock request */
request = createRequest();

/* Set up mocks*/
mongoose.mongo.Admin.prototype.ping = jest
.fn()
.mockRejectedValueOnce(new Error('Failed to ping'));

/* Test against expected */
await HealthCheckController.checkHealth(request, response);
expect(response.statusCode).toBe(503);
expect(response._getJSONData().message).toBe(HEALTH_CHECK_RESPONSES._503_FAILURE);
});
});
});
55 changes: 55 additions & 0 deletions backend/tests/routes/healthCheckRoutes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/* Unit tests for the auth routes */
import request from 'supertest';
import { API_URLS_V1, AUTH_RESPONSES } from '../../src/constants';
import App from '../../src/app';
import Config from 'simple-app-config';
import HealthCheckController from '../../src/controllers/healthCheckController';

/* Mock the controller functions */
jest.mock('../../src/controllers/healthCheckController', () => ({
checkHealth: jest.fn().mockImplementation((req, res) => {
res.sendStatus(200);
})
}));

describe('Health Check Routes Tests', () => {
let appInstance: App;

beforeAll(() => {
appInstance = new App();
});

beforeEach(() => {
jest.restoreAllMocks();
});

describe('GET /healthCheck', () => {
it('should call HealthCheckController.checkHealth', async () => {
/* Make the API call */
const expressInstance = appInstance.getExpressApp();
await request(expressInstance).get(`${API_URLS_V1.HEALTH_CHECK}/healthCheck`).send({});

/* Test against expected */
expect(HealthCheckController.checkHealth).toHaveBeenCalled();
});

it('should fail when the rate limit is exceeded', async () => {
/* Call API `threshold` times so that next call will cause rating limiting */
const expressInstance = appInstance.getExpressApp();
const threshold: number = Config.get('RATE_LIMITING.HEALTH_CHECKS.HEALTH_CHECK.THRESHOLD');
for (let i = 0; i < threshold; i++) {
await request(expressInstance).get(`${API_URLS_V1.HEALTH_CHECK}/healthCheck`).send({});
}

/* Call API */
const response = await request(expressInstance)
.get(`${API_URLS_V1.HEALTH_CHECK}/healthCheck`)
.send({});

/* Test against expected */
expect(HealthCheckController.checkHealth).toHaveBeenCalled();
expect(response.statusCode).toBe(429);
expect(response.body.message).toBe(AUTH_RESPONSES._429_RATE_LIMIT_EXCEEDED);
});
});
});

0 comments on commit 4261ba3

Please sign in to comment.