Skip to content

Commit

Permalink
feat(openapi-fetch): Allow returning Response from onRequest callback
Browse files Browse the repository at this point in the history
  • Loading branch information
p-dubovitsky committed Jan 4, 2025
1 parent bb0355a commit fad2a50
Show file tree
Hide file tree
Showing 6 changed files with 160 additions and 56 deletions.
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);
}
}
};
```

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");
}
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
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>();

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);
});

0 comments on commit fad2a50

Please sign in to comment.