diff --git a/.changeset/orange-rules-sneeze.md b/.changeset/orange-rules-sneeze.md new file mode 100644 index 000000000..6acffd34c --- /dev/null +++ b/.changeset/orange-rules-sneeze.md @@ -0,0 +1,5 @@ +--- +"openapi-fetch": minor +--- + +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(); +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; +) => void | Request | Response | undefined | Promise; type MiddlewareOnResponse = ( options: MiddlewareCallbackParams & { response: Response }, ) => void | Response | undefined | Promise; 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({ onResponse }); assertType({ onRequest, onResponse }); }); + +test("can return response directly from onRequest", async () => { + const customResponse = Response.json({}); + const client = createObservedClient(); + + 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(); + + 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(); + + 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); +});