Skip to content

Commit

Permalink
feat: add timeout to api client (#1468)
Browse files Browse the repository at this point in the history
  • Loading branch information
JimTacobs authored Dec 4, 2024
1 parent 13c83be commit a87bbcf
Show file tree
Hide file tree
Showing 7 changed files with 343 additions and 9 deletions.
5 changes: 5 additions & 0 deletions .changeset/afraid-snails-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shopware/api-client": minor
---

Added fetchOptions to both API clients to allow for base configuration of http client
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,13 @@ import type { operations } from "@shopware/api-client/store-api-types";

The fields in the provided object as an argument can be described as:

| field | description | example |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| **baseURL** | optional - Used to point an URL of `store-api` where the Shopware 6 instance is available over the network. | `https://demo-frontends.shopware.store/store-api` |
| **accessToken** | optional - The unique key ID that refers to the specific sales channel (for more info visit a [Store API docs](https://shopware.stoplight.io/docs/store-api/)) | `SWSCBHFSNTVMAWNZDNFKSHLAYW` |
| **contextToken** | optional - The unique key in UUID format that points the corresponding session in the backend | |
| **defaultHeaders** | optional - Standard dictionary object that keeps possible HTTP Headers that will be used for further requests | `{"Content-Type":"application/json"}` |
| field | description | example |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| **baseURL** | optional - Used to point an URL of `store-api` where the Shopware 6 instance is available over the network. | `https://demo-frontends.shopware.store/store-api` |
| **accessToken** | optional - The unique key ID that refers to the specific sales channel (for more info visit a [Store API docs](https://shopware.stoplight.io/docs/store-api/)) | `SWSCBHFSNTVMAWNZDNFKSHLAYW` |
| **contextToken** | optional - The unique key in UUID format that points the corresponding session in the backend | |
| **defaultHeaders** | optional - Standard dictionary object that keeps possible HTTP Headers that will be used for further requests | `{"Content-Type":"application/json"}` |
| **fetchOptions** | optional - Set standard timeout or retry options for each request made through the client. | `{ timeout: 5000, retry: 2, retryDelay: 2000, retryStatusCodes: [500] }` |

## Example of creating the API _Client_ instance

Expand All @@ -57,5 +58,6 @@ export const apiClient = createAPIClient<operations>({
baseURL: "https://demo-frontends.shopware.store/store-api",
accessToken: "SWSCBHFSNTVMAWNZDNFKSHLAYW",
contextToken: Cookies.get("sw-context-token"),
timeout: 5000,
});
```
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ The fields in the provided object as an argument can be described as:
| **credentials** | optional - an object containing secrets enable to use a few options of authentication | `{ grant_type: "client_credentials", client_id: "someClientId", client_secret: "someVerySecretKey" } ` |
| **sessionData** | optional - handful in case of OAuth, when the authorization mechanism is taken care by some other tool | `{ accessToken: "some-token", refreshToken: "some-refresh-token", expirationTime: 1728412483 }` |
| **defaultHeaders** | optional - Standard dictionary object that keeps available HTTP Headers that will be used for further requests | `{"Content-Type":"application/json"}` |
| **fetchOptions** | optional - Set standard timeout or retry options for each request made through the client. | `{ timeout: 5000, retry: 2, retryDelay: 2000, retryStatusCodes: [500] }` |

## Example of creating the Admin API _Client_ instance

Expand Down
14 changes: 13 additions & 1 deletion packages/api-client/src/createAPIClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { type FetchResponse, ofetch, FetchOptions } from "ofetch";
import {
type FetchResponse,
ofetch,
type FetchOptions,
type ResponseType,
} from "ofetch";
import type { operations } from "../api-types/storeApiTypes";
import { ClientHeaders, createHeaders } from "./defaultHeaders";
import { errorInterceptor } from "./errorInterceptor";
Expand Down Expand Up @@ -53,6 +58,11 @@ export type InvokeParameters<CURRENT_OPERATION> =
>;
};

export type GlobalFetchOptions = Pick<
FetchOptions<ResponseType>,
"retry" | "retryDelay" | "retryStatusCodes" | "timeout"
>;

export type ApiClientHooks = {
onContextChanged: (newContextToken: string) => void;
onResponseError: (response: FetchResponse<ResponseType>) => void;
Expand All @@ -68,6 +78,7 @@ export function createAPIClient<
accessToken?: string;
contextToken?: string;
defaultHeaders?: ClientHeaders;
fetchOptions?: GlobalFetchOptions;
}) {
// Create a hookable instance
const apiClientHooks = createHooks<ApiClientHooks>();
Expand All @@ -89,6 +100,7 @@ export function createAPIClient<

const apiFetch = ofetch.create({
baseURL: params.baseURL,
...params.fetchOptions,
// async onRequest({ request, options }) {},
// async onRequestError({ request, options, error }) {},
async onResponse(context) {
Expand Down
11 changes: 9 additions & 2 deletions packages/api-client/src/createAdminAPIClient.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { ofetch } from "ofetch";
import type { FetchOptions, FetchResponse } from "ofetch";
import {
type FetchResponse,
ofetch,
type FetchOptions,
type ResponseType,
} from "ofetch";
import type { operations } from "../api-types/adminApiTypes";
import { ClientHeaders, createHeaders } from "./defaultHeaders";
import { errorInterceptor } from "./errorInterceptor";
import { createHooks } from "hookable";
import defu from "defu";
import { createPathWithParams } from "./transformPathToQuery";
import type { InvokeParameters } from "./createAPIClient";
import { GlobalFetchOptions } from "./createAPIClient";

type SimpleUnionOmit<T, K extends string | number | symbol> = T extends unknown
? Omit<T, K>
Expand Down Expand Up @@ -67,6 +72,7 @@ export function createAdminAPIClient<
credentials?: OPERATIONS["token post /oauth/token"]["body"];
sessionData?: AdminSessionData;
defaultHeaders?: ClientHeaders;
fetchOptions?: GlobalFetchOptions;
}) {
const isTokenBasedAuth =
params.credentials?.grant_type === "client_credentials";
Expand Down Expand Up @@ -123,6 +129,7 @@ export function createAdminAPIClient<

const apiFetch = ofetch.create({
baseURL: params.baseURL,
...params.fetchOptions,
async onRequest({ request, options }) {
const isExpired = sessionData.expirationTime <= Date.now();
if (isExpired && !request.toString().includes("/oauth/token")) {
Expand Down
163 changes: 163 additions & 0 deletions packages/api-client/src/createAdminApiClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,4 +561,167 @@ describe("createAdminAPIClient", () => {
);
});
});

describe("fetchOptions", () => {
it("should enforce the timeout for API requests when a timeout is provided", async () => {
const app = createApp().use(
"/order",
eventHandler(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
return { message: "This should never be returned" };
}),
);

const baseURL = await createPortAndGetUrl(app);

const client = createAdminAPIClient<operations>({
sessionData: {
accessToken: "Bearer my-access-token",
refreshToken: "my-refresh-token",
expirationTime: Date.now() + 1000 * 60,
},
fetchOptions: {
timeout: 50,
},
baseURL,
});

await expect(
client.invoke("getOrderList get /order", {}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[FetchError: [GET] "${baseURL}order": <no response> [TimeoutError]: The operation was aborted due to timeout]`,
);
});

it("should complete request if timeout is not provided and endpoint resolves", async () => {
const app = createApp().use(
"/fast-endpoint",
eventHandler(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
return { message: "Request succeeded" };
}),
);

const baseURL = await createPortAndGetUrl(app);

const client = createAdminAPIClient<operations>({
sessionData: {
accessToken: "Bearer my-access-token",
refreshToken: "my-refresh-token",
expirationTime: Date.now() + 1000 * 60,
},
baseURL,
});

// @ts-expect-error this endpoint does not exist
const response = await client.invoke("testNoTimeout get /fast-endpoint");

expect(response).toEqual({
data: { message: "Request succeeded" },
status: 200,
});
});

it("should complete request if timeout is larger than the time it took to resolve the request", async () => {
const app = createApp().use(
"/fast-endpoint",
eventHandler(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
return { message: "Request succeeded" };
}),
);

const baseURL = await createPortAndGetUrl(app);

const client = createAdminAPIClient<operations>({
sessionData: {
accessToken: "Bearer my-access-token",
refreshToken: "my-refresh-token",
expirationTime: Date.now() + 1000 * 60,
},
fetchOptions: {
timeout: 100,
},
baseURL,
});

// @ts-expect-error this endpoint does not exist
const response = await client.invoke("testTimeout get /fast-endpoint");

expect(response).toEqual({
data: { message: "Request succeeded" },
status: 200,
});
});

it("should use per-request timeout instead of client default timeout", async () => {
const app = createApp().use(
"/override-endpoint",
eventHandler(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
return { message: "Request succeeded" };
}),
);

const baseURL = await createPortAndGetUrl(app);

const client = createAdminAPIClient<operations>({
sessionData: {
accessToken: "Bearer my-access-token",
refreshToken: "my-refresh-token",
expirationTime: Date.now() + 1000 * 60,
},
fetchOptions: {
timeout: 100,
},
baseURL,
});

const response = await client.invoke(
// @ts-expect-error this endpoint does not exist
"testOverrideTimeout get /override-endpoint",
{
fetchOptions: { timeout: 200 },
},
);

expect(response).toEqual({
data: { message: "Request succeeded" },
status: 200,
});
});

it("should fail when per-request timeout is smaller than endpoint response time", async () => {
const app = createApp().use(
"/override-endpoint",
eventHandler(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
return { message: "Request succeeded" };
}),
);

const baseURL = await createPortAndGetUrl(app);

const client = createAdminAPIClient<operations>({
sessionData: {
accessToken: "Bearer my-access-token",
refreshToken: "my-refresh-token",
expirationTime: Date.now() + 1000 * 60,
},
fetchOptions: {
timeout: 200,
},
baseURL,
});

await expect(
// @ts-expect-error this endpoint does not exist
client.invoke("testOverrideTimeout get /override-endpoint", {
fetchOptions: { timeout: 100 },
}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[FetchError: [GET] "${baseURL}override-endpoint": <no response> [TimeoutError]: The operation was aborted due to timeout]`,
);
});
});
});
Loading

0 comments on commit a87bbcf

Please sign in to comment.