diff --git a/__tests__/query-params.test.ts b/__tests__/query-params.test.ts new file mode 100644 index 0000000..2346f89 --- /dev/null +++ b/__tests__/query-params.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ApiClient } from '../src/ApiClient'; + +const createApiClient = () => + new ApiClient({ + apiKey: 'test-api-key', + token: 'test-token', + baseUrl: 'https://example.com', + timeout: 3000, + }); + +/** + * Sends a request with the given query params and returns the query string + * that was actually put on the wire. + */ +const queryStringFor = async (queryParams: Record) => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + await createApiClient().sendRequest('GET', '/test', undefined, queryParams); + + const requestedUrl = fetchSpy.mock.calls[0][0] as string; + return new URL(requestedUrl).search.replace(/^\?/, ''); +}; + +describe('query param serialization', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('serializes an array of objects as JSON', async () => { + const search = await queryStringFor({ + sort: [{ field: 'created_at', direction: 1 }], + }); + + expect(search).toContain( + `sort=${encodeURIComponent('[{"field":"created_at","direction":1}]')}`, + ); + }); + + it('keeps the param name for Date values', async () => { + const search = await queryStringFor({ + start_time: new Date('2026-08-15T10:00:00.000Z'), + }); + + expect(search).toContain( + `start_time=${encodeURIComponent('2026-08-15T10:00:00.000Z')}`, + ); + }); + + it('serializes an array of strings comma-separated', async () => { + const search = await queryStringFor({ ids: ['first', 'second'] }); + + expect(search).toContain(`ids=${encodeURIComponent('first,second')}`); + }); + + it('drops empty entries from an array of strings', async () => { + const search = await queryStringFor({ + ids: ['first', null, undefined, 'second'], + }); + + expect(search).toContain(`ids=${encodeURIComponent('first,second')}`); + }); + + it('drops empty entries from an array of objects', async () => { + const search = await queryStringFor({ + sort: [{ field: 'created_at', direction: 1 }, null], + }); + + expect(search).toContain( + `sort=${encodeURIComponent('[{"field":"created_at","direction":1}]')}`, + ); + }); + + it('serializes a nested object as JSON', async () => { + const search = await queryStringFor({ + payload: { sort: [{ field: 'created_at', direction: 1 }] }, + }); + + expect(search).toContain( + `payload=${encodeURIComponent( + '{"sort":[{"field":"created_at","direction":1}]}', + )}`, + ); + }); + + it('omits params with no value', async () => { + const search = await queryStringFor({ limit: undefined, id_gt: null }); + + expect(search).toBe('api_key=test-api-key'); + }); +}); diff --git a/src/ApiClient.ts b/src/ApiClient.ts index e8359e1..8a74dcd 100644 --- a/src/ApiClient.ts +++ b/src/ApiClient.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'; import { ApiConfig, RequestMetadata, StreamError } from './types'; import { APIError } from './gen/models'; import { getRateLimitFromResponseHeader } from './utils/rate-limit'; +import { isScalar, stringifyArrayParam } from './utils/query-params'; export class ApiClient { private readonly dispatcher?: RequestInit['dispatcher']; @@ -128,20 +129,17 @@ export class ApiClient { const newParams = []; for (const k in params) { const param = params[k]; + if (param === null || param === undefined) continue; if (Array.isArray(param)) { - newParams.push(`${k}=${encodeURIComponent(param.join(','))}`); + newParams.push( + `${k}=${encodeURIComponent(stringifyArrayParam(param))}`, + ); } else if (param instanceof Date) { - newParams.push(param.toISOString()); + newParams.push(`${k}=${encodeURIComponent(param.toISOString())}`); } else if (typeof param === 'object') { newParams.push(`${k}=${encodeURIComponent(JSON.stringify(param))}`); - } else { - if ( - typeof param === 'string' || - typeof param === 'number' || - typeof param === 'boolean' - ) { - newParams.push(`${k}=${encodeURIComponent(param)}`); - } + } else if (isScalar(param)) { + newParams.push(`${k}=${encodeURIComponent(param)}`); } } diff --git a/src/utils/query-params.ts b/src/utils/query-params.ts new file mode 100644 index 0000000..0f8cd71 --- /dev/null +++ b/src/utils/query-params.ts @@ -0,0 +1,21 @@ +export const isScalar = (value: any) => { + const type = typeof value; + return type === 'string' || type === 'number' || type === 'boolean'; +}; + +/** + * Scalar arrays go on the wire comma-separated (`ids=a,b`), anything else has + * to be JSON or it stringifies to `[object Object]`. `null` and `undefined` + * entries are dropped so that a single empty value can't flip the format. + */ +export const stringifyArrayParam = (param: any[]) => { + const entries = []; + let allScalar = true; + for (const entry of param) { + if (entry == null) continue; + if (!isScalar(entry)) allScalar = false; + entries.push(entry); + } + + return allScalar ? entries.join(',') : JSON.stringify(entries); +};