Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ We follow the format used by [Open Telemetry](https://github.com/open-telemetry/

## Unreleased

- feat: add `customFetch` option to `Config` to allow using custom fetch implementations by @jbergstroem
- fix: export esm as module in `package.json` by @jbergstroem in https://github.com/Topsort/topsort.js/pull/180

## Version 0.3.5 (2025-10-23)
Expand Down
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ This repository holds the official Topsort javascript client library. This proje
- [Config Parameters](#config-parameters-1)
- [Sample response](#sample-response-1)
- [Retryable Errors](#retryable-errors)
- [Examples](#examples)
- [Contributing](#contributing)
- [License](#license)

Expand Down Expand Up @@ -85,15 +86,24 @@ topsortClient.createAuction(auctionDetails)
- `userAgent`: Optional user agent to be added as part of the request. Example: `Mozilla/5.0`
- `timeout`: Optional timeout in milliseconds. Default is 30 seconds. If timeout is reached, the call will be rejected with an [AbortError](https://developer.mozilla.org/en-US/docs/Web/API/DOMException#aborterror).
- `fetchOptions`: Optional fetch options to pass to the fetch call. Defaults to `{ keepalive: true }`.
- `customFetch`: Optional custom fetch implementation to replace the default fetch. Useful for using libraries like axios or adding custom middleware. See [Using Custom Fetch Implementation](#using-custom-fetch-implementation) for examples.

`auctionDetails`: An object containing the details of the auction to be created, please refer to [Topsort's Auction API doc](https://docs.topsort.com/reference/createauctions) for body specification.

##### Using a Custom Fetch Implementation

The SDK allows you to replace the default `fetch` implementation with a custom one. This is useful for:
- Using HTTP clients like `axios` or `node-fetch`
- Adding custom middleware or interceptors
- Working in environments without native fetch support
- Adding custom headers, logging, or error handling

##### Overriding fetch options

By default, we pass `{ keepalive: true }` to fetch while making requests to our APIs. If you want to pass other options
or disable fetch due to the browser/engine version required, you can do so by overriding the `fetchOptions` object.

#### Sample response
#### Sample responses

200:
```json
Expand All @@ -117,6 +127,7 @@ or disable fetch due to the browser/engine version required, you can do so by ov
]
}
```

400:
```json
{
Expand Down Expand Up @@ -173,6 +184,7 @@ topsortClient.reportEvent(event)
- `userAgent`: Optional user agent to be added as part of the request. Example: `Mozilla/5.0`
- `timeout`: Optional timeout in milliseconds. Default is 30 seconds. If timeout is reached, the call will be rejected with an [AbortError](https://developer.mozilla.org/en-US/docs/Web/API/DOMException#aborterror).
- `fetchOptions`: Optional fetch options to pass to the fetch call. Defaults to `{ keepalive: true }`. When keepalive is enabled, requests will continue even if the page is being unloaded, which is useful for analytics and event tracking.
- `customFetch`: Optional custom fetch implementation to replace the default fetch. Useful for using libraries like axios or adding custom middleware. See [Using Custom Fetch Implementation](#using-custom-fetch-implementation) for examples.

`event`: An object containing the details of the event to be reported, please refer to [Topsort's Event API doc](https://docs.topsort.com/reference/reportevents) for body specification.

Expand Down Expand Up @@ -207,7 +219,7 @@ topsortClient.reportEvent(event)

#### Retryable Errors

The `reportEvent` function returns `"retry": true` if the response status code is `429` or any `5xx`. This enables you to identify when its appropriate to retry the function call.
The `reportEvent` function returns `"retry": true` if the response status code is `429` or any `5xx`. This enables you to identify when it's appropriate to retry the function call.

## Contributing

Expand Down
50 changes: 50 additions & 0 deletions e2e/auctions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,54 @@ test.describe("Create Auction via Topsort SDK", () => {
const isErrorFound = hasMatchingError(result);
expect(isErrorFound).toBeTruthy();
});

test("should work with custom fetch implementation", async ({ page }) => {
const mockAPIResponse = {
results: [
{
resultType: "listings",
winners: [],
error: false,
},
],
};

await page.route(`${baseURL}/${endpoints.auctions}`, async (route) => {
await route.fulfill({ json: mockAPIResponse });
});

await page.goto(playwrightConstants.host);
const result = await page.evaluate(() => {
let customFetchCalled = false;

// Create a custom fetch that wraps the original
const customFetch = async (url: string, options?: RequestInit) => {
customFetchCalled = true;
return fetch(url, options);
};

const config = {
apiKey: "rando-api-key",
customFetch,
};

const auctionDetails = {
auctions: [
{
type: "listings",
slots: 3,
category: { id: "cat123" },
geoTargeting: { location: "US" },
},
],
};

return window.sdk.createAuction(config, auctionDetails).then((response) => {
return { response, customFetchCalled };
});
});

expect(result.response).toEqual(mockAPIResponse);
expect(result.customFetchCalled).toBe(true);
});
});
35 changes: 20 additions & 15 deletions src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ class APIClient {
return data;
}

private async request(endpoint: string, options: RequestInit): Promise<unknown> {
private async request(endpoint: string, options: RequestInit, config: Config): Promise<unknown> {
try {
const sanitizedUrl = this.sanitizeUrl(endpoint);
const response = await fetch(sanitizedUrl, options);
const fetchFn = config.customFetch ?? fetch;
const response = await fetchFn(sanitizedUrl, options);
return this.handleResponse(response);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
Expand All @@ -40,20 +41,24 @@ class APIClient {
public async post(endpoint: string, body: unknown, config: Config): Promise<unknown> {
const signal = this.setupTimeoutSignal(config);
const fetchOptions = config.fetchOptions ?? { keepalive: true };
return this.request(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-UA": config.userAgent
? `@topsort/sdk ${version} ${config.userAgent}`
: `@topsort/sdk ${version}`,
Authorization: `Bearer ${config.apiKey}`,
return this.request(
endpoint,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-UA": config.userAgent
? `@topsort/sdk ${version} ${config.userAgent}`
: `@topsort/sdk ${version}`,
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify(body),
signal,
...fetchOptions,
},
body: JSON.stringify(body),
signal,
...fetchOptions,
});
config,
);
}

private sanitizeUrl(url: string): string {
Expand Down
3 changes: 3 additions & 0 deletions src/types/shared.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ export interface Config {
userAgent?: string;
/// Optional fetch options to pass to the fetch call. Defaults to { keepalive: true }.
fetchOptions?: RequestInit;
/// Optional custom fetch implementation to replace the default fetch.
/// Useful for using libraries like axios or adding custom middleware.
customFetch?: (url: string, options?: RequestInit) => Promise<Response>;
}
101 changes: 101 additions & 0 deletions test/api-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,105 @@ describe("apiClient", () => {
],
});
});

it("should use customFetch when provided", async () => {
let customFetchCalled = false;
let capturedUrl = "";
let capturedOptions: RequestInit | undefined;

const mockResponse = {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({
results: [
{
resultType: "listings",
winners: [],
error: false,
},
],
}),
} as Response;

const customFetch = async (url: string, options?: RequestInit): Promise<Response> => {
customFetchCalled = true;
capturedUrl = url;
capturedOptions = options;
return mockResponse;
};

const config: Config = {
apiKey: "test-api-key",
customFetch,
};

const url = `${baseURL}/${endpoints.auctions}`;
const body = { test: "data" };

const result = await APIClient.post(url, body, config);

expect(customFetchCalled).toBe(true);
expect(capturedUrl).toBe(url);
expect(capturedOptions?.method).toBe("POST");
expect(capturedOptions?.headers).toMatchObject({
"Content-Type": "application/json",
Accept: "application/json",
Authorization: "Bearer test-api-key",
});
expect(capturedOptions?.body).toBe(JSON.stringify(body));
expect(result).toEqual({
results: [
{
resultType: "listings",
winners: [],
error: false,
},
],
});
});

it("should handle customFetch errors properly", async () => {
const customFetch = async (): Promise<Response> => {
throw new Error("Custom fetch error");
};

const config: Config = {
apiKey: "test-api-key",
customFetch,
};

const url = `${baseURL}/${endpoints.auctions}`;
const body = { test: "data" };

expect(APIClient.post(url, body, config)).rejects.toThrow();
});

it("should pass fetchOptions to customFetch", async () => {
let capturedOptions: RequestInit | undefined;

const mockResponse = {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ results: [] }),
} as Response;

const customFetch = async (url: string, options?: RequestInit): Promise<Response> => {
capturedOptions = options;
return mockResponse;
};

const config: Config = {
apiKey: "test-api-key",
fetchOptions: { keepalive: false, mode: "cors" },
customFetch,
};

const url = `${baseURL}/${endpoints.auctions}`;
await APIClient.post(url, {}, config);

expect(capturedOptions?.keepalive).toBe(false);
expect(capturedOptions?.mode).toBe("cors");
});
});