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

Merged
merged 2 commits into from
Feb 19, 2025
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/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.clone());
}

Choose a reason for hiding this comment

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

Here you must return return response.clone(), otherwise the request body will be read on line 222 in index.js and repeated requests will be rejected with an error

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? The response in the cache is cloned.

I say this only from looking at the code, if you have tried this and it fails, well, then it fails :)

PR to fix the example in this case is very much appreciated.

Choose a reason for hiding this comment

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

I am using nextjs 15 (server-side requests). At some point response becomes bodyUsage=true, maybe it is related to the environment - nodesj. Most likely your example works for client-side code, but not for server-side code. I'm not sure how best to flesh out the documentation. In my case, I save data(as text), headers and statuses in Map.

const cache = new Map<string, string>();
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
      const response = new Response(cached);
      return response;
    }
  },
  async onResponse({ request, response }) {
    if (response.ok) {
      const key = getCacheKey(request);
      const responseBody = await response.clone().text();
      cache.set(key, responseBody);
    }
  }
};

Copy link
Contributor

Choose a reason for hiding this comment

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

TY for the example. Unfortunately, without more understanding of what is going on here, from the maintainers side, I don't think we have a strong enough signal to take this further.

If you would like, please file a bug with this, and investigation can proceed there. However, please understand that you will be expected to produce a reproducible example that relies on a (standard) fetch implementation only (of course that needs to run in browsers and Node.js, but no SSR magic, just free-standing code).

}
};
```

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
Contributor 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
62 changes: 62 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
Contributor 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,65 @@ 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>({}, () => {
throw new Error("unexpected call to 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 client = createObservedClient<paths>();

client.use(
{
async onRequest() {
return Response.json({});
},
},
{
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 client = createObservedClient<paths>();

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

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

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