Skip to content

Commit

Permalink
Extend status API with health checks for connected services (#314)
Browse files Browse the repository at this point in the history
Signed-off-by: Marvin A. Ruder <[email protected]>
  • Loading branch information
marvinruder authored Jul 12, 2023
1 parent 52c718d commit f7a859e
Show file tree
Hide file tree
Showing 14 changed files with 119 additions and 19 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ export const suiteName = "Status API";
export const tests: LiveTestSuite = [];

tests.push({
testName: "returns status “operational”",
testName: "returns status “healthy”",
testFunction: async () => {
const res = await supertest.get(`/api${statusEndpointPath}`);
expect(res.status).toBe(200);
expect(res.body.status).toBe("operational");
expect(res.body.status).toBe("healthy");
},
});
8 changes: 7 additions & 1 deletion packages/backend/src/controllers/StatusController.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Request, Response } from "express";
import { statusEndpointPath } from "@rating-tracker/commons";
import Router from "../utils/router.js";
import { redisIsReady } from "../redis/redis.js";
import { prismaIsReady } from "../db/client.js";
import { seleniumIsReady } from "../utils/webdriver.js";
import { signalIsReadyOrUnused } from "../signal/signalBase.js";

/**
* This class is responsible for providing a trivial status response whenever the backend API is up and running.
Expand All @@ -18,6 +22,8 @@ export class StatusController {
accessRights: 0,
})
get(_: Request, res: Response) {
res.status(200).json({ status: "operational" }).end();
Promise.all([redisIsReady(), prismaIsReady(), seleniumIsReady(), signalIsReadyOrUnused()])
.then(() => res.status(200).json({ status: "healthy" }).end())
.catch((e) => res.status(500).json({ status: "unhealthy", details: e.message }).end());
}
}
11 changes: 11 additions & 0 deletions packages/backend/src/db/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,15 @@ import { PrismaClient } from "../../prisma/client";
*/
const client = new PrismaClient();

/**
* Checks if the database is reachable.
*
* @returns {Promise<void>} A promise that resolves when the database is reachable, or rejects with an error if it is
* not.
*/
export const prismaIsReady = (): Promise<void> =>
client.$executeRaw`SELECT null`
.then(() => Promise.resolve())
.catch((e) => Promise.reject(new Error("Database is not reachable: " + e.message)));

export default client;
12 changes: 12 additions & 0 deletions packages/backend/src/openapi/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,5 +387,17 @@ export const components: OpenAPIV3.ComponentsObject = {
},
required: ["message"],
},
Status: {
type: "object",
properties: {
status: {
type: "string",
},
details: {
type: "string",
},
},
required: ["status"],
},
},
};
13 changes: 9 additions & 4 deletions packages/backend/src/openapi/paths/statusEndpoint.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { OpenAPIV3 } from "express-openapi-validator/dist/framework/types.js";
import { okOperational } from "../responses/success.js";
import { okHealthy } from "../responses/success.js";
import { internalServerErrorServerUnhealthy } from "../responses/serverError.js";

/**
* Returns a JSON object with the status “operational” if online.
* Returns a JSON object with the status “healthy” if online and able to connect to all services, or “unhealthy”
* otherwise.
*/
const get: OpenAPIV3.OperationObject = {
tags: ["Status API"],
operationId: "status",
summary: "Status API",
description: "Returns a JSON object with the status “operational” if online.",
description:
"Returns a JSON object with the status “healthy” if online and able to connect to all services, or " +
"“unhealthy” otherwise.",
responses: {
"200": okOperational,
"200": okHealthy,
"500": internalServerErrorServerUnhealthy,
},
};

Expand Down
14 changes: 14 additions & 0 deletions packages/backend/src/openapi/responses/serverError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ export const internalServerError: OpenAPIV3.ResponseObject = {
},
};

/**
* A response with a 500 Internal Server Error status code and a Status object body.
*/
export const internalServerErrorServerUnhealthy: OpenAPIV3.ResponseObject = {
description: "Internal Server Error – Server Unhealthy",
content: {
"application/json": {
schema: {
$ref: "#/components/schemas/Status",
},
},
},
};

/**
* A response with a 501 Not Implemented status code and an Error object body.
*/
Expand Down
12 changes: 3 additions & 9 deletions packages/backend/src/openapi/responses/success.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,20 +129,14 @@ export const okWatchlistSummary: OpenAPIV3.ResponseObject = {
};

/**
* A response with a 200 OK status code and an object containing a status string.
* A response with a 200 OK status code and a Status object body.
*/
export const okOperational: OpenAPIV3.ResponseObject = {
export const okHealthy: OpenAPIV3.ResponseObject = {
description: "OK",
content: {
"application/json": {
schema: {
type: "object",
properties: {
status: {
type: "string",
},
},
required: ["status"],
$ref: "#/components/schemas/Status",
},
},
},
Expand Down
18 changes: 18 additions & 0 deletions packages/backend/src/redis/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,22 @@ redis.on(
);
await redis.connect();

/**
* Checks if the Redis server is reachable.
*
* @returns {Promise<void>} A promise that resolves when the Redis server is reachable, or rejects with an error if it
* is not.
*/
export const redisIsReady = (): Promise<void> =>
redis.isReady
? redis
.ping()
.then((pong) =>
pong === "PONG"
? Promise.resolve()
: /* c8 ignore next */ Promise.reject(new Error("Redis is not reachable: server responded with " + pong)),
)
: /* c8 ignore next */ Promise.reject(new Error("Redis is not ready"));
// The errors only occurs when Redis server is not available, which is difficult to reproduce.

export default redis;
7 changes: 7 additions & 0 deletions packages/backend/src/signal/__mocks__/signalBase.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/**
* A mock of the signalIsReadyOrUnused function. Always resolves to "No Signal URL provided".
*
* @returns {Promise<string | void>} A promise that resolves to "No Signal URL provided".
*/
export const signalIsReadyOrUnused = (): Promise<string | void> => Promise.resolve("No Signal URL provided");

/**
* A mock storage of sent Signal messages.
*/
Expand Down
16 changes: 15 additions & 1 deletion packages/backend/src/signal/signalBase.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
// This file is not tested because tests must not depend on a running Signal Client instance
import axios from "axios";
import axios, { AxiosError } from "axios";
import chalk from "chalk";
import logger, { PREFIX_SIGNAL } from "../utils/logger.js";

/**
* Checks if the Signal Client instance is reachable.
*
* @returns {Promise<string | void>} A promise that resolves when the Signal Client instance is reachable, or rejects
* with an error if it is not.
*/
export const signalIsReadyOrUnused = (): Promise<string | void> =>
process.env.SIGNAL_URL && process.env.SIGNAL_SENDER
? axios
.get(`${process.env.SIGNAL_URL}/v1/health`, { timeout: 1000 })
.then((res) => (res.status === 204 ? Promise.resolve() : Promise.reject(new Error("Signal is not ready"))))
.catch((e: AxiosError) => Promise.reject(new Error("Signal is not reachable: " + e.message)))
: Promise.resolve("No Signal URL provided");

/**
* Send a message to a list of recipients.
*
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/utils/__mocks__/webdriver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* A mock of the seleniumIsReady function. Always resolves.
*
* @returns {Promise<void>} A promise that always resolves.
*/
export const seleniumIsReady = (): Promise<void> => Promise.resolve();
2 changes: 1 addition & 1 deletion packages/backend/src/utils/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export default (bypassAuthenticationForInternalRequestsToken: string, autoFetchS
chalk.bgGrey.hex("#339933")("") +
chalk.whiteBright.bgGrey(` Auto Fetch activated `) +
chalk.grey("") +
chalk.green(" This instance will periodically fetch information from data providers for all known stocks."),
chalk.green(" This process will periodically fetch information from data providers for all known stocks."),
);
logger.info("");
};
14 changes: 13 additions & 1 deletion packages/backend/src/utils/webdriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,25 @@ import logger, { PREFIX_SELENIUM } from "./logger.js";
import chalk from "chalk";
import { Stock, resourceEndpointPath } from "@rating-tracker/commons";
import { createResource } from "../redis/repositories/resourceRepository.js";
import axios from "axios";
import axios, { AxiosError } from "axios";

/**
* A page load strategy to use by the WebDriver.
*/
type PageLoadStrategy = "normal" | "eager" | "none";

/**
* Checks if the Selenium instance is reachable.
*
* @returns {Promise<void>} A promise that resolves when the Selenium instance is reachable, or rejects with an error if
* it is not.
*/
export const seleniumIsReady = (): Promise<void> =>
axios
.get(`${process.env.SELENIUM_URL}/status`, { timeout: 1000 })
.then((res) => (res.data.value.ready ? Promise.resolve() : Promise.reject(new Error("Selenium is not ready"))))
.catch((e: AxiosError) => Promise.reject(new Error("Selenium is not reachable: " + e.message)));

/**
* Creates and returns a new WebDriver instance.
*
Expand Down
1 change: 1 addition & 0 deletions packages/backend/test/live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { sentMessages } from "../src/signal/__mocks__/signalBase";
import * as signal from "../src/signal/signal";

vi.mock("../src/utils/logger");
vi.mock("../src/utils/webdriver");
vi.mock("../src/signal/signalBase");
vi.mock("@simplewebauthn/server", async () => await import("./moduleMocks/@simplewebauthn/server"));

Expand Down

0 comments on commit f7a859e

Please sign in to comment.