diff --git a/docs/openapi-fetch/api.md b/docs/openapi-fetch/api.md index ce19e255f..ac15a0ada 100644 --- a/docs/openapi-fetch/api.md +++ b/docs/openapi-fetch/api.md @@ -207,7 +207,7 @@ openapi-fetch supports path serialization as [outlined in the 3.1 spec](https:// ## Middleware -Middleware is an object with `onRequest()` and `onResponse()` callbacks that can observe and modify requests and responses. +Middleware is an object with `onRequest()`, `onResponse()` and `onError()` callbacks that can observe and modify requests, responses and errors thrown by `fetch`. ```ts import createClient from "openapi-fetch"; @@ -224,6 +224,12 @@ const myMiddleware: Middleware = { // change status of response return new Response(body, { ...resOptions, status: 200 }); }, + async onError({ error }) { + // wrap errors thrown by fetch + onError({ error }) { + return new Error("Oops, fetch failed", { cause: error }); + }, + }, }; const client = createClient({ baseUrl: "https://myapi.dev/v1/" }); @@ -238,21 +244,33 @@ client.use(myMiddleware); Each middleware callback receives the following `options` object with the following: -| Name | Type | Description | -| :----------- | :-------------- | :------------------------------------------------------------------------------------------ | -| `request` | `Request` | The current `Request` to be sent to the endpoint. | -| `response` | `Response` | The `Response` returned from the endpoint (note: this will be `undefined` for `onRequest`). | -| `schemaPath` | `string` | The original OpenAPI path called (e.g. `/users/{user_id}`) | -| `params` | `Object` | The original `params` object passed to `GET()` / `POST()` / etc. | -| `id` | `string` | A random, unique ID for this request. | -| `options` | `ClientOptions` | The readonly options passed to `createClient()`. | +| Name | Type | Description | +| :----------- | :-------------- | :----------------------------------------------------------------| +| `request` | `Request` | The current `Request` to be sent to the endpoint. | +| `schemaPath` | `string` | The original OpenAPI path called (e.g. `/users/{user_id}`) | +| `params` | `Object` | The original `params` object passed to `GET()` / `POST()` / etc. | +| `id` | `string` | A random, unique ID for this request. | +| `options` | `ClientOptions` | The readonly options passed to `createClient()`. | + +In addition to these, the `onResponse` callback receives an additional `response` property: + +| Name | Type | Description | +| :----------- | :-------------- | :------------------------------------------| +| `response` | `Response` | The `Response` returned from the endpoint. | + +And the `onError` callback receives an additional `error` property: + +| Name | Type | Description | +| :----------- | :-------------- | :------------------------------------------------------------------------| +| `error` | `unknown` | The error thrown by `fetch`, probably a `TypeError` or a `DOMException`. | #### Response Each middleware callback can return: - **onRequest**: Either a `Request` to modify the request, or `undefined` to leave it untouched (skip) -- **onResponse** Either a `Response` to modify the response, or `undefined` to leave it untouched (skip) +- **onResponse**: Either a `Response` to modify the response, or `undefined` to leave it untouched (skip) +- **onError**: Either an `Error` to modify the error that is thrown, a `Response` which means that the `fetch` call will proceed as successful, or `undefined` to leave the error untouched (skip) ### Ejecting middleware diff --git a/docs/openapi-fetch/middleware-auth.md b/docs/openapi-fetch/middleware-auth.md index aa82567a5..3bd287a3a 100644 --- a/docs/openapi-fetch/middleware-auth.md +++ b/docs/openapi-fetch/middleware-auth.md @@ -4,11 +4,11 @@ title: Middleware & Auth # Middleware & Auth -Middleware allows you to modify either the request, response, or both for all fetches. One of the most common usecases is authentication, but can also be used for logging/telemetry, throwing errors, or handling specific edge cases. +Middleware allows you to modify either the request, response, or both for all fetches as well as handling errors thrown by `fetch`. One of the most common usecases is authentication, but can also be used for logging/telemetry, throwing errors, or handling specific edge cases. ## Middleware -Each middleware can provide `onRequest()` and `onResponse()` callbacks, which can observe and/or mutate requests and responses. +Each middleware can provide `onRequest()`, `onResponse()` and `onError` callbacks, which can observe and/or mutate requests, responses and `fetch` errors. ::: code-group @@ -27,6 +27,12 @@ const myMiddleware: Middleware = { // change status of response return new Response(body, { ...resOptions, status: 200 }); }, + async onError({ error }) { + // wrap errors thrown by fetch + onError({ error }) { + return new Error("Oops, fetch failed", { cause: error }); + }, + }, }; const client = createClient({ baseUrl: "https://myapi.dev/v1/" }); @@ -71,6 +77,44 @@ onResponse({ response }) { } ``` +### Error Handling + +The `onError` callback allows you to handle errors thrown by `fetch`. Common errors are `TypeError`s which can occur when there is a network or CORS error or `DOMException`s when the request is aborted using an `AbortController`. + +Depending on the return value, `onError` can handle errors in three different ways: + +**Return nothing** which means that the error will still be thrown. This is useful for logging. + +```ts +onError({ error }) { + console.error(error); + return; +}, +``` + +**Return another instance of `Error`** which is thrown instead of the original error. + +```ts +onError({ error }) { + return new Error("Oops", { cause: error }); +}, +``` + +**Return a new instance of `Response`** which means that the `fetch` call will proceed as successful. + +```ts +onError({ error }) { + return Response.json({ message: 'nothing to see' }); +}, +``` + +::: tip + +`onError` _does not_ handle error responses with `4xx` or `5xx` HTTP status codes, since these are considered "successful" responses but with a bad status code. In these cases you need to check the response's status property or `ok()` method via the `onResponse` callback. + +::: + + ### Ejecting middleware To remove middleware, call `client.eject(middleware)`: diff --git a/packages/openapi-fetch/src/index.d.ts b/packages/openapi-fetch/src/index.d.ts index a7b26a1be..6686b302d 100644 --- a/packages/openapi-fetch/src/index.d.ts +++ b/packages/openapi-fetch/src/index.d.ts @@ -6,8 +6,8 @@ import type { MediaType, OperationRequestBodyContent, PathsWithMethod, - ResponseObjectMap, RequiredKeysOf, + ResponseObjectMap, SuccessResponse, } from "openapi-typescript-helpers"; @@ -152,15 +152,25 @@ type MiddlewareOnRequest = ( type MiddlewareOnResponse = ( options: MiddlewareCallbackParams & { response: Response }, ) => void | Response | undefined | Promise; +type MiddlewareOnError = ( + options: MiddlewareCallbackParams & { error: unknown }, +) => void | Response | Error | Promise; export type Middleware = | { onRequest: MiddlewareOnRequest; onResponse?: MiddlewareOnResponse; + onError?: MiddlewareOnError; } | { onRequest?: MiddlewareOnRequest; onResponse: MiddlewareOnResponse; + onError?: MiddlewareOnError; + } + | { + onRequest?: MiddlewareOnRequest; + onResponse?: MiddlewareOnResponse; + onError: MiddlewareOnError; }; /** This type helper makes the 2nd function param required if params/requestBody are required; otherwise, optional */ diff --git a/packages/openapi-fetch/src/index.js b/packages/openapi-fetch/src/index.js index 1b8502c6f..af3bd4759 100644 --- a/packages/openapi-fetch/src/index.js +++ b/packages/openapi-fetch/src/index.js @@ -124,7 +124,49 @@ export default function createClient(clientOptions) { } // fetch! - let response = await fetch(request); + let response; + try { + response = await fetch(request); + } catch (error) { + let errorAfterMiddleware = error; + // middleware (error) + // execute in reverse-array order (first priority gets last transform) + if (middlewares.length) { + for (let i = middlewares.length - 1; i >= 0; i--) { + const m = middlewares[i]; + if (m && typeof m === "object" && typeof m.onError === "function") { + const result = await m.onError({ + request, + error: errorAfterMiddleware, + schemaPath, + params, + options, + id, + }); + if (result) { + // if error is handled by returning a response, skip remaining middleware + if (result instanceof Response) { + errorAfterMiddleware = undefined; + response = result; + break; + } + + if (result instanceof Error) { + errorAfterMiddleware = result; + continue; + } + + throw new Error("onError: must return new Response() or instance of Error"); + } + } + } + } + + // rethrow error if not handled by middleware + if (errorAfterMiddleware) { + throw errorAfterMiddleware; + } + } // middleware (response) // execute in reverse-array order (first priority gets last transform) @@ -213,8 +255,8 @@ export default function createClient(clientOptions) { if (!m) { continue; } - if (typeof m !== "object" || !("onRequest" in m || "onResponse" in m)) { - throw new Error("Middleware must be an object with one of `onRequest()` or `onResponse()`"); + if (typeof m !== "object" || !("onRequest" in m || "onResponse" in m || "onError" in m)) { + throw new Error("Middleware must be an object with one of `onRequest()`, `onResponse() or `onError()`"); } middlewares.push(m); } diff --git a/packages/openapi-fetch/test/middleware/middleware.test.ts b/packages/openapi-fetch/test/middleware/middleware.test.ts index a13ee8c57..9ccc23af4 100644 --- a/packages/openapi-fetch/test/middleware/middleware.test.ts +++ b/packages/openapi-fetch/test/middleware/middleware.test.ts @@ -1,6 +1,6 @@ -import { expect, test, expectTypeOf, assertType } from "vitest"; -import { createObservedClient } from "../helpers.js"; +import { assertType, expect, expectTypeOf, test } from "vitest"; import type { Middleware, MiddlewareCallbackParams } from "../../src/index.js"; +import { createObservedClient } from "../helpers.js"; import type { paths } from "./schemas/middleware.js"; test("receives a UUID per-request", async () => { @@ -96,6 +96,62 @@ test("can modify response", async () => { expect(response.headers.get("middleware")).toBe("value"); }); +test("returns original errors if nothing is returned", async () => { + const actualError = new Error(); + const client = createObservedClient({}, async (req) => { + throw actualError; + }); + client.use({ + onError({ error }) { + expect(error).toBe(actualError); + return; + }, + }); + + try { + await client.GET("/posts/{id}", { params: { path: { id: 123 } } }); + } catch (thrownError) { + expect(thrownError).toBe(actualError); + } +}); + +test("can modify errors", async () => { + const actualError = new Error(); + const modifiedError = new Error(); + const client = createObservedClient({}, async (req) => { + throw actualError; + }); + client.use({ + onError() { + return modifiedError; + }, + }); + + try { + await client.GET("/posts/{id}", { params: { path: { id: 123 } } }); + } catch (thrownError) { + expect(thrownError).toBe(modifiedError); + } +}); + +test("can catch errors and return a response instead", async () => { + const actualError = new Error(); + const customResponse = Response.json({}); + const client = createObservedClient({}, async (req) => { + throw actualError; + }); + client.use({ + onError({ error }) { + expect(error).toBe(actualError); + return customResponse; + }, + }); + + const { response } = await client.GET("/posts/{id}", { params: { path: { id: 123 } } }); + + expect(response).toBe(customResponse); +}); + test("executes in expected order", async () => { let actualRequest = new Request("https://nottherealurl.fake"); const client = createObservedClient({}, async (req) => { @@ -153,6 +209,30 @@ test("executes in expected order", async () => { expect(response.headers.get("step")).toBe("A"); }); +test("executes error handlers in expected order", async () => { + const actualError = new Error(); + const modifiedError = new Error(); + const customResponse = Response.json({}); + const client = createObservedClient({}, async (req) => { + throw actualError; + }); + client.use({ + onError({ error }) { + expect(error).toBe(modifiedError); + return customResponse; + }, + }); + client.use({ + onError() { + return modifiedError; + }, + }); + + const { response } = await client.GET("/posts/{id}", { params: { path: { id: 123 } } }); + + expect(response).toBe(customResponse); +}); + test("receives correct options", async () => { let requestBaseUrl = "";