diff --git a/.changeset/famous-badgers-roll.md b/.changeset/famous-badgers-roll.md new file mode 100644 index 000000000..583511858 --- /dev/null +++ b/.changeset/famous-badgers-roll.md @@ -0,0 +1,5 @@ +--- +'@openapi-qraft/react': patch +--- + +Fix URL serialization using `encodeURIComponent` instead of `encodeURI` to avoid issues with slash serialization in path parameters. diff --git a/.changeset/light-files-pull.md b/.changeset/light-files-pull.md new file mode 100644 index 000000000..5a1756244 --- /dev/null +++ b/.changeset/light-files-pull.md @@ -0,0 +1,5 @@ +--- +'@openapi-qraft/react': minor +--- + +Refactor `getQueryString` function to use native `URLSearchParams` instead of custom implementation for better maintainability. diff --git a/.github/actions/spelling/excludes.txt b/.github/actions/spelling/excludes.txt index 7e589437e..e5347db9d 100644 --- a/.github/actions/spelling/excludes.txt +++ b/.github/actions/spelling/excludes.txt @@ -94,3 +94,4 @@ ^packages/tanstack-query-react-plugin/src/ts-factory/service-operation.generated/ ^website/static/ ignore$ +^\Qpackages/react-client/src/lib/urlSerializer.test.ts\E$ diff --git a/packages/react-client/src/lib/requestFn.ts b/packages/react-client/src/lib/requestFn.ts index 54664b6f1..fdad283e3 100644 --- a/packages/react-client/src/lib/requestFn.ts +++ b/packages/react-client/src/lib/requestFn.ts @@ -100,7 +100,7 @@ export function urlSerializer( info.parameters?.path && Object.prototype.hasOwnProperty.call(info.parameters.path, group) ) { - return encodeURI(String(info.parameters?.path[group])); + return encodeURIComponent(String(info.parameters?.path[group])); } return substring; } @@ -116,37 +116,24 @@ export function urlSerializer( } function getQueryString(params: Record): string { - const qs: string[] = []; - - const append = (key: string, value: any) => { - qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); - }; - - const process = (key: string, value: any) => { - if (typeof value === 'undefined' || value === null) return; - - if (Array.isArray(value)) { - value.forEach((v) => { - process(key, v); - }); - } else if (typeof value === 'object') { - Object.entries(value).forEach(([k, v]) => { - process(`${key}[${k}]`, v); - }); - } else { - append(key, value); + const search = new URLSearchParams(); + + const walk = (prefix: string, value: any) => { + if (value == null) return; + if (value instanceof Date) + return search.append(prefix, value.toISOString()); + if (Array.isArray(value)) return value.forEach((v) => walk(prefix, v)); + if (typeof value === 'object') { + return Object.entries(value).forEach(([k, v]) => + walk(`${prefix}[${k}]`, v) + ); } + search.append(prefix, String(value)); }; - Object.entries(params).forEach(([key, value]) => { - process(key, value); - }); - - if (qs.length > 0) { - return `?${qs.join('&')}`; - } + Object.entries(params).forEach(([k, v]) => walk(k, v)); - return ''; + return search.toString() ? `?${search.toString()}` : ''; } export function mergeHeaders(...allHeaders: (HeadersOptions | undefined)[]) { diff --git a/packages/react-client/src/lib/urlSerializer.test.ts b/packages/react-client/src/lib/urlSerializer.test.ts index 1039de58e..8e1a458dc 100644 --- a/packages/react-client/src/lib/urlSerializer.test.ts +++ b/packages/react-client/src/lib/urlSerializer.test.ts @@ -14,6 +14,38 @@ describe('urlSerializer', () => { ).toBe('https://api.example.com/users/123/posts/456'); }); + it('should correctly encode complex characters in path and query parameters', () => { + const info = { + baseUrl: 'https://api.example.com', + parameters: { + path: { + query: 'hello/world:test@domain.com', + category: 'books & magazines', + }, + query: { + filter: 'price:$10-$50', + tags: ['tag/with/slash', 'tag:with:colon'], + special: 'symbols!@#$%^&*()+={}[]|\\:";\'<>?,./~`', + }, + }, + } as const; + + const result = urlSerializer( + { url: '/search/{query}/category/{category}', method: 'get' }, + info + ); + + const expectedUrl = new URL( + 'https://api.example.com/search/hello%2Fworld%3Atest%40domain.com/category/books%20%26%20magazines' + ); + expectedUrl.searchParams.set('filter', info.parameters.query.filter); + expectedUrl.searchParams.append('tags', info.parameters.query.tags[0]); + expectedUrl.searchParams.append('tags', info.parameters.query.tags[1]); + expectedUrl.searchParams.set('special', info.parameters.query.special); + + expect(result).toBe(expectedUrl.toString()); + }); + it('should correctly append query parameters', () => { expect( urlSerializer( @@ -215,4 +247,237 @@ describe('urlSerializer', () => { ) ).toBe('https://api.example.com/users/undefined/posts/456'); }); + + it('should correctly serialize query parameters with Date objects', () => { + expect( + urlSerializer( + { url: '/events', method: 'get' }, + { + baseUrl: 'https://api.example.com', + parameters: { + query: { + startDate: new Date('2023-12-25T10:30:00.000Z'), + limit: 5, + }, + }, + } + ) + ).toBe( + `https://api.example.com/events?startDate=2023-12-25T10%3A30%3A00.000Z&limit=5` + ); + }); + + it('should correctly serialize query parameters with Date objects in arrays', () => { + expect( + urlSerializer( + { url: '/events', method: 'get' }, + { + baseUrl: 'https://api.example.com', + parameters: { + query: { + dates: [ + new Date('2023-01-01T00:00:00.000Z'), + new Date('2023-12-31T23:59:59.999Z'), + ], + active: true, + }, + }, + } + ) + ).toBe( + 'https://api.example.com/events?dates=2023-01-01T00%3A00%3A00.000Z&dates=2023-12-31T23%3A59%3A59.999Z&active=true' + ); + }); + + it('should correctly serialize query parameters with Date objects in nested objects', () => { + const startDate = new Date('2023-01-01T00:00:00.000Z'); + expect( + urlSerializer( + { url: '/events', method: 'get' }, + { + baseUrl: 'https://api.example.com', + parameters: { + query: { + timeRange: { + start: startDate, + end: null, + }, + limit: 10, + }, + }, + } + ) + ).toBe( + 'https://api.example.com/events?timeRange%5Bstart%5D=2023-01-01T00%3A00%3A00.000Z&limit=10' + ); + }); + + it('should correctly serialize query parameters with boolean values', () => { + expect( + urlSerializer( + { url: '/users', method: 'get' }, + { + baseUrl: 'https://api.example.com', + parameters: { + query: { + active: true, + verified: false, + premium: null, + limit: 10, + }, + }, + } + ) + ).toBe('https://api.example.com/users?active=true&verified=false&limit=10'); + }); + + it('should correctly serialize query parameters with numeric values', () => { + expect( + urlSerializer( + { url: '/products', method: 'get' }, + { + baseUrl: 'https://api.example.com', + parameters: { + query: { + price: 0, + discount: 25.5, + quantity: -1, + maxPrice: 1000, + }, + }, + } + ) + ).toBe( + 'https://api.example.com/products?price=0&discount=25.5&quantity=-1&maxPrice=1000' + ); + }); + + it('should correctly serialize query parameters with special numeric values', () => { + expect( + urlSerializer( + { url: '/data', method: 'get' }, + { + baseUrl: 'https://api.example.com', + parameters: { + query: { + infinity: Infinity, + negativeInfinity: -Infinity, + notANumber: NaN, + normal: 42, + }, + }, + } + ) + ).toBe( + 'https://api.example.com/data?infinity=Infinity&negativeInfinity=-Infinity¬ANumber=NaN&normal=42' + ); + }); + + it('should correctly serialize query parameters with empty string values', () => { + expect( + urlSerializer( + { url: '/search', method: 'get' }, + { + baseUrl: 'https://api.example.com', + parameters: { + query: { + query: '', + category: 'books', + sort: null, + }, + }, + } + ) + ).toBe('https://api.example.com/search?query=&category=books'); + }); + + it('should correctly serialize deeply nested objects', () => { + expect( + urlSerializer( + { url: '/complex', method: 'get' }, + { + baseUrl: 'https://api.example.com', + parameters: { + query: { + level1: { + level2: { + level3: { + value: 'deep', + number: 42, + }, + array: ['a', 'b'], + }, + }, + simple: 'value', + }, + }, + } + ) + ).toBe( + 'https://api.example.com/complex?level1%5Blevel2%5D%5Blevel3%5D%5Bvalue%5D=deep&level1%5Blevel2%5D%5Blevel3%5D%5Bnumber%5D=42&level1%5Blevel2%5D%5Barray%5D=a&level1%5Blevel2%5D%5Barray%5D=b&simple=value' + ); + }); + + it('should correctly serialize mixed arrays with different types', () => { + const testDate = new Date('2023-06-15T12:00:00.000Z'); + expect( + urlSerializer( + { url: '/mixed', method: 'get' }, + { + baseUrl: 'https://api.example.com', + parameters: { + query: { + mixed: ['string', 123, true, testDate, null, undefined], + clean: 'value', + }, + }, + } + ) + ).toBe( + 'https://api.example.com/mixed?mixed=string&mixed=123&mixed=true&mixed=2023-06-15T12%3A00%3A00.000Z&clean=value' + ); + }); + + it('should handle objects with arrays containing objects', () => { + expect( + urlSerializer( + { url: '/nested-array-objects', method: 'get' }, + { + baseUrl: 'https://api.example.com', + parameters: { + query: { + items: [ + { name: 'item1', value: 100 }, + { name: 'item2', value: null }, + ], + total: 2, + }, + }, + } + ) + ).toBe( + 'https://api.example.com/nested-array-objects?items%5Bname%5D=item1&items%5Bvalue%5D=100&items%5Bname%5D=item2&total=2' + ); + }); + + it('should handle edge cases with String() conversion', () => { + // Test to verify correct processing of different types of data + expect( + urlSerializer( + { url: '/edge-cases', method: 'get' }, + { + baseUrl: 'https://api.example.com', + parameters: { + query: { + bigint: BigInt(123), + symbol: 'symbol-as-string', // Symbol cannot be serialized directly + func: '[Function]', // Functions should not get into query, but if they do - as a string + }, + }, + } + ) + ).toBe( + 'https://api.example.com/edge-cases?bigint=123&symbol=symbol-as-string&func=%5BFunction%5D' + ); + }); });