Skip to content

Commit 90532f4

Browse files
authored
fix(fetch): restore urlEncodeParameters support for the fetch client (#3343) (#3405)
* refactor(core,query): relocate makeRouteSafe to @orval/core Move makeRouteSafe and wrapRouteParameters from @orval/query/utils into @orval/core/getters/route so non-query packages can reuse them without introducing a reverse dependency on @orval/query. Update query/src/client.ts to import makeRouteSafe from @orval/core and move the related unit tests to core/getters/route.test.ts. Behavior is unchanged. This prepares for the follow-up fix that restores urlEncodeParameters support in @orval/fetch (#3343). Signed-off-by: zeriong <jaeryong95@gmail.com> * fix(fetch): restore urlEncodeParameters support (#3343) The fetch client generator stopped honoring the `urlEncodeParameters` output option after commit cb06139 (#2168) accidentally dropped the guard introduced by #2292. Generated URL helpers interpolated path parameters as-is, so values containing reserved characters (`/`, `#`, `?`, ...) leaked into the request URL. Re-apply `makeRouteSafe(route)` at the top of `generateRequestFunction` in `@orval/fetch`, mirroring the axios path in `@orval/query`. This wraps each `\${param}` segment with `encodeURIComponent(String(...))`. Query parameters remain handled by `URLSearchParams` as before; this option only affects path parameters. Add a dedicated fetch + `urlEncodeParameters` snapshot fixture so a future regression in the fetch path won't be silently absorbed by the `dateParams` fixture. * docs(output): clarify urlEncodeParameters scope and limitations State explicitly that the option wraps only path parameters (query parameters are already encoded by the underlying client) and note that array/object path parameters fall back to `String(value)` instead of following their OpenAPI `style` serialization, since orval does not generate `style: simple|matrix|label` for path parameters. * test(fetch): simplify urlEncodeParameters fixture to avoid CI snapshot drift The model files generated by this fixture exhibited a JSDoc reflow only on CI (Ubuntu) that didn't reproduce locally, while the same petstore spec under other fixtures stayed stable. Drop `schemas` and `mock` from the fixture so it only emits `endpoints.ts`; that single file still exercises the regression covered by #3343 — `${encodeURIComponent( String(petId))}` is asserted in the URL helper output. * test(fetch): refresh urlEncodeParameters snapshot for new JSDoc reflow After the master merge of #3393 (`fix(core): handle multi-line descriptions in JSDoc generation`), the core now emits multi-line JSDoc with proper ` * ` line prefixes. Regenerate the snapshot for this PR's new fixture so it matches the post-#3393 output. The `encodeURIComponent(String(petId))` assertions covering #3343 are preserved. --------- Signed-off-by: zeriong <jaeryong95@gmail.com>
1 parent cb9698d commit 90532f4

12 files changed

Lines changed: 604 additions & 73 deletions

File tree

docs/content/docs/reference/configuration/output.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1694,7 +1694,9 @@ Make all parameters optional except path parameters.
16941694
**Type:** `Boolean`
16951695
**Default:** `false`
16961696

1697-
Enable URL encoding of path/query parameters.
1697+
Wrap each path parameter with `encodeURIComponent(String(...))` in generated URL helpers. This option only affects path parameters; query parameters are typically encoded by the underlying client (`URLSearchParams`, `axios`, etc.).
1698+
1699+
Path parameters are stringified via `String(value)` before encoding, so array (`style: simple|matrix|label`) and object path parameters are not serialized according to their OpenAPI `style` — they fall back to the default `String(value)` representation.
16981700

16991701
### optionsParamRequired
17001702

packages/core/src/getters/route.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
getFullRoute,
1212
getRoute,
1313
getRouteAsArray,
14+
makeRouteSafe,
15+
wrapRouteParameters,
1416
} from './route';
1517

1618
describe('getRoute getter', () => {
@@ -267,3 +269,57 @@ describe('getRouteAsArray getter', () => {
267269
expect(getRouteAsArray(input)).toEqual(output);
268270
});
269271
});
272+
273+
describe('wrapRouteParameters', () => {
274+
it('wraps parameters correctly', () => {
275+
const result = wrapRouteParameters(
276+
'/user/${id}/profile',
277+
'prefix-',
278+
'-suffix',
279+
);
280+
expect(result).toBe('/user/${prefix-id-suffix}/profile');
281+
});
282+
283+
it('handles no parameters gracefully', () => {
284+
const result = wrapRouteParameters('/user/profile', 'prefix-', '-suffix');
285+
expect(result).toBe('/user/profile');
286+
});
287+
288+
it('handles empty route', () => {
289+
const result = wrapRouteParameters('', 'prefix-', '-suffix');
290+
expect(result).toBe('');
291+
});
292+
});
293+
294+
describe('makeRouteSafe', () => {
295+
it('encodes URI components in parameters', () => {
296+
const result = makeRouteSafe('/search/${query}/bla/${something}');
297+
expect(result).toBe(
298+
'/search/${encodeURIComponent(String(query))}/bla/${encodeURIComponent(String(something))}',
299+
);
300+
});
301+
302+
it('encodes adjacent parameters separately', () => {
303+
const result = makeRouteSafe('/x/${a}${b}');
304+
expect(result).toBe(
305+
'/x/${encodeURIComponent(String(a))}${encodeURIComponent(String(b))}',
306+
);
307+
});
308+
309+
it('encodes parameters mixed with literal separators', () => {
310+
const result = makeRouteSafe('/files/${name}.${ext}');
311+
expect(result).toBe(
312+
'/files/${encodeURIComponent(String(name))}.${encodeURIComponent(String(ext))}',
313+
);
314+
});
315+
316+
it('handles no special characters gracefully', () => {
317+
const result = makeRouteSafe('/search/query');
318+
expect(result).toBe('/search/query');
319+
});
320+
321+
it('handles empty route', () => {
322+
const result = makeRouteSafe('');
323+
expect(result).toBe('');
324+
});
325+
});

packages/core/src/getters/route.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { TEMPLATE_TAG_REGEX } from '../constants';
12
import type {
23
BaseUrlFromConstant,
34
BaseUrlFromSpec,
@@ -145,6 +146,17 @@ export function getBaseUrlRuntimeImports(
145146
}));
146147
}
147148

149+
// Emits a codegen string: wraps each `${param}` segment of a template-literal
150+
// route so the generated client encodes path parameters at request time.
151+
export const wrapRouteParameters = (
152+
route: string,
153+
prepend: string,
154+
append: string,
155+
): string => route.replaceAll(TEMPLATE_TAG_REGEX, `\${${prepend}$1${append}}`);
156+
157+
export const makeRouteSafe = (route: string): string =>
158+
wrapRouteParameters(route, 'encodeURIComponent(String(', '))');
159+
148160
// Creates a mixed use array with path variables and string from template string route
149161
export function getRouteAsArray(route: string): string {
150162
return route

packages/fetch/src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type GeneratorVerbOptions,
1212
GetterPropType,
1313
isObject,
14+
makeRouteSafe,
1415
type OpenApiParameterObject,
1516
type OpenApiReferenceObject,
1617
type OpenApiSchemaObject,
@@ -68,8 +69,14 @@ export const generateRequestFunction = (
6869
override,
6970
doc,
7071
}: GeneratorVerbOptions,
71-
{ route, context, pathRoute }: GeneratorOptions,
72+
{ route: _route, context, pathRoute }: GeneratorOptions,
7273
) => {
74+
let route = _route;
75+
76+
if (context.output.urlEncodeParameters) {
77+
route = makeRouteSafe(route);
78+
}
79+
7380
const isRequestOptions = override.requestOptions !== false;
7481
const isFormData = !override.formData.disabled;
7582
const isFormUrlEncoded = override.formUrlEncoded !== false;

packages/query/src/client.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
isObject,
1616
isSyntheticDefaultImportsAllow,
1717
kebab,
18+
makeRouteSafe,
1819
OutputHttpClient,
1920
pascal,
2021
toObjectString,
@@ -24,12 +25,7 @@ import {
2425
generateRequestFunction as generateFetchRequestFunction,
2526
} from '@orval/fetch';
2627

27-
import {
28-
getHasSignal,
29-
makeRouteSafe,
30-
vueUnRefParams,
31-
vueWrapTypeWithMaybeRef,
32-
} from './utils';
28+
import { getHasSignal, vueUnRefParams, vueWrapTypeWithMaybeRef } from './utils';
3329

3430
export const AXIOS_DEPENDENCIES = [
3531
{

packages/query/src/utils.test.ts

Lines changed: 1 addition & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,6 @@
11
import { describe, expect, it } from 'vitest';
22

3-
import {
4-
makeRouteSafe,
5-
normalizeQueryOptions,
6-
wrapRouteParameters,
7-
} from './utils';
8-
9-
describe('wrapRouteParameters', () => {
10-
it('wraps parameters correctly', () => {
11-
const result = wrapRouteParameters(
12-
'/user/${id}/profile',
13-
'prefix-',
14-
'-suffix',
15-
);
16-
expect(result).toBe('/user/${prefix-id-suffix}/profile');
17-
});
18-
19-
it('handles no parameters gracefully', () => {
20-
const result = wrapRouteParameters('/user/profile', 'prefix-', '-suffix');
21-
expect(result).toBe('/user/profile');
22-
});
23-
24-
it('handles empty route', () => {
25-
const result = wrapRouteParameters('', 'prefix-', '-suffix');
26-
expect(result).toBe('');
27-
});
28-
});
29-
30-
describe('makeRouteSafe', () => {
31-
it('encodes URI components in parameters', () => {
32-
const result = makeRouteSafe('/search/${query}/bla/${something}');
33-
expect(result).toBe(
34-
'/search/${encodeURIComponent(String(query))}/bla/${encodeURIComponent(String(something))}',
35-
);
36-
});
37-
38-
it('handles no special characters gracefully', () => {
39-
const result = makeRouteSafe('/search/query');
40-
expect(result).toBe('/search/query');
41-
});
42-
43-
it('handles empty route', () => {
44-
const result = makeRouteSafe('');
45-
expect(result).toBe('');
46-
});
47-
});
3+
import { normalizeQueryOptions } from './utils';
484

495
describe('normalizeQueryOptions', () => {
506
it('should include useOperationIdAsQueryKey when provided', () => {

packages/query/src/utils.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
OutputClient,
1313
type OutputClientFunc,
1414
type QueryOptions,
15-
TEMPLATE_TAG_REGEX,
1615
} from '@orval/core';
1716

1817
export const normalizeQueryOptions = (
@@ -137,15 +136,6 @@ export const vueUnRefParams = (props: GetterProps): string => {
137136
.join('\n');
138137
};
139138

140-
export const wrapRouteParameters = (
141-
route: string,
142-
prepend: string,
143-
append: string,
144-
): string => route.replaceAll(TEMPLATE_TAG_REGEX, `\${${prepend}$1${append}}`);
145-
146-
export const makeRouteSafe = (route: string): string =>
147-
wrapRouteParameters(route, 'encodeURIComponent(String(', '))');
148-
149139
export const isVue = (client: OutputClient | OutputClientFunc) =>
150140
OutputClient.VUE_QUERY === client;
151141

tests/__snapshots__/fetch/dateParams/pets/pets.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,8 @@ export const getListPetsByCountryUrl = (
100100
const stringifiedParams = normalizedParams.toString();
101101

102102
return stringifiedParams.length > 0
103-
? `/pets-by-country/${country}?${stringifiedParams}`
104-
: `/pets-by-country/${country}`;
103+
? `/pets-by-country/${encodeURIComponent(String(country))}?${stringifiedParams}`
104+
: `/pets-by-country/${encodeURIComponent(String(country))}`;
105105
};
106106

107107
/**
@@ -170,8 +170,8 @@ export const getListPetsByAgeUrl = (
170170
const stringifiedParams = normalizedParams.toString();
171171

172172
return stringifiedParams.length > 0
173-
? `/pets-by-age/${age}?${stringifiedParams}`
174-
: `/pets-by-age/${age}`;
173+
? `/pets-by-age/${encodeURIComponent(String(age))}?${stringifiedParams}`
174+
: `/pets-by-age/${encodeURIComponent(String(age))}`;
175175
};
176176

177177
/**

0 commit comments

Comments
 (0)