Skip to content

Commit

Permalink
Add shared error handler (#21)
Browse files Browse the repository at this point in the history
* Add shared error handler

* Cover remaining statements with tests

* Handle optional chaining branches in tests

* Remove MockedResponse in favor of real Response

* Restore missing test cases

* Handle remaining branch + rename test cases

* Documentation
  • Loading branch information
neg4n authored Dec 2, 2023
1 parent 53748c6 commit 95c81d6
Show file tree
Hide file tree
Showing 8 changed files with 324 additions and 40 deletions.
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,55 @@ const { GET } = compose({
export { GET };
```

## Error handling

Handling errors both in middleware and in the main handler is as simple as providing `sharedErrorHandler` to the `compose` function's second parameter _(a.k.a compose settings)_. Main goal of the shared error handler is to provide clear and easy way to e.g. send the error metadata to Sentry or other error tracking service.

By default, shared error handler looks like this:

```ts
sharedErrorHandler: {
handler: undefined;
// ^^^^ This is the handler function. By default there is no handler, so the error is being just thrown.
includeRouteHandler: false;
// ^^^^^^^^^^^^^^^^ This toggles whether the route handler itself should be included in a error handled area.
// By default only middlewares are being caught by the sharedErrorHandler
}
```

... and some usage example:

```ts
// [...]
function errorMiddleware() {
throw new Error("foo");
}

const { GET } = compose(
{
GET: [
[errorMiddleware],
() => {
// Unreachable code due to errorMiddleware throwing an error and halting the chain
return new Response(JSON.stringify({ foo: "bar" }));
},
],
},
{
sharedErrorHandler: {
handler: (_method, error) => {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
});
},
},
}
);
// [...]
```

will return `{"error": "foo"}` along with `500` status code instead of throwing an error.

## Theory and caveats

1. Unfortunately there is no way to dynamically export named ESModules _(or at least I did not find a way)_ so you have to use `export { GET, POST }` syntax instead of something like `export compose(...)` if you're composing GET and POST methods :(
Expand Down Expand Up @@ -113,4 +162,4 @@ The project is licensed under The MIT License. Thanks for all the contributions!
[next-api-route-handlers]: https://nextjs.org/docs/app/building-your-application/routing/route-handlers
[next-app-router-intro]: https://nextjs.org/docs/app/building-your-application/routing#the-app-router
[next-app-router]: https://nextjs.org/docs/app
[next-pages-router]: https://nextjs.org/docs/pages
[next-pages-router]: https://nextjs.org/docs/pages
4 changes: 2 additions & 2 deletions packages/next-api-compose/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
collectCoverageFrom: ['src/*'],
coverageReporters: ['html', 'json', 'lcov'],
setupFilesAfterEnv: ['./jest.setup.js'],
coverageProvider: 'v8'
coverageProvider: 'v8',
}
9 changes: 3 additions & 6 deletions packages/next-api-compose/jest.setup.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
global.Response = class MockedResponse {
constructor(body, status) {
this.body = body
this.status = status
}
}
const { Response } = require('undici')

global.Response = Response
3 changes: 2 additions & 1 deletion packages/next-api-compose/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@
"ts-toolbelt": "^9.6.0",
"tsup": "^7.2.0",
"type-fest": "^4.2.0",
"typescript": "^5.1.6"
"typescript": "^5.1.6",
"undici": "^5.28.2"
},
"prettier": {
"printWidth": 90,
Expand Down
74 changes: 72 additions & 2 deletions packages/next-api-compose/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Promisable } from 'type-fest'
import type { Promisable, PartialDeep } from 'type-fest'
import type { NextApiRequest, NextApiResponse } from 'next'
import type { NextResponse } from 'next/server'

Expand All @@ -16,6 +16,22 @@ type NextApiMethodHandler = (
request: NextApiRequest
) => Promisable<NextApiResponse> | Promisable<Response>

type ComposeSettings = PartialDeep<{
sharedErrorHandler: {
/**
* @param {NextApiRouteMethod} method HTTP method of the composed route handler that failed.
* @param {Error} error Error that was thrown by the middleware or the handler.
*/
handler: (method: NextApiRouteMethod, error: Error) => Promisable<Response | void>
/**
* Whether to include the route handler in the error handled area.
*
* By default only middlewares are included (being caught by the sharedErrorHandler).
*/
includeRouteHandler: boolean
}
}>

type ComposeParameters<
Methods extends NextApiRouteMethod,
MiddlewareChain extends Array<
Expand All @@ -39,6 +55,7 @@ type ComposeParameters<
* Function that allows to define complex API structure in Next.js App router's Route Handlers.
*
* @param {ComposeParameters} parameters Middlewares array **(order matters)** or options object with previously mentioned middlewares array as `middlewareChain` property and error handler shared by every middleware in the array as `sharedErrorHandler` property.
* @param {ComposeSettings} composeSettings Settings object that allows to configure the compose function.
* @returns Method handlers with applied middleware.
*/
export function compose<
Expand All @@ -51,7 +68,22 @@ export function compose<
| Promisable<Response | undefined>
| Promisable<void | undefined>
>
>(parameters: ComposeParameters<UsedMethods, MiddlewareChain>) {
>(
parameters: ComposeParameters<UsedMethods, MiddlewareChain>,
composeSettings?: ComposeSettings
) {
const defaultComposeSettings = {
sharedErrorHandler: {
handler: undefined,
includeRouteHandler: false
}
}

const mergedComposeSettings = {
...defaultComposeSettings,
...composeSettings
}

const modified = Object.entries(parameters).map(
([method, composeForMethodData]: [
UsedMethods,
Expand All @@ -66,12 +98,50 @@ export function compose<
[method]: async (request: any) => {
if (typeof composeForMethodData === 'function') {
const handler = composeForMethodData
if (
mergedComposeSettings.sharedErrorHandler.includeRouteHandler &&
mergedComposeSettings.sharedErrorHandler.handler != null
) {
try {
return await handler(request)
} catch (error) {
const composeSharedErrorHandlerResult =
await mergedComposeSettings.sharedErrorHandler.handler(method, error)

if (
composeSharedErrorHandlerResult != null &&
composeSharedErrorHandlerResult instanceof Response
) {
return composeSharedErrorHandlerResult
}
}
}

return await handler(request)
}

const [middlewareChain, handler] = composeForMethodData

for (const middleware of middlewareChain) {
if (mergedComposeSettings.sharedErrorHandler.handler != null) {
try {
const abortedMiddleware = await middleware(request)

if (abortedMiddleware != null && abortedMiddleware instanceof Response)
return abortedMiddleware
} catch (error) {
const composeSharedErrorHandlerResult =
await mergedComposeSettings.sharedErrorHandler.handler(method, error)

if (
composeSharedErrorHandlerResult != null &&
composeSharedErrorHandlerResult instanceof Response
) {
return composeSharedErrorHandlerResult
}
}
}

const abortedMiddleware = await middleware(request)

if (abortedMiddleware != null && abortedMiddleware instanceof Response)
Expand Down
Loading

0 comments on commit 95c81d6

Please sign in to comment.