diff --git a/src/errorMiddleware/README.md b/src/errorMiddleware/README.md index 326d93c..85e3eb3 100644 --- a/src/errorMiddleware/README.md +++ b/src/errorMiddleware/README.md @@ -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' }, + }), +); +``` diff --git a/src/errorMiddleware/errorMiddleware.test.ts b/src/errorMiddleware/errorMiddleware.test.ts index ade47b6..5659018 100644 --- a/src/errorMiddleware/errorMiddleware.test.ts +++ b/src/errorMiddleware/errorMiddleware.test.ts @@ -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(); @@ -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'); diff --git a/src/errorMiddleware/errorMiddleware.ts b/src/errorMiddleware/errorMiddleware.ts index e3ed99a..cd43928 100644 --- a/src/errorMiddleware/errorMiddleware.ts +++ b/src/errorMiddleware/errorMiddleware.ts @@ -8,6 +8,34 @@ const ERROR_STATE_KEY = (Symbol('seek-koala-error') as unknown) as string; const isObject = (value: unknown): value is Record => 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: * @@ -17,6 +45,10 @@ const isObject = (value: unknown): value is Record => * 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 @@ -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) || ''; + } } };