From 3e72198968b8969104e06fc37e15f60b2a5df937 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 17 Aug 2026 09:17:28 +0200 Subject: [PATCH] fix: correct query param serialization for arrays, dates and null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `queryParamsStringify` silently corrupted GET query strings in three ways. All of them failed silently — the request returned 200 with wrong data rather than erroring, so callers got no signal the param was never sent. - Arrays of objects used `join(',')`, stringifying each element via `String()` and producing `sort=[object Object]`. They are now JSON encoded, which is the format the API expects. Scalar arrays keep the comma-separated form (`ids=a,b`), since switching those to JSON would be a breaking wire-format change. - `Date` values were pushed without the `${k}=` prefix, dropping the key and injecting a bare value into the query string (`?limit=10&2026-08-15T10:00:00.000Z`). They now keep their key and are URL encoded. - `null` took the `typeof param === 'object'` branch and serialized as the literal string `id_gt=null`. It is now skipped, matching `undefined`. `null` and `undefined` entries within an array are dropped as well, so that a single empty value can't flip a scalar array from `a,b` to a JSON array. The array handling lives in `utils/query-params.ts` and resolves the format in a single pass. Affected endpoints: ChatApi.getReplies and VideoApi.queryCallSessionParticipantStats (top-level `sort`), plus VideoApi.getCallParticipantSessionMetrics and VideoApi.getCallStatsMap (`Date` bounds). Verified against the live API: the server validates the JSON `sort` array and honours it for page selection, and accepts the ISO date bounds. --- __tests__/query-params.test.ts | 96 ++++++++++++++++++++++++++++++++++ src/ApiClient.ts | 18 +++---- src/utils/query-params.ts | 21 ++++++++ 3 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 __tests__/query-params.test.ts create mode 100644 src/utils/query-params.ts 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); +};