diff --git a/README.md b/README.md index 37ff3f3..f02146c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package.json b/package.json index c9af237..3187f0f 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/asyncMiddleware/README.md b/src/asyncMiddleware/README.md index 66bbde4..3e150e3 100644 --- a/src/asyncMiddleware/README.md +++ b/src/asyncMiddleware/README.md @@ -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/ diff --git a/src/asyncMiddleware/asyncMiddleware.test.ts b/src/asyncMiddleware/asyncMiddleware.test.ts index f8f8df9..59080e6 100644 --- a/src/asyncMiddleware/asyncMiddleware.test.ts +++ b/src/asyncMiddleware/asyncMiddleware.test.ts @@ -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() @@ -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); }); diff --git a/src/errorMiddleware/README.md b/src/errorMiddleware/README.md new file mode 100644 index 0000000..326d93c --- /dev/null +++ b/src/errorMiddleware/README.md @@ -0,0 +1,38 @@ +# 🐨 Error Middleware 🐨 + +## Introduction + +Catches errors thrown from downstream middleware, as specified here: + + + +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); +``` diff --git a/src/errorMiddleware/errorMiddleware.test.ts b/src/errorMiddleware/errorMiddleware.test.ts new file mode 100644 index 0000000..ade47b6 --- /dev/null +++ b/src/errorMiddleware/errorMiddleware.test.ts @@ -0,0 +1,99 @@ +import Koa from 'koa'; + +import { agentFromApp } from '../testing/server'; + +import { handle, thrown } from './errorMiddleware'; + +describe('errorMiddleware', () => { + const mockPrev = jest.fn(); + const mockNext = jest.fn(); + + 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, ''); + }); +}); diff --git a/src/errorMiddleware/errorMiddleware.ts b/src/errorMiddleware/errorMiddleware.ts new file mode 100644 index 0000000..e3ed99a --- /dev/null +++ b/src/errorMiddleware/errorMiddleware.ts @@ -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 => + 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; diff --git a/src/testing/server.ts b/src/testing/server.ts new file mode 100644 index 0000000..89801dd --- /dev/null +++ b/src/testing/server.ts @@ -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 = (app: Koa) => { + let server: Server; + let agent: request.SuperTest; + + 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 }); +}; diff --git a/yarn.lock b/yarn.lock index b1841b4..c53033f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -778,6 +778,11 @@ resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.3.tgz#0aa116701955c2faa0717fc69cd1596095e49d96" integrity sha512-P1bffQfhD3O4LW0ioENXUhZ9OIa0Zn+P7M+pWgkCKaT53wVLSq0mrKksCID/FGHpFhRSxRGhgrQmfhRuzwtKdg== +"@types/cookiejar@*": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.1.tgz#90b68446364baf9efd8e8349bb36bd3852b75b80" + integrity sha512-aRnpPa7ysx3aNW60hTiCtLHlQaIFsXFCgQlpakNgDNVFzbtusSY8PwjAQgRWfSk0ekNoBjO51eQRB6upA9uuyw== + "@types/cookies@*": version "0.7.4" resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.4.tgz#26dedf791701abc0e36b5b79a5722f40e455f87b" @@ -955,6 +960,21 @@ resolved "https://registry.yarnpkg.com/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz#9aa30c04db212a9a0649d6ae6fd50accc40748a1" integrity sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ== +"@types/superagent@*": + version "4.1.7" + resolved "https://registry.yarnpkg.com/@types/superagent/-/superagent-4.1.7.tgz#a7d92d98c490ee0f802a127fdf149b9a114f77a5" + integrity sha512-JSwNPgRYjIC4pIeOqLwWwfGj6iP1n5NE6kNBEbGx2V8H78xCPwx7QpNp9plaI30+W3cFEzJO7BIIsXE+dbtaGg== + dependencies: + "@types/cookiejar" "*" + "@types/node" "*" + +"@types/supertest@2.0.9": + version "2.0.9" + resolved "https://registry.yarnpkg.com/@types/supertest/-/supertest-2.0.9.tgz#049bddbcb0ee0d60a9b836ccc977d813a1c32325" + integrity sha512-0BTpWWWAO1+uXaP/oA0KW1eOZv4hc0knhrWowV06Gwwz3kqQxNO98fUFM2e15T+PdPRmOouNFrYvaBgdojPJ3g== + dependencies: + "@types/superagent" "*" + "@types/uuid@8.0.0": version "8.0.0" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.0.0.tgz#165aae4819ad2174a17476dbe66feebd549556c0" @@ -1984,7 +2004,7 @@ compare-func@^1.3.1: array-ify "^1.0.0" dot-prop "^3.0.0" -component-emitter@^1.2.1: +component-emitter@^1.2.0, component-emitter@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== @@ -2113,6 +2133,11 @@ convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: dependencies: safe-buffer "~5.1.1" +cookiejar@^2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.2.tgz#dd8a235530752f988f9a0844f3fc589e3111125c" + integrity sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA== + cookies@~0.8.0: version "0.8.0" resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90" @@ -3024,7 +3049,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2: assign-symbols "^1.0.0" is-extendable "^1.0.1" -extend@~3.0.2: +extend@^3.0.0, extend@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== @@ -3249,6 +3274,15 @@ forever-agent@~0.6.1: resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= +form-data@^2.3.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4" + integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + form-data@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" @@ -3258,6 +3292,11 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" +formidable@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.2.tgz#bf69aea2972982675f00865342b982986f6b8dd9" + integrity sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q== + fragment-cache@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" @@ -5481,6 +5520,11 @@ merge2@^1.3.0: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== +methods@^1.1.1, methods@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= + micromatch@4.x, micromatch@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" @@ -5520,6 +5564,11 @@ mime-types@^2.1.12, mime-types@^2.1.18, mime-types@~2.1.19, mime-types@~2.1.24: dependencies: mime-db "1.44.0" +mime@^1.4.1: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + mime@^2.4.3: version "2.4.6" resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.6.tgz#e5b407c90db442f2beb5b162373d07b69affa4d1" @@ -6773,6 +6822,11 @@ qrcode-terminal@^0.12.0: resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz#bb5b699ef7f9f0505092a3748be4464fe71b5819" integrity sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ== +qs@^6.5.1: + version "6.9.4" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.4.tgz#9090b290d1f91728d3c22e54843ca44aea5ab687" + integrity sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ== + qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" @@ -6923,7 +6977,7 @@ read@1, read@~1.0.1, read@~1.0.7: dependencies: mute-stream "~0.0.4" -"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.6, readable-stream@~2.3.6: +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== @@ -7950,6 +8004,30 @@ strip-json-comments@^3.1.0: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.0.tgz#7638d31422129ecf4457440009fba03f9f9ac180" integrity sha512-e6/d0eBu7gHtdCqFt0xJr642LdToM5/cN4Qb9DbHjVx1CP5RyeM+zH7pbecEmDv/lBqb0QH+6Uqq75rxFPkM0w== +superagent@^3.8.3: + version "3.8.3" + resolved "https://registry.yarnpkg.com/superagent/-/superagent-3.8.3.tgz#460ea0dbdb7d5b11bc4f78deba565f86a178e128" + integrity sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA== + dependencies: + component-emitter "^1.2.0" + cookiejar "^2.1.0" + debug "^3.1.0" + extend "^3.0.0" + form-data "^2.3.1" + formidable "^1.2.0" + methods "^1.1.1" + mime "^1.4.1" + qs "^6.5.1" + readable-stream "^2.3.5" + +supertest@4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/supertest/-/supertest-4.0.2.tgz#c2234dbdd6dc79b6f15b99c8d6577b90e4ce3f36" + integrity sha512-1BAbvrOZsGA3YTCWqbmh14L0YEq0EGICX/nBnfkfVJn7SrxQV1I3pMYjSzG9y/7ZU2V9dWqyqk2POwxlb09duQ== + dependencies: + methods "^1.1.2" + superagent "^3.8.3" + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"