diff --git a/README.md b/README.md index 0c945083..f5726ec7 100644 --- a/README.md +++ b/README.md @@ -36,3 +36,9 @@ To understand what is planned you can read and ask questions here: To install correct node version, we recommend that you use [nvm](https://github.com/nvm-sh/nvm). If you have `nvm` installed you can run `nvm install && nvm use` to automatically use the correct node version. The version is detected from the [.nvmrc](./.nvmrc). If you do not have a code editor setup, we recommend that you use [Visual Studio Code](https://code.visualstudio.com/) to get started. It is very beginner friendly and you can move on to something else down the road if you want to. + +## Health endpoints + +Both api and frontend have health endpoints that can be used to check if the service is up and running. + +See the respective descriptions in the [api](./api/README.md#health-endpoint) and [frontend](./frontend/README.md#health-endpoint) READMEs. diff --git a/api/README.md b/api/README.md index 7ce389fe..ed33d344 100644 --- a/api/README.md +++ b/api/README.md @@ -53,3 +53,7 @@ npx prisma studio ### Logging configuration The configuration is received from the `logger.config.ts` file at the root of the project. Adjust the file parameters to control the logger behaviour. + +## Health endpoint + +Endpoint available at `/healthz` path. Provides response if api is currently running and prisma (orm) is able to execute queries. diff --git a/api/package-lock.json b/api/package-lock.json index 62b7ac62..a50099b1 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -47,7 +47,8 @@ "gql-query-builder": "^3.8.0", "graphql-request": "^5.2.0", "prisma": "^4.11.0", - "typescript": "^4.9.5" + "typescript": "^4.9.5", + "vitest-mock-extended": "^1.1.3" } }, "node_modules/@ampproject/remapping": { @@ -148,6 +149,25 @@ "graphql": "14.x || 15.x || 16.x" } }, + "node_modules/@apollo/server/node_modules/node-fetch": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz", + "integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/@apollo/usage-reporting-protobuf": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@apollo/usage-reporting-protobuf/-/usage-reporting-protobuf-4.1.0.tgz", @@ -1344,6 +1364,25 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, + "node_modules/@mapbox/node-pre-gyp/node_modules/node-fetch": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz", + "integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4987,25 +5026,6 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==" }, - "node_modules/node-fetch": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz", - "integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-releases": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz", @@ -6259,6 +6279,15 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "node_modules/ts-essentials": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-9.3.1.tgz", + "integrity": "sha512-9CChSvQMyVRo29Vb1A2jbs+LKo3d/bAf+ndSaX0T8cEiy/HChVaRN/HY5DqUryZ8hZ6uol9bEgCnGmnDbwBR9Q==", + "dev": true, + "peerDependencies": { + "typescript": ">=4.1.0" + } + }, "node_modules/tsconfig-paths": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", @@ -6622,6 +6651,19 @@ } } }, + "node_modules/vitest-mock-extended": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitest-mock-extended/-/vitest-mock-extended-1.1.3.tgz", + "integrity": "sha512-MiaKYZbTg+fjozKnCpoTTva0BnlSNYyk4jiPuM2xVhg4aou112QIrALdH3/ZKK6qfXWh0A17gFIjWJjylOlXxg==", + "dev": true, + "dependencies": { + "ts-essentials": "^9.3.1" + }, + "peerDependencies": { + "typescript": "3.x || 4.x || 5.x", + "vitest": ">=0.29.2" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -6867,6 +6909,16 @@ "node-fetch": "^2.6.7", "uuid": "^9.0.0", "whatwg-mimetype": "^3.0.0" + }, + "dependencies": { + "node-fetch": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz", + "integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==", + "requires": { + "whatwg-url": "^5.0.0" + } + } } }, "@apollo/server-gateway-interface": { @@ -7662,6 +7714,16 @@ "rimraf": "^3.0.2", "semver": "^7.3.5", "tar": "^6.1.11" + }, + "dependencies": { + "node-fetch": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz", + "integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==", + "requires": { + "whatwg-url": "^5.0.0" + } + } } }, "@nodelib/fs.scandir": { @@ -10391,14 +10453,6 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==" }, - "node-fetch": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz", - "integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==", - "requires": { - "whatwg-url": "^5.0.0" - } - }, "node-releases": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz", @@ -11305,6 +11359,13 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "ts-essentials": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-9.3.1.tgz", + "integrity": "sha512-9CChSvQMyVRo29Vb1A2jbs+LKo3d/bAf+ndSaX0T8cEiy/HChVaRN/HY5DqUryZ8hZ6uol9bEgCnGmnDbwBR9Q==", + "dev": true, + "requires": {} + }, "tsconfig-paths": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", @@ -11520,6 +11581,15 @@ "why-is-node-running": "^2.2.2" } }, + "vitest-mock-extended": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitest-mock-extended/-/vitest-mock-extended-1.1.3.tgz", + "integrity": "sha512-MiaKYZbTg+fjozKnCpoTTva0BnlSNYyk4jiPuM2xVhg4aou112QIrALdH3/ZKK6qfXWh0A17gFIjWJjylOlXxg==", + "dev": true, + "requires": { + "ts-essentials": "^9.3.1" + } + }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/api/package.json b/api/package.json index 2fd415a2..d8ceac88 100644 --- a/api/package.json +++ b/api/package.json @@ -53,6 +53,7 @@ "gql-query-builder": "^3.8.0", "graphql-request": "^5.2.0", "prisma": "^4.11.0", - "typescript": "^4.9.5" + "typescript": "^4.9.5", + "vitest-mock-extended": "^1.1.3" } } diff --git a/api/src/__mocks__/database.ts b/api/src/__mocks__/database.ts new file mode 100644 index 00000000..8551c946 --- /dev/null +++ b/api/src/__mocks__/database.ts @@ -0,0 +1,11 @@ +import { PrismaClient } from '@prisma/client'; +import { beforeEach } from 'vitest'; +import { mockDeep, mockReset } from 'vitest-mock-extended'; + +const prisma = mockDeep(); + +beforeEach(() => { + mockReset(prisma); +}); + +export default prisma; diff --git a/api/src/app.ts b/api/src/app.ts index 85492154..b7aa47df 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -2,19 +2,42 @@ import type { Express } from 'express'; import express from 'express'; import expressPlayground from 'graphql-playground-middleware-express'; import { getChildLogger } from './logger'; +import prisma from './database'; const logger = getChildLogger({ msgPrefix: 'APP' }); +const startupTime = new Date(); export const createApp = (): Express => { logger.debug('Creating app'); const app = express(); - app.get('/', expressPlayground({ - endpoint: '/api/graphql', - settings: { - 'editor.theme': 'light', - }, - })); + app.get('/healthz', async (_req, res) => { + try { + // TODO: after migration to postgres, do SELECT 1 + await prisma.user.findFirst(); + } catch (error) { + return res.status(500).json({ + status: 'DB failed initialization check', + time: new Date(), + startupTime, + }); + } + return res.json({ + status: 'healthy', + time: new Date(), + startupTime, + }); + }); + + app.get( + '/', + expressPlayground({ + endpoint: '/api/graphql', + settings: { + 'editor.theme': 'light', + }, + }), + ); return app; }; diff --git a/api/tests/auxilary.test.ts b/api/tests/auxilary.test.ts index 053ce60f..360f5e99 100644 --- a/api/tests/auxilary.test.ts +++ b/api/tests/auxilary.test.ts @@ -1,6 +1,10 @@ -import { test, expect } from 'vitest'; +import { + test, expect, vi, describe, +} from 'vitest'; import { getJwtSecret, getJwtExpirationPeriod } from '../src/env/getters'; import { restoreEnvAfterEach } from './helpers/env'; +import { ctx } from './helpers/server'; +import prisma from '../src/__mocks__/database'; restoreEnvAfterEach(); @@ -34,3 +38,25 @@ test('Env: jwt expiration in seconds format', async () => { process.env.JWT_EXPIRATION_PERIOD = '3600'; expect(getJwtExpirationPeriod()).toBe('1h'); }); + +describe('Healthz', () => { + vi.mock('../src/database'); + + test('healthz: returns 200', async () => { + prisma.user.findFirst.mockResolvedValueOnce({ id: '1', username: 'asdf', password: 'asdf' }); + const url = `${ctx.baseUrl}/healthz`; + const res = await fetch(url); + expect(res.status).toBe(200); + const json: any = await res.json(); + expect(json.status).toBe('healthy'); + }); + + test('healthz: returns 500', async () => { + prisma.user.findFirst.mockRejectedValueOnce(new Error('test')); + const url = `${ctx.baseUrl}/healthz`; + const res = await fetch(url); + expect(res.status).toBe(500); + const json: any = await res.json(); + expect(json.status).toBe('DB failed initialization check'); + }); +}); diff --git a/api/tests/helpers/server.ts b/api/tests/helpers/server.ts index 52ceee99..58408d1e 100644 --- a/api/tests/helpers/server.ts +++ b/api/tests/helpers/server.ts @@ -6,10 +6,12 @@ import { createApp } from '../../src/app'; interface TestContext { client: GraphQLClient; + baseUrl: string; } function getGraphqlTestContext() { let serverInstance: Server | null = null; + let baseUrl: string | null = null; return { async before() { const app = createApp(); @@ -19,10 +21,12 @@ function getGraphqlTestContext() { throw new Error('Unexpected server address format'); } const { port } = serverAddress; - return new GraphQLClient(`http://0.0.0.0:${port}/graphql`); + baseUrl = `http://0.0.0.0:${port}`; + return { client: new GraphQLClient(`${baseUrl}/graphql`), baseUrl }; }, async after() { serverInstance?.close(); + baseUrl = null; }, }; } @@ -31,8 +35,9 @@ function createTestContext(): TestContext { const context = {} as TestContext; const graphqlTestContext = getGraphqlTestContext(); beforeEach(async () => { - const client = await graphqlTestContext.before(); + const { client, baseUrl } = await graphqlTestContext.before(); context.client = client; + context.baseUrl = baseUrl; }); afterEach(async () => { await graphqlTestContext.after(); diff --git a/frontend/README.md b/frontend/README.md index 1d5de788..ca54ff6c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -13,3 +13,7 @@ Please refer to the root readme to learn more about general development setup. ## Production You can build application for production using `npm run build` and then locally preview production build via `npm run preview`. + +## Health endpoint + +Endpoint available at `/healthz` path. Provides response if frontend is currently running. diff --git a/frontend/server/api/healthz.ts b/frontend/server/api/healthz.ts new file mode 100644 index 00000000..d1de2977 --- /dev/null +++ b/frontend/server/api/healthz.ts @@ -0,0 +1,9 @@ +const startupTime = new Date() + +export default eventHandler(() => { + return { + status: 'healthy', + time: new Date(), + startupTime + } +})