Skip to content

Commit

Permalink
feat(ErrorMiddleware): Add module (#9)
Browse files Browse the repository at this point in the history
  • Loading branch information
72636c authored Jun 12, 2020
1 parent 5f29d24 commit 9c90800
Show file tree
Hide file tree
Showing 9 changed files with 317 additions and 8 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ yarn add seek-koala

- **[AsyncMiddleware](./src/asyncMiddleware/README.md)** facilitates lazy loading of an asynchronously-initialised middleware.

- **[ErrorMiddleware](./src/errorMiddleware/README.md)** catches errors from downstream middleware.

- **[MetricsMiddleware](./src/metricsMiddleware/README.md)** uses [hot-shots](https://github.com/brightcove/hot-shots) to record Datadog metrics about requests and their response codes.

- **[RequestLogging](./src/requestLogging/README.md)** facilitates logging information about requests and responses.
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
"description": "Koa add-ons for SEEK-standard tracing, logging and metrics",
"devDependencies": {
"@types/koa": "2.11.3",
"@types/supertest": "2.0.9",
"@types/uuid": "8.0.0",
"eslint-plugin-tsdoc": "0.2.5",
"hot-shots": "7.5.0",
"koa": "2.12.0",
"semantic-release": "17.0.8",
"skuba": "3.6.0"
"skuba": "3.6.0",
"supertest": "4.0.2"
},
"engines": {
"node": ">=10.7"
Expand Down
2 changes: 1 addition & 1 deletion src/asyncMiddleware/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

This add-on wraps an asynchronously-initialised middleware to allow it to be synchronously attached to a Koa application.
This is useful if you want to keep the initialisation of your Koa application synchronous for simplicity and interoperability with tooling like [Koa Cluster],
but you need to perform asynchronous work like [GraphQL schema introspection] in order to build one of the middlewares in your chain.
but you need to perform asynchronous work like [GraphQL schema introspection] in order to build one of the middleware in your chain.

[koa cluster]: https://github.com/koajs/cluster
[graphql schema introspection]: https://graphql.org/learn/introspection/
Expand Down
16 changes: 13 additions & 3 deletions src/asyncMiddleware/asyncMiddleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,35 @@ describe('asyncMiddleware', () => {
...fields,
} as unknown) as Koa.Context);

const ctx: Koa.Context = makeCtx();
const next = jest.fn().mockRejectedValue(new Error('why are you here'));

it('should cache a successfully initialised inner middleware', async () => {
const innerMiddleware = jest.fn(() => (ctx.status = 201));
const ctx: Koa.Context = makeCtx();

const innerMiddleware = jest
.fn()
.mockImplementationOnce(() => (ctx.status = 201))
.mockImplementationOnce(() => (ctx.status = 202));
const createInnerMiddleware = jest.fn().mockResolvedValue(innerMiddleware);
const middleware = lazyLoad(createInnerMiddleware);

await expect(middleware(ctx, next)).resolves.toBe(201);
expect(ctx.status).toBe(201);

expect(createInnerMiddleware).toHaveBeenCalledTimes(1);
expect(innerMiddleware).toHaveBeenCalledTimes(1);

await expect(middleware(ctx, next)).resolves.toBe(201);
await expect(middleware(ctx, next)).resolves.toBe(202);
expect(ctx.status).toBe(202);

expect(createInnerMiddleware).toHaveBeenCalledTimes(1);
expect(innerMiddleware).toHaveBeenCalledTimes(2);
});

it('should retry inner middleware initialisation on failure', async () => {
const ctx: Koa.Context = makeCtx();
const err = new Error('middleware initialisation failed!');

const innerMiddleware = jest.fn(() => (ctx.status = 201));
const createInnerMiddleware = jest
.fn()
Expand All @@ -39,10 +47,12 @@ describe('asyncMiddleware', () => {
const middleware = lazyLoad(createInnerMiddleware);

await expect(middleware(ctx, next)).rejects.toThrow(err);
expect(ctx.status).not.toBe(201);

expect(createInnerMiddleware).toHaveBeenCalledTimes(1);

await expect(middleware(ctx, next)).resolves.toBe(201);
expect(ctx.status).toBe(201);

expect(createInnerMiddleware).toHaveBeenCalledTimes(2);
});
Expand Down
38 changes: 38 additions & 0 deletions src/errorMiddleware/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 🐨 Error Middleware 🐨

## Introduction

Catches errors thrown from downstream middleware, as specified here:

<https://github.com/koajs/koa/wiki/Error-Handling#catching-downstream-errors>

This tries to extract a numeric error `status` to serve as the response status,
and will set the error message as the response body for non-5xx statuses.
It works well with Koa's built-in `ctx.throw`.

This should be placed high up the middleware chain so that errors from lower middleware are handled.
It also serves to set the correct `ctx.status` for middleware that emit logs or metrics containing the response status.

## Usage

```typescript
import { ErrorMiddleware, RequestLoggingMiddleware } from 'seek-koala';

const requestLoggingMiddleware = RequestLogging.createMiddleware(
(ctx, fields, err) => {
const data = {
...fields,
err: err ?? ErrorMiddleware.thrown(ctx),
};

return ctx.status < 500
? rootLogger.info(data, 'request')
: rootLogger.error(data, 'request');
},
);

app
.use(requestLoggingMiddleware)
.use(metricsMiddleware)
.use(ErrorMiddleware.handle);
```
99 changes: 99 additions & 0 deletions src/errorMiddleware/errorMiddleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import Koa from 'koa';

import { agentFromApp } from '../testing/server';

import { handle, thrown } from './errorMiddleware';

describe('errorMiddleware', () => {
const mockPrev = jest.fn<unknown, [Koa.Context, Koa.Next]>();
const mockNext = jest.fn<unknown, [Koa.Context, Koa.Next]>();

const app = new Koa().use(mockPrev).use(handle).use(mockNext);

const agent = agentFromApp(app);

beforeAll(agent.setup);

beforeEach(() => mockPrev.mockImplementation((_, next) => next()));

afterEach(mockPrev.mockReset);
afterEach(mockNext.mockReset);

afterAll(agent.teardown);

it('passes through a returned 2xx', async () => {
mockNext.mockImplementation((ctx) => {
ctx.status = 200;
ctx.body = 'good';
});

await agent().get('/').expect(200, 'good');
});

it('passes through a returned 5xx', async () => {
mockNext.mockImplementation((ctx) => {
ctx.status = 500;
ctx.body = 'evil';
});

await agent().get('/').expect(500, 'evil');
});

it('provides thrown error to higher middleware', async () => {
expect.assertions(1);

mockPrev.mockImplementation(async (ctx, next) => {
await next();

expect(thrown(ctx)).toEqual(expect.any(Error));
});

mockNext.mockImplementation((ctx) => {
ctx.throw(400, 'bad');
});

await agent().get('/').expect(400, 'bad');
});

it('exposes a thrown 4xx error', async () => {
mockNext.mockImplementation((ctx) => {
ctx.throw(400, 'bad');
});

await agent().get('/').expect(400, 'bad');
});

it('redacts a thrown 5xx error', async () => {
mockNext.mockImplementation((ctx) => {
ctx.throw(500, 'bad');
});

await agent().get('/').expect(500, '');
});

it('handles directly-thrown error', async () => {
mockNext.mockImplementation(() => {
throw new Error('bad');
});

await agent().get('/').expect(500, '');
});

it('handles null error', async () => {
mockNext.mockImplementation(() => {
/* eslint-disable-next-line no-throw-literal */
throw null;
});

await agent().get('/').expect(500, '');
});

it('handles string error', async () => {
mockNext.mockImplementation(() => {
/* eslint-disable-next-line no-throw-literal */
throw 'bad';
});

await agent().get('/').expect(500, '');
});
});
57 changes: 57 additions & 0 deletions src/errorMiddleware/errorMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Context, Middleware } from 'koa';

/**
* @see {@link https://github.com/microsoft/TypeScript/issues/1863}
*/
const ERROR_STATE_KEY = (Symbol('seek-koala-error') as unknown) as string;

const isObject = (value: unknown): value is Record<PropertyKey, unknown> =>
typeof value === 'object' && value !== null;

/**
* Catches errors thrown from downstream middleware, as specified here:
*
* https://github.com/koajs/koa/wiki/Error-Handling#catching-downstream-errors
*
* This tries to extract a numeric error `status` to serve as the response
* status, and will set the error message as the response body for non-5xx
* statuses. It works well with Koa's built-in `ctx.throw`.
*
* This should be placed high up the middleware chain so that errors from lower
* middleware are handled. It also serves to set the correct `ctx.status` for
* middleware that emit logs or metrics containing the response status. For
* this reason, we recommend a sequence like:
*
* ```javascript
* app
* .use(requestLoggingMiddleware)
* .use(metricsMiddleware)
* .use(ErrorMiddleware.handle)
* .use(allTheRest);
* ```
*/
export const handle: Middleware = async (ctx, next) => {
try {
return (await next()) as unknown;
} catch (err) {
ctx.state[ERROR_STATE_KEY] = err as unknown;

if (!isObject(err) || typeof err.status !== 'number') {
ctx.status = 500;
ctx.body = '';
return;
}

ctx.status = err.status;
ctx.body = (err.status < 500 && err.message) || '';
}
};

/**
* Retrieve the error caught by `ErrorMiddleware.handle` from state.
*
* This is useful if you want to do something with the error higher up in the
* middleware chain.
*/
export const thrown = (ctx: Context): unknown =>
ctx.state[ERROR_STATE_KEY] as unknown;
23 changes: 23 additions & 0 deletions src/testing/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Server } from 'http';

import Koa from 'koa';
import request from 'supertest';

/**
* Create a new SuperTest agent from a Koa application.
*/
export const agentFromApp = <State, Context>(app: Koa<State, Context>) => {
let server: Server;
let agent: request.SuperTest<request.Test>;

const getAgent = () => agent;

const setup = async () => {
await new Promise((resolve) => (server = app.listen(undefined, resolve)));
agent = request.agent(server);
};

const teardown = () => new Promise((resolve) => server.close(resolve));

return Object.assign(getAgent, { setup, teardown });
};
Loading

0 comments on commit 9c90800

Please sign in to comment.