Skip to content

Commit

Permalink
Add health check endpoint to Keystone's express server (#6192)
Browse files Browse the repository at this point in the history
  • Loading branch information
JedWatson authored Jul 27, 2021
1 parent 14cb7c5 commit 93f1e5d
Show file tree
Hide file tree
Showing 9 changed files with 206 additions and 3 deletions.
40 changes: 40 additions & 0 deletions .changeset/cyan-rats-sip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@keystone-next/keystone": patch
"@keystone-next/types": patch
---

Added an optional `/_healthcheck` endpoint to Keystone's express server.

You can enable it by setting `config.server.healthCheck: true`

By default it will respond with `{ status: 'pass', timestamp: Date.now() }`

You can also specify a custom path and JSON data:

```js
config({
server: {
healthCheck: {
path: '/my-health-check',
data: { status: 'healthy' },
}
}
})
```

Or use a function for the `data` config to return real-time information:

```js
config({
server: {
healthCheck: {
path: '/my-health-check',
data: () => ({
status: 'healthy',
timestamp: Date.now(),
uptime: process.uptime(),
}),
}
}
})
```
42 changes: 39 additions & 3 deletions docs/pages/docs/apis/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -196,18 +196,54 @@ Options:
If left undefined `cors` will not be used.
- `port` (default: `3000` ): The port your Express server will listen on.
- `maxFileSize` (default: `200 * 1024 * 1024`): The maximum file size allowed for uploads. If left undefined, defaults to `200 MiB`
- `healthCheck` (default: `undefined`): Allows you to configure a health check endpoint on your server.

```typescript
export default config({
server: {
cors: { origin: ['http://localhost:7777'], credentials: true }:
port: 3000
maxFileSize: 200 * 1024 * 1024
cors: { origin: ['http://localhost:7777'], credentials: true },
port: 3000,
maxFileSize: 200 * 1024 * 1024,
healthCheck: true,
},
/* ... */
});
```

### healthCheck

If set to `true`, a `/_healthcheck` endpoint will be added to your server which will respond with `{ status: 'pass', timestamp: Date.now() }`.

You can configure the health check with a custom path and JSON data:

```typescript
config({
server: {
healthCheck: {
path: '/my-health-check',
data: { status: 'healthy' },
}
}
})
```

Or use a function for the `data` config to return real-time information:

```typescript
config({
server: {
healthCheck: {
path: '/my-health-check',
data: () => ({
status: 'healthy',
timestamp: Date.now(),
uptime: process.uptime(),
}),
}
}
})
```

## session

```
Expand Down
3 changes: 3 additions & 0 deletions packages/keystone/src/lib/config/defaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const defaults = {
healthCheckPath: '/_healthcheck',
} as const;
23 changes: 23 additions & 0 deletions packages/keystone/src/lib/server/addHealthCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { KeystoneConfig } from '@keystone-next/types';
import { Application } from 'express';

import { defaults } from '../config/defaults';

type AddHealthCheckArgs = { config: KeystoneConfig; server: Application };

export const addHealthCheck = async ({ config, server }: AddHealthCheckArgs) => {
if (!config.server?.healthCheck) return;
const healthCheck = config.server.healthCheck === true ? {} : config.server.healthCheck;

const path = healthCheck.path || defaults.healthCheckPath;

server.use(path, (req, res) => {
const data = (typeof healthCheck.data === 'function'
? healthCheck.data()
: healthCheck.data) || {
status: 'pass',
timestamp: Date.now(),
};
res.json(data);
});
};
3 changes: 3 additions & 0 deletions packages/keystone/src/lib/server/createExpressServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { graphqlUploadExpress } from 'graphql-upload';
import type { KeystoneConfig, CreateContext, SessionStrategy } from '@keystone-next/types';
import { createAdminUIServer } from '../../admin-ui/system';
import { createApolloServerExpress } from './createApolloServer';
import { addHealthCheck } from './addHealthCheck';

const DEFAULT_MAX_FILE_SIZE = 200 * 1024 * 1024; // 200 MiB

Expand Down Expand Up @@ -58,6 +59,8 @@ export const createExpressServer = async (
server.use(cors(corsConfig));
}

addHealthCheck({ config, server });

if (isVerbose) console.log('✨ Preparing GraphQL Server');
addApolloServer({
server,
Expand Down
16 changes: 16 additions & 0 deletions packages/keystone/src/scripts/run/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { devMigrations, pushPrismaSchemaToDatabase } from '../../lib/migrations'
import { createSystem } from '../../lib/createSystem';
import { initConfig } from '../../lib/config/initConfig';
import { requireSource } from '../../lib/config/requireSource';
import { defaults } from '../../lib/config/defaults';
import { createExpressServer } from '../../lib/server/createExpressServer';
import {
generateCommittedArtifacts,
Expand Down Expand Up @@ -78,6 +79,21 @@ export const dev = async (cwd: string, shouldDropDatabase: boolean) => {
console.log(`👋 Admin UI and GraphQL API ready`);
};

// You shouldn't really be doing a healthcheck on the dev server, but we
// respond on the endpoint with the correct error code just in case. This
// doesn't send the configured data shape, because config doesn't allow
// for the "not ready" case but that's probably OK.
if (config.server?.healthCheck) {
const healthCheckPath =
config.server.healthCheck === true
? defaults.healthCheckPath
: config.server.healthCheck.path || defaults.healthCheckPath;
app.use(healthCheckPath, (req, res, next) => {
if (expressServer) return next();
res.status(503).json({ status: 'fail', timestamp: Date.now() });
});
}

app.use('/__keystone_dev_status', (req, res) => {
res.json({ ready: expressServer ? true : false });
});
Expand Down
7 changes: 7 additions & 0 deletions packages/types/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,20 @@ export type AdminFileToWrite =

// config.server

type HealthCheckConfig = {
path?: string;
data?: Record<string, any> | (() => Record<string, any>);
};

export type ServerConfig = {
/** Configuration options for the cors middleware. Set to `true` to use core Keystone defaults */
cors?: CorsOptions | true;
/** Port number to start the server on. Defaults to process.env.PORT || 3000 */
port?: number;
/** Maximum upload file size allowed (in bytes) */
maxFileSize?: number;
/** Health check configuration. Set to `true` to add a basic `/_healthcheck` route, or specify the path and data explicitly */
healthCheck?: HealthCheckConfig | true;
};

// config.graphql
Expand Down
74 changes: 74 additions & 0 deletions tests/api-tests/healthcheck.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { createSchema, list } from '@keystone-next/keystone/schema';
import { text } from '@keystone-next/fields';
import { setupTestRunner } from '@keystone-next/testing';
import supertest from 'supertest';
import { apiTestConfig } from './utils';

const makeRunner = (healthCheck: any) =>
setupTestRunner({
config: apiTestConfig({
lists: createSchema({ User: list({ fields: { name: text() } }) }),
server: { healthCheck },
}),
});

test(
'No health check',
makeRunner(undefined)(async ({ app }) => {
await supertest(app).get('/_healthcheck').set('Accept', 'application/json').expect(404);
})
);

test(
'Default health check',
makeRunner(true)(async ({ app }) => {
const { text } = await supertest(app)
.get('/_healthcheck')
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect(200);
expect(JSON.parse(text)).toEqual({
status: 'pass',
timestamp: expect.any(Number),
});
})
);

test(
'Custom path',
makeRunner({ path: '/custom' })(async ({ app }) => {
const { text } = await supertest(app)
.get('/custom')
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect(200);
expect(JSON.parse(text)).toEqual({
status: 'pass',
timestamp: expect.any(Number),
});
})
);

test(
'Custom data: object',
makeRunner({ data: { foo: 'bar' } })(async ({ app }) => {
const { text } = await supertest(app)
.get('/_healthcheck')
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect(200);
expect(JSON.parse(text)).toEqual({ foo: 'bar' });
})
);

test(
'Custom data: function',
makeRunner({ data: () => ({ foo: 'bar' }) })(async ({ app }) => {
const { text } = await supertest(app)
.get('/_healthcheck')
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect(200);
expect(JSON.parse(text)).toEqual({ foo: 'bar' });
})
);
1 change: 1 addition & 0 deletions tests/api-tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"graphql": "^15.5.1",
"memoize-one": "^5.2.1",
"superagent": "^6.1.0",
"supertest": "^6.1.4",
"testcheck": "^1.0.0-rc.2",
"uuid": "^8.3.2"
},
Expand Down

1 comment on commit 93f1e5d

@vercel
Copy link

@vercel vercel bot commented on 93f1e5d Jul 27, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.