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
5 changes: 5 additions & 0 deletions .changeset/famous-badgers-roll.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/light-files-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@openapi-qraft/react': minor
---

Refactor `getQueryString` function to use native `URLSearchParams` instead of custom implementation for better maintainability.
1 change: 1 addition & 0 deletions .github/actions/spelling/excludes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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$
43 changes: 15 additions & 28 deletions packages/react-client/src/lib/requestFn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -116,37 +116,24 @@ export function urlSerializer(
}

function getQueryString(params: Record<string, any>): 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)[]) {
Expand Down
265 changes: 265 additions & 0 deletions packages/react-client/src/lib/urlSerializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Comment thread Fixed
Comment thread Fixed
);
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(
Expand Down Expand Up @@ -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'
Comment thread Fixed
);
});

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,
Comment thread Fixed
normal: 42,
},
},
}
)
).toBe(
'https://api.example.com/data?infinity=Infinity&negativeInfinity=-Infinity&notANumber=NaN&normal=42'
Comment thread Fixed
);
});

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'
Comment thread Fixed
Comment thread Fixed
Comment thread Fixed
Comment thread Fixed
Comment thread Fixed
Comment thread Fixed
Comment thread Fixed
Comment thread Fixed
Comment thread Fixed
);
});

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'
Comment thread Fixed
Comment thread Fixed
Comment thread Fixed
);
});

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'
Comment thread Fixed
);
});
});
Loading