diff --git a/.changeset/bright-rivers-explain.md b/.changeset/bright-rivers-explain.md new file mode 100644 index 000000000..ffc33f0a9 --- /dev/null +++ b/.changeset/bright-rivers-explain.md @@ -0,0 +1,5 @@ +--- +"@shopware/api-client": minor +--- + +Added onRequest interceptor as option to ApiClientHooks in API Client diff --git a/packages/api-client/README.md b/packages/api-client/README.md index aa4ec067c..b3e0e92f2 100644 --- a/packages/api-client/README.md +++ b/packages/api-client/README.md @@ -265,6 +265,16 @@ apiClient.hook("onDefaultHeaderChanged", (key, value) => { calling `apiClient.hook` will autocomplete the list of available hooks. +#### onResponse + +Using the onRequest hook, you can modify the request before it is sent. This is particularly useful for adding custom headers. + +```typescript +client.hook("onRequest", (_request, options) => { + options.headers.append("x-custom-header", "value"); +}); +``` + ## Links - [🧑‍🎓 Tutorial](https://api-client-tutorial-composable-frontends.pages.dev) diff --git a/packages/api-client/src/createAPIClient.ts b/packages/api-client/src/createAPIClient.ts index 691f71e02..8f95de440 100644 --- a/packages/api-client/src/createAPIClient.ts +++ b/packages/api-client/src/createAPIClient.ts @@ -2,7 +2,9 @@ import defu from "defu"; import { createHooks } from "hookable"; import { type FetchOptions, + type FetchRequest, type FetchResponse, + type ResolvedFetchOptions, type ResponseType, ofetch, } from "ofetch"; @@ -68,6 +70,10 @@ export type ApiClientHooks = { onResponseError: (response: FetchResponse) => void; onSuccessResponse: (response: FetchResponse) => void; onDefaultHeaderChanged: (headerName: string, value?: T) => void; + onRequest: ( + request: FetchRequest, + options: ResolvedFetchOptions, + ) => void; }; export function createAPIClient< @@ -121,6 +127,9 @@ export function createAPIClient< apiClientHooks.callHook("onResponseError", response); errorInterceptor(response); }, + async onRequest({ request, options }) { + await apiClientHooks.callHook("onRequest", request, options); + }, }); /** * Invoke API request based on provided path definition. diff --git a/packages/api-client/src/createApiClient.test.ts b/packages/api-client/src/createApiClient.test.ts index 14031f9cd..cee8eac97 100644 --- a/packages/api-client/src/createApiClient.test.ts +++ b/packages/api-client/src/createApiClient.test.ts @@ -200,6 +200,124 @@ describe("createAPIClient", () => { expect(contextChangedMock).not.toHaveBeenCalled(); }); + it("should invoke onRequest hook once", async () => { + const app = createApp().use( + "/context", + eventHandler(async () => { + return {}; + }), + ); + + const baseURL = await createPortAndGetUrl(app); + + const onRequestMock = vi.fn(); + + const client = createAPIClient({ + baseURL, + accessToken: "123", + contextToken: "456", + }); + + client.hook("onRequest", onRequestMock); + + await client.invoke("readContext get /context"); + + expect(onRequestMock).toHaveBeenCalledOnce(); + }); + + it("should allow onRequest hook to modify request options", async () => { + const requestHeadersSpy = vi.fn(); + + const app = createApp().use( + "/context", + eventHandler(async (event) => { + const requestHeaders = getHeaders(event); + requestHeadersSpy(requestHeaders); + return {}; + }), + ); + + const baseURL = await createPortAndGetUrl(app); + + const client = createAPIClient({ baseURL }); + + client.hook("onRequest", (_request, options) => { + options.headers.append("x-custom-header", "modified-header"); + }); + + await client.invoke("readContext get /context"); + + expect(requestHeadersSpy).toHaveBeenCalledWith( + expect.objectContaining({ + "x-custom-header": "modified-header", + }), + ); + }); + + it("should invoke all onRequest hooks independently", async () => { + const requestHeadersSpy = vi.fn(); + + const app = createApp().use( + "/context", + eventHandler(async (event) => { + const requestHeaders = getHeaders(event); + requestHeadersSpy(requestHeaders); // Capture request headers + return {}; + }), + ); + + const baseURL = await createPortAndGetUrl(app); + + const client = createAPIClient({ baseURL }); + + client.hook("onRequest", (_request, options) => { + options.headers.append("x-hook1", "value1"); + }); + + client.hook("onRequest", (_request, options) => { + options.headers.append("x-hook2", "value2"); + }); + + await client.invoke("readContext get /context"); + + expect(requestHeadersSpy).toHaveBeenCalledWith( + expect.objectContaining({ + "x-hook1": "value1", + "x-hook2": "value2", + }), + ); + }); + + it("should not interfere with normal request execution", async () => { + const requestHeadersSpy = vi.fn(); + + const app = createApp().use( + "/context", + eventHandler(async (event) => { + const requestHeaders = getHeaders(event); + requestHeadersSpy(requestHeaders); + return { success: true }; + }), + ); + + const baseURL = await createPortAndGetUrl(app); + + const client = createAPIClient({ baseURL }); + + client.hook("onRequest", () => { + // Perform a no-op + }); + + const response = await client.invoke("readContext get /context"); + + expect(response.data).toEqual({ success: true }); + expect(requestHeadersSpy).toHaveBeenCalledWith( + expect.objectContaining({ + accept: "application/json", + }), + ); + }); + it("should throw error when there is a problem with request", async () => { const app = createApp().use( "/checkout/cart",