From 1c542c96033d35e97ead1c41b16e94a8b4f25bdb Mon Sep 17 00:00:00 2001 From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:05:22 +0000 Subject: [PATCH] fix: percent-encode path parameters in sendRequest Path parameter values were substituted into the URL as-is, so an id containing '/', '?', '#' or '%' changed the request path or was truncated. Each value is now encoded with encodeURIComponent. --- __tests__/path-params.test.ts | 63 +++++++++++++++++++++++++++++++++++ src/ApiClient.ts | 5 ++- 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 __tests__/path-params.test.ts diff --git a/__tests__/path-params.test.ts b/__tests__/path-params.test.ts new file mode 100644 index 0000000..50b8f55 --- /dev/null +++ b/__tests__/path-params.test.ts @@ -0,0 +1,63 @@ +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 path params and returns the URL that was + * actually requested. + */ +const requestedUrlFor = async ( + url: string, + pathParams: Record, +) => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + await createApiClient().sendRequest('GET', url, pathParams); + + return new URL(fetchSpy.mock.calls[0][0]); +}; + +describe('path param serialization', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('leaves plain values unchanged', async () => { + const url = await requestedUrlFor('/api/v2/chat/messages/{id}', { + id: 'message-1_a', + }); + + expect(url.pathname).toBe('/api/v2/chat/messages/message-1_a'); + }); + + it('keeps reserved characters inside a single path segment', async () => { + const url = await requestedUrlFor('/api/v2/chat/messages/{id}', { + id: 'a/b?c#d', + }); + + expect(url.pathname).toBe('/api/v2/chat/messages/a%2Fb%3Fc%23d'); + expect(url.search).toBe('?api_key=test-api-key'); + expect(url.hash).toBe(''); + }); + + it('encodes each path param separately', async () => { + const url = await requestedUrlFor( + '/api/v2/video/call/{type}/{id}/{session}', + { type: 'default', id: 'call 100%', session: 's/1' }, + ); + + expect(url.pathname).toBe('/api/v2/video/call/default/call%20100%25/s%2F1'); + }); +}); diff --git a/src/ApiClient.ts b/src/ApiClient.ts index 57af75e..709ab17 100644 --- a/src/ApiClient.ts +++ b/src/ApiClient.ts @@ -28,7 +28,10 @@ export class ApiClient { const encodedParams = this.queryParamsStringify(queryParams); if (pathParams) { Object.keys(pathParams).forEach((paramName) => { - url = url.replace(`{${paramName}}`, pathParams[paramName]); + url = url.replace( + `{${paramName}}`, + encodeURIComponent(pathParams[paramName]), + ); }); }