Skip to content

Commit

Permalink
feat(ErrorMiddleware): JsonErrorResponse (#12)
Browse files Browse the repository at this point in the history
This gives us built-in support for exposing JSON error response bodies
without per-app hacks. This is useful for returning richer structured
errors or e.g. implementing OAuth 2.0.

This makes a big deal about content negotiation with `text/plain`.
However, I really only added that as a motivation to keep the
`Error.message` sane for logging and debugging.
  • Loading branch information
etaoins authored Jun 17, 2020
1 parent 226a8b3 commit c319f9a
Show file tree
Hide file tree
Showing 3 changed files with 98 additions and 2 deletions.
18 changes: 18 additions & 0 deletions src/errorMiddleware/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,21 @@ app
.use(metricsMiddleware)
.use(ErrorMiddleware.handle);
```

## JsonResponse

`JsonResponse` is a custom error type used by `handle` to support JSON error response bodies.
Its constructor takes a `message` string and a `body` JavaScript value.
If the request accepts JSON then the error response will include the JSON encoded `body`.

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

ctx.throw(
400,
new ErrorMiddleware.JsonResponse('Bad input', {
message: 'Bad input',
invalidFields: { '/path/to/field': 'Value out of range' },
}),
);
```
37 changes: 36 additions & 1 deletion src/errorMiddleware/errorMiddleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Koa from 'koa';

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

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

describe('errorMiddleware', () => {
const mockPrev = jest.fn<unknown, [Koa.Context, Koa.Next]>();
Expand Down Expand Up @@ -71,6 +71,41 @@ describe('errorMiddleware', () => {
await agent().get('/').expect(500, '');
});

it('exposes a thrown 4xx `JsonResponse` as JSON by default', async () => {
mockNext.mockImplementation((ctx) => {
ctx.throw(400, new JsonResponse('Bad input', { bad: true }));
});

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

it('exposes a thrown 4xx `JsonResponse` as JSON based on `Accept`', async () => {
mockNext.mockImplementation((ctx) => {
ctx.throw(403, new JsonResponse('No access', { access: false }));
});

await agent()
.get('/')
.set('Accept', 'application/json')
.expect(403, { access: false });
});

it('exposes a thrown 4xx `JsonResponse` as text based on `Accept`', async () => {
mockNext.mockImplementation((ctx) => {
ctx.throw(410, new JsonResponse('Gone away', { gone: true }));
});

await agent().get('/').set('Accept', 'text/plain').expect(410, 'Gone away');
});

it('redact a thrown 5xx `JsonResponse`', async () => {
mockNext.mockImplementation((ctx) => {
ctx.throw(500, new JsonResponse('Bad input', { bad: true }));
});

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

it('handles directly-thrown error', async () => {
mockNext.mockImplementation(() => {
throw new Error('bad');
Expand Down
45 changes: 44 additions & 1 deletion src/errorMiddleware/errorMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,34 @@ 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;

/**
* Custom error type supporting JSON response bodies
*
* The `handle` middleware will return either `message` or `body` depending on
* the request's `Accept` header.
*
* ```javascript
* ctx.throw(400, new JsonResponse('Invalid input', { fieldName: '/foo' }));
* ```
*/
export class JsonResponse extends Error {
/**
* Creates a new `JsonResponse`
*
* This must be passed to `ctx.throw` instead of being thrown directly.
*
* @param message - Plain text message used for requests preferring
* `text/plain`. This is also used as the `Error` superclass
* message.
*
* @param body - JavaScript value used for requests accepting
* `application/json`. This is encoded as JSON in the response.
*/
constructor(message: string, public body: unknown) {
super(message);
}
}

/**
* Catches errors thrown from downstream middleware, as specified here:
*
Expand All @@ -17,6 +45,10 @@ const isObject = (value: unknown): value is Record<PropertyKey, unknown> =>
* 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 includes a specific check for the `JsonResponse` class to support
* including a JSON response body. If the request accepts `application/json`
* the error's `body` will be returned, otherwise its plain text `message`.
*
* 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
Expand All @@ -43,7 +75,18 @@ export const handle: Middleware = async (ctx, next) => {
}

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

if (
expose &&
err instanceof JsonResponse &&
// Prefer JSON ourselves if the request has no preference
ctx.accepts(['application/json', 'text/plain']) === 'application/json'
) {
ctx.body = err.body;
} else {
ctx.body = (expose && err.message) || '';
}
}
};

Expand Down

0 comments on commit c319f9a

Please sign in to comment.