From fad2a509a7fbd19a032ed9976925cb8d1b5b6d94 Mon Sep 17 00:00:00 2001
From: Pavel Dubovitsky <p.a.dubovitsky@gmail.com>
Date: Sat, 4 Jan 2025 14:58:44 +0100
Subject: [PATCH 1/2] feat(openapi-fetch): Allow returning Response from
 onRequest callback

---
 .changeset/orange-rules-sneeze.md             |   5 +
 docs/openapi-fetch/api.md                     |   2 +-
 docs/openapi-fetch/middleware-auth.md         |  32 +++++
 packages/openapi-fetch/src/index.d.ts         |   2 +-
 packages/openapi-fetch/src/index.js           | 114 +++++++++---------
 .../test/middleware/middleware.test.ts        |  61 ++++++++++
 6 files changed, 160 insertions(+), 56 deletions(-)
 create mode 100644 .changeset/orange-rules-sneeze.md

diff --git a/.changeset/orange-rules-sneeze.md b/.changeset/orange-rules-sneeze.md
new file mode 100644
index 000000000..ce46d9c05
--- /dev/null
+++ b/.changeset/orange-rules-sneeze.md
@@ -0,0 +1,5 @@
+---
+"openapi-fetch": patch
+---
+
+Allow returning Response from onRequest callback
diff --git a/docs/openapi-fetch/api.md b/docs/openapi-fetch/api.md
index f5ea0c8c2..1a504c5d1 100644
--- a/docs/openapi-fetch/api.md
+++ b/docs/openapi-fetch/api.md
@@ -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)
 
diff --git a/docs/openapi-fetch/middleware-auth.md b/docs/openapi-fetch/middleware-auth.md
index 3bd287a3a..12c0746c4 100644
--- a/docs/openapi-fetch/middleware-auth.md
+++ b/docs/openapi-fetch/middleware-auth.md
@@ -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);
+    }
+  }
+};
+```
+
+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):
diff --git a/packages/openapi-fetch/src/index.d.ts b/packages/openapi-fetch/src/index.d.ts
index 06d01d400..79dec3d77 100644
--- a/packages/openapi-fetch/src/index.d.ts
+++ b/packages/openapi-fetch/src/index.d.ts
@@ -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>;
diff --git a/packages/openapi-fetch/src/index.js b/packages/openapi-fetch/src/index.js
index 5e0c2fcd4..432b1ff33 100644
--- a/packages/openapi-fetch/src/index.js
+++ b/packages/openapi-fetch/src/index.js
@@ -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) {
@@ -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");
             }
-            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;
           }
         }
       }
diff --git a/packages/openapi-fetch/test/middleware/middleware.test.ts b/packages/openapi-fetch/test/middleware/middleware.test.ts
index 9ccc23af4..d1643fbd3 100644
--- a/packages/openapi-fetch/test/middleware/middleware.test.ts
+++ b/packages/openapi-fetch/test/middleware/middleware.test.ts
@@ -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>();
+
+  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({});
+  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({});
+  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);
+});

From 5a384361796b32fe6563d053c142eb65385528e6 Mon Sep 17 00:00:00 2001
From: Pavel Dubovitsky <p.a.dubovitsky@gmail.com>
Date: Thu, 13 Feb 2025 16:22:19 +0100
Subject: [PATCH 2/2] feat(openapi-fetch): Allow returning Response from
 onRequest callback

---
 docs/openapi-fetch/middleware-auth.md                 |  2 +-
 .../openapi-fetch/test/middleware/middleware.test.ts  | 11 ++++++-----
 2 files changed, 7 insertions(+), 6 deletions(-)

diff --git a/docs/openapi-fetch/middleware-auth.md b/docs/openapi-fetch/middleware-auth.md
index 12c0746c4..bf94c6aca 100644
--- a/docs/openapi-fetch/middleware-auth.md
+++ b/docs/openapi-fetch/middleware-auth.md
@@ -84,7 +84,7 @@ const cacheMiddleware: Middleware = {
   onResponse({ request, response }) {
     if (response.ok) {
       const key = getCacheKey(request);
-      cache.set(key, response);
+      cache.set(key, response.clone());
     }
   }
 };
diff --git a/packages/openapi-fetch/test/middleware/middleware.test.ts b/packages/openapi-fetch/test/middleware/middleware.test.ts
index d1643fbd3..f215e9cfe 100644
--- a/packages/openapi-fetch/test/middleware/middleware.test.ts
+++ b/packages/openapi-fetch/test/middleware/middleware.test.ts
@@ -446,7 +446,10 @@ test("type error occurs only when neither onRequest nor onResponse is specified"
 
 test("can return response directly from onRequest", async () => {
   const customResponse = Response.json({});
-  const client = createObservedClient<paths>();
+
+  const client = createObservedClient<paths>({}, () => {
+    throw new Error("unexpected call to fetch");
+  });
 
   client.use({
     async onRequest() {
@@ -463,13 +466,12 @@ test("can return response directly from onRequest", async () => {
 
 test("skips subsequent onRequest handlers when response is returned", async () => {
   let onRequestCalled = false;
-  const customResponse = Response.json({});
   const client = createObservedClient<paths>();
 
   client.use(
     {
       async onRequest() {
-        return customResponse;
+        return Response.json({});
       },
     },
     {
@@ -487,12 +489,11 @@ test("skips subsequent onRequest handlers when response is returned", async () =
 
 test("skips onResponse handlers when response is returned from onRequest", async () => {
   let onResponseCalled = false;
-  const customResponse = Response.json({});
   const client = createObservedClient<paths>();
 
   client.use({
     async onRequest() {
-      return customResponse;
+      return Response.json({});
     },
     async onResponse() {
       onResponseCalled = true;