Skip to content
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

feat(openapi-fetch): Allow returning Response from onRequest callback #2091

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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/orange-rules-sneeze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openapi-fetch": patch
---

Allow returning Response from onRequest callback
2 changes: 1 addition & 1 deletion docs/openapi-fetch/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ And the `onError` callback receives an additional `error` property:

Each middleware callback can return:

- **onRequest**: Either a `Request` to modify the request, or `undefined` to leave it untouched (skip)
- **onRequest**: A `Request` to modify the request, a `Response` to short-circuit the middleware chain, or `undefined` to leave request 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)

Expand Down
32 changes: 32 additions & 0 deletions docs/openapi-fetch/middleware-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,38 @@ onRequest({ schemaPath }) {

This will leave the request/response unmodified, and pass things off to the next middleware handler (if any). There’s no internal callback or observer library needed.

### Early Response

You can return a `Response` directly from `onRequest`, which will skip the actual request and remaining middleware chain. This is useful for cases such as deduplicating or caching responses to avoid unnecessary network requests.

```ts
const cache = new Map<string, Response>();
const getCacheKey = (request: Request) => `${request.method}:${request.url}`;

const cacheMiddleware: Middleware = {
onRequest({ request }) {
const key = getCacheKey(request);
const cached = cache.get(key);
if (cached) {
// Return cached response, skipping actual request and remaining middleware chain
return cached.clone();
}
},
onResponse({ request, response }) {
if (response.ok) {
const key = getCacheKey(request);
cache.set(key, response);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure we do not need to clone the response here? The response we return is (probably) going to be consumed by the caller.

IIUC, clone() will throw if the response has already been used:

clone() throws a TypeError if the response body has already been used.

(https://developer.mozilla.org/en-US/docs/Web/API/Response/clone)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think you're right. Thanks for your consideration!

}
}
};
```

When a middleware returns a `Response`:

* The request is not sent to the server
* Subsequent `onRequest` handlers are skipped
* `onResponse` handlers are skipped

### Throwing

Middleware can also be used to throw an error that `fetch()` wouldn’t normally, useful in libraries like [TanStack Query](https://tanstack.com/query/latest):
Expand Down
2 changes: 1 addition & 1 deletion packages/openapi-fetch/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export interface MiddlewareCallbackParams {

type MiddlewareOnRequest = (
options: MiddlewareCallbackParams,
) => void | Request | undefined | Promise<Request | undefined | void>;
) => void | Request | Response | undefined | Promise<Request | Response | undefined | void>;
type MiddlewareOnResponse = (
options: MiddlewareCallbackParams & { response: Response },
) => void | Response | undefined | Promise<Response | undefined | void>;
Expand Down
114 changes: 60 additions & 54 deletions packages/openapi-fetch/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export default function createClient(clientOptions) {
let id;
let options;
let request = new CustomRequest(createFinalURL(schemaPath, { baseUrl, params, querySerializer }), requestInit);
let response;

/** Add custom parameters to Request object */
for (const key in init) {
Expand Down Expand Up @@ -124,79 +125,84 @@ export default function createClient(clientOptions) {
id,
});
if (result) {
if (!(result instanceof CustomRequest)) {
throw new Error("onRequest: must return new Request() when modifying the request");
if (result instanceof CustomRequest) {
request = result;
} else if (result instanceof Response) {
response = result;
break;
} else {
throw new Error("onRequest: must return new Request() or Response() when modifying the request");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this error message was wrong an now it feels even more wrong :-/ (IIUC you must not return new Request() but mutate the request in place and return it, unclear to me why returning the request is even necessary...).

Probably not blocking for this PR, but a fix would be appreciated.

Copy link
Contributor

@drwpow drwpow Jan 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I’m keen to just remove this (not necessarily in this PR, just in general); this is not true in some cases. It was meant to provide a friendlier error for folks, but I actually think just letting the platform surface the error is more helpful, rather than us getting in the way

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understood you correctly. Do you want to throw an error without a message or something else?

}
request = result;
}
}
}
}

// fetch!
let response;
try {
response = await fetch(request, requestInitExt);
} catch (error) {
let errorAfterMiddleware = error;
// middleware (error)
if (!response) {
// fetch!
try {
response = await fetch(request, requestInitExt);
} 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)
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({
if (m && typeof m === "object" && typeof m.onResponse === "function") {
const result = await m.onResponse({
request,
error: errorAfterMiddleware,
response,
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 Response)) {
throw new Error("onResponse: must return new Response() when modifying the response");
}

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)
if (middlewares.length) {
for (let i = middlewares.length - 1; i >= 0; i--) {
const m = middlewares[i];
if (m && typeof m === "object" && typeof m.onResponse === "function") {
const result = await m.onResponse({
request,
response,
schemaPath,
params,
options,
id,
});
if (result) {
if (!(result instanceof Response)) {
throw new Error("onResponse: must return new Response() when modifying the response");
response = result;
}
response = result;
}
}
}
Expand Down
61 changes: 61 additions & 0 deletions packages/openapi-fetch/test/middleware/middleware.test.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice tests!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

Original file line number Diff line number Diff line change
Expand Up @@ -443,3 +443,64 @@ test("type error occurs only when neither onRequest nor onResponse is specified"
assertType<Middleware>({ onResponse });
assertType<Middleware>({ onRequest, onResponse });
});

test("can return response directly from onRequest", async () => {
const customResponse = Response.json({});
const client = createObservedClient<paths>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const client = createObservedClient<paths>();
const client = createObservedClient<paths>({}, () => throw new Error("unexpected call to fetch"));

This way, we make sure we really do not call fetch.


client.use({
async onRequest() {
return customResponse;
},
});

const { response } = await client.GET("/posts/{id}", {
params: { path: { id: 123 } },
});

expect(response).toBe(customResponse);
});

test("skips subsequent onRequest handlers when response is returned", async () => {
let onRequestCalled = false;
const customResponse = Response.json({});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Inline below? You don't actually need the reference for this test.

const client = createObservedClient<paths>();

client.use(
{
async onRequest() {
return customResponse;
},
},
{
async onRequest() {
onRequestCalled = true;
return undefined;
},
},
);

await client.GET("/posts/{id}", { params: { path: { id: 123 } } });

expect(onRequestCalled).toBe(false);
});

test("skips onResponse handlers when response is returned from onRequest", async () => {
let onResponseCalled = false;
const customResponse = Response.json({});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same: Inline below?

const client = createObservedClient<paths>();

client.use({
async onRequest() {
return customResponse;
},
async onResponse() {
onResponseCalled = true;
return undefined;
},
});

await client.GET("/posts/{id}", { params: { path: { id: 123 } } });

expect(onResponseCalled).toBe(false);
});
Loading