Skip to content

Commit

Permalink
feat(openapi-fetch): add onError handler to middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
luchsamapparat committed Nov 10, 2024
1 parent 09624c3 commit 1e2bfae
Show file tree
Hide file tree
Showing 5 changed files with 212 additions and 18 deletions.
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

0 comments on commit 1e2bfae

Please sign in to comment.