diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ade7d1..e5692c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index bbbd260..9e222bb 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -117,6 +127,7 @@ or disable fetch due to the browser/engine version required, you can do so by ov ] } ``` + 400: ```json { @@ -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. @@ -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 it’s 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 diff --git a/e2e/auctions.test.ts b/e2e/auctions.test.ts index 9a759b7..d5c8bd7 100644 --- a/e2e/auctions.test.ts +++ b/e2e/auctions.test.ts @@ -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); + }); }); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 2e00a0c..70e26f7 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -18,10 +18,11 @@ class APIClient { return data; } - private async request(endpoint: string, options: RequestInit): Promise { + private async request(endpoint: string, options: RequestInit, config: Config): Promise { 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"; @@ -40,20 +41,24 @@ class APIClient { public async post(endpoint: string, body: unknown, config: Config): Promise { 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 { diff --git a/src/types/shared.d.ts b/src/types/shared.d.ts index 6221ef2..dd38bb0 100644 --- a/src/types/shared.d.ts +++ b/src/types/shared.d.ts @@ -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; } diff --git a/test/api-client.test.ts b/test/api-client.test.ts index 397f8cd..ceeef4d 100644 --- a/test/api-client.test.ts +++ b/test/api-client.test.ts @@ -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 => { + 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 => { + 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 => { + 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"); + }); });