Skip to content

Commit 35c576c

Browse files
feat(openapi-fetch): add onError handler to middleware (#1974)
1 parent 30070e5 commit 35c576c

File tree

6 files changed

+217
-18
lines changed

6 files changed

+217
-18
lines changed

.changeset/heavy-kangaroos-beam.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-fetch": patch
3+
---
4+
5+
add onError handler to middleware

docs/openapi-fetch/api.md

+28-10
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ openapi-fetch supports path serialization as [outlined in the 3.1 spec](https://
207207

208208
## Middleware
209209

210-
Middleware is an object with `onRequest()` and `onResponse()` callbacks that can observe and modify requests and responses.
210+
Middleware is an object with `onRequest()`, `onResponse()` and `onError()` callbacks that can observe and modify requests, responses and errors.
211211

212212
```ts
213213
import createClient from "openapi-fetch";
@@ -224,6 +224,12 @@ const myMiddleware: Middleware = {
224224
// change status of response
225225
return new Response(body, { ...resOptions, status: 200 });
226226
},
227+
async onError({ error }) {
228+
// wrap errors thrown by fetch
229+
onError({ error }) {
230+
return new Error("Oops, fetch failed", { cause: error });
231+
},
232+
},
227233
};
228234

229235
const client = createClient<paths>({ baseUrl: "https://myapi.dev/v1/" });
@@ -238,21 +244,33 @@ client.use(myMiddleware);
238244

239245
Each middleware callback receives the following `options` object with the following:
240246

241-
| Name | Type | Description |
242-
| :----------- | :-------------- | :------------------------------------------------------------------------------------------ |
243-
| `request` | `Request` | The current `Request` to be sent to the endpoint. |
244-
| `response` | `Response` | The `Response` returned from the endpoint (note: this will be `undefined` for `onRequest`). |
245-
| `schemaPath` | `string` | The original OpenAPI path called (e.g. `/users/{user_id}`) |
246-
| `params` | `Object` | The original `params` object passed to `GET()` / `POST()` / etc. |
247-
| `id` | `string` | A random, unique ID for this request. |
248-
| `options` | `ClientOptions` | The readonly options passed to `createClient()`. |
247+
| Name | Type | Description |
248+
| :----------- | :-------------- | :----------------------------------------------------------------|
249+
| `request` | `Request` | The current `Request` to be sent to the endpoint. |
250+
| `schemaPath` | `string` | The original OpenAPI path called (e.g. `/users/{user_id}`) |
251+
| `params` | `Object` | The original `params` object passed to `GET()` / `POST()` / etc. |
252+
| `id` | `string` | A random, unique ID for this request. |
253+
| `options` | `ClientOptions` | The readonly options passed to `createClient()`. |
254+
255+
In addition to these, the `onResponse` callback receives an additional `response` property:
256+
257+
| Name | Type | Description |
258+
| :----------- | :-------------- | :------------------------------------------|
259+
| `response` | `Response` | The `Response` returned from the endpoint. |
260+
261+
And the `onError` callback receives an additional `error` property:
262+
263+
| Name | Type | Description |
264+
| :----------- | :-------------- | :------------------------------------------------------------------------|
265+
| `error` | `unknown` | The error thrown by `fetch`, probably a `TypeError` or a `DOMException`. |
249266

250267
#### Response
251268

252269
Each middleware callback can return:
253270

254271
- **onRequest**: Either a `Request` to modify the request, or `undefined` to leave it untouched (skip)
255-
- **onResponse** Either a `Response` to modify the response, or `undefined` to leave it untouched (skip)
272+
- **onResponse**: Either a `Response` to modify the response, or `undefined` to leave it untouched (skip)
273+
- **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)
256274

257275
### Ejecting middleware
258276

docs/openapi-fetch/middleware-auth.md

+46-2
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ title: Middleware & Auth
44

55
# Middleware & Auth
66

7-
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.
7+
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.
88

99
## Middleware
1010

11-
Each middleware can provide `onRequest()` and `onResponse()` callbacks, which can observe and/or mutate requests and responses.
11+
Each middleware can provide `onRequest()`, `onResponse()` and `onError` callbacks, which can observe and/or mutate requests, responses and `fetch` errors.
1212

1313
::: code-group
1414

@@ -27,6 +27,12 @@ const myMiddleware: Middleware = {
2727
// change status of response
2828
return new Response(body, { ...resOptions, status: 200 });
2929
},
30+
async onError({ error }) {
31+
// wrap errors thrown by fetch
32+
onError({ error }) {
33+
return new Error("Oops, fetch failed", { cause: error });
34+
},
35+
},
3036
};
3137

3238
const client = createClient<paths>({ baseUrl: "https://myapi.dev/v1/" });
@@ -71,6 +77,44 @@ onResponse({ response }) {
7177
}
7278
```
7379

80+
### Error Handling
81+
82+
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`.
83+
84+
Depending on the return value, `onError` can handle errors in three different ways:
85+
86+
**Return nothing** which means that the error will still be thrown. This is useful for logging.
87+
88+
```ts
89+
onError({ error }) {
90+
console.error(error);
91+
return;
92+
},
93+
```
94+
95+
**Return another instance of `Error`** which is thrown instead of the original error.
96+
97+
```ts
98+
onError({ error }) {
99+
return new Error("Oops", { cause: error });
100+
},
101+
```
102+
103+
**Return a new instance of `Response`** which means that the `fetch` call will proceed as successful.
104+
105+
```ts
106+
onError({ error }) {
107+
return Response.json({ message: 'nothing to see' });
108+
},
109+
```
110+
111+
::: tip
112+
113+
`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.
114+
115+
:::
116+
117+
74118
### Ejecting middleware
75119

76120
To remove middleware, call `client.eject(middleware)`:

packages/openapi-fetch/src/index.d.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import type {
66
MediaType,
77
OperationRequestBodyContent,
88
PathsWithMethod,
9-
ResponseObjectMap,
109
RequiredKeysOf,
10+
ResponseObjectMap,
1111
SuccessResponse,
1212
} from "openapi-typescript-helpers";
1313

@@ -152,15 +152,25 @@ type MiddlewareOnRequest = (
152152
type MiddlewareOnResponse = (
153153
options: MiddlewareCallbackParams & { response: Response },
154154
) => void | Response | undefined | Promise<Response | undefined | void>;
155+
type MiddlewareOnError = (
156+
options: MiddlewareCallbackParams & { error: unknown },
157+
) => void | Response | Error | Promise<void | Response | Error>;
155158

156159
export type Middleware =
157160
| {
158161
onRequest: MiddlewareOnRequest;
159162
onResponse?: MiddlewareOnResponse;
163+
onError?: MiddlewareOnError;
160164
}
161165
| {
162166
onRequest?: MiddlewareOnRequest;
163167
onResponse: MiddlewareOnResponse;
168+
onError?: MiddlewareOnError;
169+
}
170+
| {
171+
onRequest?: MiddlewareOnRequest;
172+
onResponse?: MiddlewareOnResponse;
173+
onError: MiddlewareOnError;
164174
};
165175

166176
/** This type helper makes the 2nd function param required if params/requestBody are required; otherwise, optional */

packages/openapi-fetch/src/index.js

+45-3
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,49 @@ export default function createClient(clientOptions) {
124124
}
125125

126126
// fetch!
127-
let response = await fetch(request);
127+
let response;
128+
try {
129+
response = await fetch(request);
130+
} catch (error) {
131+
let errorAfterMiddleware = error;
132+
// middleware (error)
133+
// execute in reverse-array order (first priority gets last transform)
134+
if (middlewares.length) {
135+
for (let i = middlewares.length - 1; i >= 0; i--) {
136+
const m = middlewares[i];
137+
if (m && typeof m === "object" && typeof m.onError === "function") {
138+
const result = await m.onError({
139+
request,
140+
error: errorAfterMiddleware,
141+
schemaPath,
142+
params,
143+
options,
144+
id,
145+
});
146+
if (result) {
147+
// if error is handled by returning a response, skip remaining middleware
148+
if (result instanceof Response) {
149+
errorAfterMiddleware = undefined;
150+
response = result;
151+
break;
152+
}
153+
154+
if (result instanceof Error) {
155+
errorAfterMiddleware = result;
156+
continue;
157+
}
158+
159+
throw new Error("onError: must return new Response() or instance of Error");
160+
}
161+
}
162+
}
163+
}
164+
165+
// rethrow error if not handled by middleware
166+
if (errorAfterMiddleware) {
167+
throw errorAfterMiddleware;
168+
}
169+
}
128170

129171
// middleware (response)
130172
// execute in reverse-array order (first priority gets last transform)
@@ -213,8 +255,8 @@ export default function createClient(clientOptions) {
213255
if (!m) {
214256
continue;
215257
}
216-
if (typeof m !== "object" || !("onRequest" in m || "onResponse" in m)) {
217-
throw new Error("Middleware must be an object with one of `onRequest()` or `onResponse()`");
258+
if (typeof m !== "object" || !("onRequest" in m || "onResponse" in m || "onError" in m)) {
259+
throw new Error("Middleware must be an object with one of `onRequest()`, `onResponse() or `onError()`");
218260
}
219261
middlewares.push(m);
220262
}

packages/openapi-fetch/test/middleware/middleware.test.ts

+82-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { expect, test, expectTypeOf, assertType } from "vitest";
2-
import { createObservedClient } from "../helpers.js";
1+
import { assertType, expect, expectTypeOf, test } from "vitest";
32
import type { Middleware, MiddlewareCallbackParams } from "../../src/index.js";
3+
import { createObservedClient } from "../helpers.js";
44
import type { paths } from "./schemas/middleware.js";
55

66
test("receives a UUID per-request", async () => {
@@ -96,6 +96,62 @@ test("can modify response", async () => {
9696
expect(response.headers.get("middleware")).toBe("value");
9797
});
9898

99+
test("returns original errors if nothing is returned", async () => {
100+
const actualError = new Error();
101+
const client = createObservedClient<paths>({}, async (req) => {
102+
throw actualError;
103+
});
104+
client.use({
105+
onError({ error }) {
106+
expect(error).toBe(actualError);
107+
return;
108+
},
109+
});
110+
111+
try {
112+
await client.GET("/posts/{id}", { params: { path: { id: 123 } } });
113+
} catch (thrownError) {
114+
expect(thrownError).toBe(actualError);
115+
}
116+
});
117+
118+
test("can modify errors", async () => {
119+
const actualError = new Error();
120+
const modifiedError = new Error();
121+
const client = createObservedClient<paths>({}, async (req) => {
122+
throw actualError;
123+
});
124+
client.use({
125+
onError() {
126+
return modifiedError;
127+
},
128+
});
129+
130+
try {
131+
await client.GET("/posts/{id}", { params: { path: { id: 123 } } });
132+
} catch (thrownError) {
133+
expect(thrownError).toBe(modifiedError);
134+
}
135+
});
136+
137+
test("can catch errors and return a response instead", async () => {
138+
const actualError = new Error();
139+
const customResponse = Response.json({});
140+
const client = createObservedClient<paths>({}, async (req) => {
141+
throw actualError;
142+
});
143+
client.use({
144+
onError({ error }) {
145+
expect(error).toBe(actualError);
146+
return customResponse;
147+
},
148+
});
149+
150+
const { response } = await client.GET("/posts/{id}", { params: { path: { id: 123 } } });
151+
152+
expect(response).toBe(customResponse);
153+
});
154+
99155
test("executes in expected order", async () => {
100156
let actualRequest = new Request("https://nottherealurl.fake");
101157
const client = createObservedClient<paths>({}, async (req) => {
@@ -153,6 +209,30 @@ test("executes in expected order", async () => {
153209
expect(response.headers.get("step")).toBe("A");
154210
});
155211

212+
test("executes error handlers in expected order", async () => {
213+
const actualError = new Error();
214+
const modifiedError = new Error();
215+
const customResponse = Response.json({});
216+
const client = createObservedClient<paths>({}, async (req) => {
217+
throw actualError;
218+
});
219+
client.use({
220+
onError({ error }) {
221+
expect(error).toBe(modifiedError);
222+
return customResponse;
223+
},
224+
});
225+
client.use({
226+
onError() {
227+
return modifiedError;
228+
},
229+
});
230+
231+
const { response } = await client.GET("/posts/{id}", { params: { path: { id: 123 } } });
232+
233+
expect(response).toBe(customResponse);
234+
});
235+
156236
test("receives correct options", async () => {
157237
let requestBaseUrl = "";
158238

0 commit comments

Comments
 (0)