Skip to content

feat(openapi-fetch): add onError handler to middleware #1974

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/heavy-kangaroos-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openapi-fetch": patch
---

add onError handler to middleware
38 changes: 28 additions & 10 deletions docs/openapi-fetch/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

```ts
import createClient from "openapi-fetch";
Expand All @@ -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<paths>({ baseUrl: "https://myapi.dev/v1/" });
Expand All @@ -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

Expand Down
48 changes: 46 additions & 2 deletions docs/openapi-fetch/middleware-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<paths>({ baseUrl: "https://myapi.dev/v1/" });
Expand Down Expand Up @@ -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)`:
Expand Down
12 changes: 11 additions & 1 deletion packages/openapi-fetch/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import type {
MediaType,
OperationRequestBodyContent,
PathsWithMethod,
ResponseObjectMap,
RequiredKeysOf,
ResponseObjectMap,
SuccessResponse,
} from "openapi-typescript-helpers";

Expand Down Expand Up @@ -152,15 +152,25 @@ type MiddlewareOnRequest = (
type MiddlewareOnResponse = (
options: MiddlewareCallbackParams & { response: Response },
) => void | Response | undefined | Promise<Response | undefined | void>;
type MiddlewareOnError = (
options: MiddlewareCallbackParams & { error: unknown },
) => void | Response | Error | Promise<void | Response | Error>;

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 */
Expand Down
48 changes: 45 additions & 3 deletions packages/openapi-fetch/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
}
Expand Down
84 changes: 82 additions & 2 deletions packages/openapi-fetch/test/middleware/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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<paths>({}, 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<paths>({}, 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<paths>({}, 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<paths>({}, async (req) => {
Expand Down Expand Up @@ -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<paths>({}, 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 = "";

Expand Down