Skip to content
Merged
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
96 changes: 96 additions & 0 deletions __tests__/query-params.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>) => {
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');
});
});
18 changes: 8 additions & 10 deletions src/ApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -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)}`);
}
}

Expand Down
21 changes: 21 additions & 0 deletions src/utils/query-params.ts
Original file line number Diff line number Diff line change
@@ -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);
};