diff --git a/docs/content/docs/reference/configuration/output.mdx b/docs/content/docs/reference/configuration/output.mdx index 5c6361301c..bd052c3719 100644 --- a/docs/content/docs/reference/configuration/output.mdx +++ b/docs/content/docs/reference/configuration/output.mdx @@ -1694,7 +1694,9 @@ Make all parameters optional except path parameters. **Type:** `Boolean` **Default:** `false` -Enable URL encoding of path/query parameters. +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.). + +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. ### optionsParamRequired diff --git a/packages/core/src/getters/route.test.ts b/packages/core/src/getters/route.test.ts index 0b90e2a517..b77054134c 100644 --- a/packages/core/src/getters/route.test.ts +++ b/packages/core/src/getters/route.test.ts @@ -11,6 +11,8 @@ import { getFullRoute, getRoute, getRouteAsArray, + makeRouteSafe, + wrapRouteParameters, } from './route'; describe('getRoute getter', () => { @@ -267,3 +269,57 @@ describe('getRouteAsArray getter', () => { expect(getRouteAsArray(input)).toEqual(output); }); }); + +describe('wrapRouteParameters', () => { + it('wraps parameters correctly', () => { + const result = wrapRouteParameters( + '/user/${id}/profile', + 'prefix-', + '-suffix', + ); + expect(result).toBe('/user/${prefix-id-suffix}/profile'); + }); + + it('handles no parameters gracefully', () => { + const result = wrapRouteParameters('/user/profile', 'prefix-', '-suffix'); + expect(result).toBe('/user/profile'); + }); + + it('handles empty route', () => { + const result = wrapRouteParameters('', 'prefix-', '-suffix'); + expect(result).toBe(''); + }); +}); + +describe('makeRouteSafe', () => { + it('encodes URI components in parameters', () => { + const result = makeRouteSafe('/search/${query}/bla/${something}'); + expect(result).toBe( + '/search/${encodeURIComponent(String(query))}/bla/${encodeURIComponent(String(something))}', + ); + }); + + it('encodes adjacent parameters separately', () => { + const result = makeRouteSafe('/x/${a}${b}'); + expect(result).toBe( + '/x/${encodeURIComponent(String(a))}${encodeURIComponent(String(b))}', + ); + }); + + it('encodes parameters mixed with literal separators', () => { + const result = makeRouteSafe('/files/${name}.${ext}'); + expect(result).toBe( + '/files/${encodeURIComponent(String(name))}.${encodeURIComponent(String(ext))}', + ); + }); + + it('handles no special characters gracefully', () => { + const result = makeRouteSafe('/search/query'); + expect(result).toBe('/search/query'); + }); + + it('handles empty route', () => { + const result = makeRouteSafe(''); + expect(result).toBe(''); + }); +}); diff --git a/packages/core/src/getters/route.ts b/packages/core/src/getters/route.ts index aefa61ac7d..3652039d2e 100644 --- a/packages/core/src/getters/route.ts +++ b/packages/core/src/getters/route.ts @@ -1,3 +1,4 @@ +import { TEMPLATE_TAG_REGEX } from '../constants'; import type { BaseUrlFromConstant, BaseUrlFromSpec, @@ -145,6 +146,17 @@ export function getBaseUrlRuntimeImports( })); } +// Emits a codegen string: wraps each `${param}` segment of a template-literal +// route so the generated client encodes path parameters at request time. +export const wrapRouteParameters = ( + route: string, + prepend: string, + append: string, +): string => route.replaceAll(TEMPLATE_TAG_REGEX, `\${${prepend}$1${append}}`); + +export const makeRouteSafe = (route: string): string => + wrapRouteParameters(route, 'encodeURIComponent(String(', '))'); + // Creates a mixed use array with path variables and string from template string route export function getRouteAsArray(route: string): string { return route diff --git a/packages/fetch/src/index.ts b/packages/fetch/src/index.ts index c1d637b10a..2bcfb08209 100644 --- a/packages/fetch/src/index.ts +++ b/packages/fetch/src/index.ts @@ -11,6 +11,7 @@ import { type GeneratorVerbOptions, GetterPropType, isObject, + makeRouteSafe, type OpenApiParameterObject, type OpenApiReferenceObject, type OpenApiSchemaObject, @@ -68,8 +69,14 @@ export const generateRequestFunction = ( override, doc, }: GeneratorVerbOptions, - { route, context, pathRoute }: GeneratorOptions, + { route: _route, context, pathRoute }: GeneratorOptions, ) => { + let route = _route; + + if (context.output.urlEncodeParameters) { + route = makeRouteSafe(route); + } + const isRequestOptions = override.requestOptions !== false; const isFormData = !override.formData.disabled; const isFormUrlEncoded = override.formUrlEncoded !== false; diff --git a/packages/query/src/client.ts b/packages/query/src/client.ts index a418da50fd..ebcb5f3956 100644 --- a/packages/query/src/client.ts +++ b/packages/query/src/client.ts @@ -15,6 +15,7 @@ import { isObject, isSyntheticDefaultImportsAllow, kebab, + makeRouteSafe, OutputHttpClient, pascal, toObjectString, @@ -24,12 +25,7 @@ import { generateRequestFunction as generateFetchRequestFunction, } from '@orval/fetch'; -import { - getHasSignal, - makeRouteSafe, - vueUnRefParams, - vueWrapTypeWithMaybeRef, -} from './utils'; +import { getHasSignal, vueUnRefParams, vueWrapTypeWithMaybeRef } from './utils'; export const AXIOS_DEPENDENCIES = [ { diff --git a/packages/query/src/utils.test.ts b/packages/query/src/utils.test.ts index 082ea7784d..3f62942f48 100644 --- a/packages/query/src/utils.test.ts +++ b/packages/query/src/utils.test.ts @@ -1,50 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - makeRouteSafe, - normalizeQueryOptions, - wrapRouteParameters, -} from './utils'; - -describe('wrapRouteParameters', () => { - it('wraps parameters correctly', () => { - const result = wrapRouteParameters( - '/user/${id}/profile', - 'prefix-', - '-suffix', - ); - expect(result).toBe('/user/${prefix-id-suffix}/profile'); - }); - - it('handles no parameters gracefully', () => { - const result = wrapRouteParameters('/user/profile', 'prefix-', '-suffix'); - expect(result).toBe('/user/profile'); - }); - - it('handles empty route', () => { - const result = wrapRouteParameters('', 'prefix-', '-suffix'); - expect(result).toBe(''); - }); -}); - -describe('makeRouteSafe', () => { - it('encodes URI components in parameters', () => { - const result = makeRouteSafe('/search/${query}/bla/${something}'); - expect(result).toBe( - '/search/${encodeURIComponent(String(query))}/bla/${encodeURIComponent(String(something))}', - ); - }); - - it('handles no special characters gracefully', () => { - const result = makeRouteSafe('/search/query'); - expect(result).toBe('/search/query'); - }); - - it('handles empty route', () => { - const result = makeRouteSafe(''); - expect(result).toBe(''); - }); -}); +import { normalizeQueryOptions } from './utils'; describe('normalizeQueryOptions', () => { it('should include useOperationIdAsQueryKey when provided', () => { diff --git a/packages/query/src/utils.ts b/packages/query/src/utils.ts index 31efee65ad..e1cb1a27d6 100644 --- a/packages/query/src/utils.ts +++ b/packages/query/src/utils.ts @@ -12,7 +12,6 @@ import { OutputClient, type OutputClientFunc, type QueryOptions, - TEMPLATE_TAG_REGEX, } from '@orval/core'; export const normalizeQueryOptions = ( @@ -137,15 +136,6 @@ export const vueUnRefParams = (props: GetterProps): string => { .join('\n'); }; -export const wrapRouteParameters = ( - route: string, - prepend: string, - append: string, -): string => route.replaceAll(TEMPLATE_TAG_REGEX, `\${${prepend}$1${append}}`); - -export const makeRouteSafe = (route: string): string => - wrapRouteParameters(route, 'encodeURIComponent(String(', '))'); - export const isVue = (client: OutputClient | OutputClientFunc) => OutputClient.VUE_QUERY === client; diff --git a/tests/__snapshots__/fetch/dateParams/pets/pets.ts b/tests/__snapshots__/fetch/dateParams/pets/pets.ts index 36f61329ed..f6223893d0 100644 --- a/tests/__snapshots__/fetch/dateParams/pets/pets.ts +++ b/tests/__snapshots__/fetch/dateParams/pets/pets.ts @@ -100,8 +100,8 @@ export const getListPetsByCountryUrl = ( const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 - ? `/pets-by-country/${country}?${stringifiedParams}` - : `/pets-by-country/${country}`; + ? `/pets-by-country/${encodeURIComponent(String(country))}?${stringifiedParams}` + : `/pets-by-country/${encodeURIComponent(String(country))}`; }; /** @@ -170,8 +170,8 @@ export const getListPetsByAgeUrl = ( const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 - ? `/pets-by-age/${age}?${stringifiedParams}` - : `/pets-by-age/${age}`; + ? `/pets-by-age/${encodeURIComponent(String(age))}?${stringifiedParams}` + : `/pets-by-age/${encodeURIComponent(String(age))}`; }; /** diff --git a/tests/__snapshots__/fetch/url-encode-parameters/endpoints.ts b/tests/__snapshots__/fetch/url-encode-parameters/endpoints.ts new file mode 100644 index 0000000000..d04e5964a5 --- /dev/null +++ b/tests/__snapshots__/fetch/url-encode-parameters/endpoints.ts @@ -0,0 +1,500 @@ +/** + * Generated by orval v8.11.0 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +export type PetCallingCode = + (typeof PetCallingCode)[keyof typeof PetCallingCode]; + +export const PetCallingCode = { + '+33': '+33', + '+420': '+420', +} as const; + +export type PetCountry = (typeof PetCountry)[keyof typeof PetCountry]; + +export const PetCountry = { + "People's_Republic_of_China": "People's Republic of China", + Uruguay: 'Uruguay', +} as const; + +export type LabradoodleBreed = + (typeof LabradoodleBreed)[keyof typeof LabradoodleBreed]; + +export const LabradoodleBreed = { + Labradoodle: 'Labradoodle', +} as const; + +export interface Labradoodle { + cuteness: number; + breed: LabradoodleBreed; +} + +export type DachshundBreed = + (typeof DachshundBreed)[keyof typeof DachshundBreed]; + +export const DachshundBreed = { + Dachshund: 'Dachshund', +} as const; + +export interface Dachshund { + length: number; + breed: DachshundBreed; +} + +export type DogType = (typeof DogType)[keyof typeof DogType]; + +export const DogType = { + dog: 'dog', +} as const; + +export type Dog = + | (Labradoodle & { + barksPerMinute?: number; + type: DogType; + }) + | (Dachshund & { + barksPerMinute?: number; + type: DogType; + }); + +export type CatType = (typeof CatType)[keyof typeof CatType]; + +export const CatType = { + cat: 'cat', +} as const; + +export interface Cat { + petsRequested?: number; + type: CatType; +} + +export type Pet = + | (Dog & { + '@id'?: string; + id: number; + name: string; + tag?: string; + email?: string; + callingCode?: PetCallingCode; + country?: PetCountry; + }) + | (Cat & { + '@id'?: string; + id: number; + name: string; + tag?: string; + email?: string; + callingCode?: PetCallingCode; + country?: PetCountry; + }); + +export type Pets = Pet[]; + +export interface Error { + code: number; + message: string; +} + +export interface PetWithTag { + tag: string; + pet: Pet | null; +} + +export type ListPetsParams = { + /** + * How many items to return at one time (max 100) + */ + limit?: string; + /** + * Which property to sort by? + * Example: name sorts ASC while -name sorts DESC. + * + */ + sort: ListPetsSort; +}; + +export type ListPetsSort = (typeof ListPetsSort)[keyof typeof ListPetsSort]; + +export const ListPetsSort = { + name: 'name', + '-name': '-name', + email: 'email', + '-email': '-email', +} as const; + +export type CreatePetsParams = { + /** + * How many items to return at one time (max 100) + */ + limit?: string; + /** + * Which property to sort by? + * Example: name sorts ASC while -name sorts DESC. + * + */ + sort: CreatePetsSort; +}; + +export type CreatePetsSort = + (typeof CreatePetsSort)[keyof typeof CreatePetsSort]; + +export const CreatePetsSort = { + name: 'name', + '-name': '-name', + email: 'email', + '-email': '-email', +} as const; + +export type CreatePetsBody = { + name: string; + tag: string; +}; + +export type HTTPStatusCode1xx = 100 | 101 | 102 | 103; +export type HTTPStatusCode2xx = 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207; +export type HTTPStatusCode3xx = 300 | 301 | 302 | 303 | 304 | 305 | 307 | 308; +export type HTTPStatusCode4xx = + | 400 + | 401 + | 402 + | 403 + | 404 + | 405 + | 406 + | 407 + | 408 + | 409 + | 410 + | 411 + | 412 + | 413 + | 414 + | 415 + | 416 + | 417 + | 418 + | 419 + | 420 + | 421 + | 422 + | 423 + | 424 + | 426 + | 428 + | 429 + | 431 + | 451; +export type HTTPStatusCode5xx = 500 | 501 | 502 | 503 | 504 | 505 | 507 | 511; +export type HTTPStatusCodes = + | HTTPStatusCode1xx + | HTTPStatusCode2xx + | HTTPStatusCode3xx + | HTTPStatusCode4xx + | HTTPStatusCode5xx; + +export type listPetsResponse200 = { + data: Pets; + status: 200; +}; + +export type listPetsResponseDefault = { + data: Error; + status: Exclude; +}; + +export type listPetsResponseSuccess = listPetsResponse200 & { + headers: Headers; +}; +export type listPetsResponseError = listPetsResponseDefault & { + headers: Headers; +}; + +export type listPetsResponse = listPetsResponseSuccess | listPetsResponseError; + +export const getListPetsUrl = (params: ListPetsParams) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : String(value)); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 ? `/pets?${stringifiedParams}` : `/pets`; +}; + +/** + * @summary List all pets + */ +export const listPets = async ( + params: ListPetsParams, + options?: RequestInit, +): Promise => { + const res = await fetch(getListPetsUrl(params), { + ...options, + method: 'GET', + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: listPetsResponse['data'] = body ? JSON.parse(body) : {}; + return { data, status: res.status, headers: res.headers } as listPetsResponse; +}; + +export type createPetsResponse200 = { + data: Pet; + status: 200; +}; + +export type createPetsResponseDefault = { + data: Error; + status: Exclude; +}; + +export type createPetsResponseSuccess = createPetsResponse200 & { + headers: Headers; +}; +export type createPetsResponseError = createPetsResponseDefault & { + headers: Headers; +}; + +export type createPetsResponse = + | createPetsResponseSuccess + | createPetsResponseError; + +export const getCreatePetsUrl = (params: CreatePetsParams) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : String(value)); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 ? `/pets?${stringifiedParams}` : `/pets`; +}; + +/** + * @summary Create a pet + */ +export const createPets = async ( + createPetsBody: CreatePetsBody, + params: CreatePetsParams, + options?: RequestInit, +): Promise => { + const res = await fetch(getCreatePetsUrl(params), { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(createPetsBody), + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: createPetsResponse['data'] = body ? JSON.parse(body) : {}; + return { + data, + status: res.status, + headers: res.headers, + } as createPetsResponse; +}; + +export type showPetByIdResponse200 = { + data: Pet; + status: 200; +}; + +export type showPetByIdResponseDefault = { + data: Error; + status: Exclude; +}; + +export type showPetByIdResponseSuccess = showPetByIdResponse200 & { + headers: Headers; +}; +export type showPetByIdResponseError = showPetByIdResponseDefault & { + headers: Headers; +}; + +export type showPetByIdResponse = + | showPetByIdResponseSuccess + | showPetByIdResponseError; + +export const getShowPetByIdUrl = (petId: string) => { + return `/pets/${encodeURIComponent(String(petId))}`; +}; + +/** + * @summary Info for a specific pet + */ +export const showPetById = async ( + petId: string, + options?: RequestInit, +): Promise => { + const res = await fetch(getShowPetByIdUrl(petId), { + ...options, + method: 'GET', + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: showPetByIdResponse['data'] = body ? JSON.parse(body) : {}; + return { + data, + status: res.status, + headers: res.headers, + } as showPetByIdResponse; +}; + +export type deletePetByIdResponse204 = { + data: void; + status: 204; +}; + +export type deletePetByIdResponseDefault = { + data: Error; + status: Exclude; +}; + +export type deletePetByIdResponseSuccess = deletePetByIdResponse204 & { + headers: Headers; +}; +export type deletePetByIdResponseError = deletePetByIdResponseDefault & { + headers: Headers; +}; + +export type deletePetByIdResponse = + | deletePetByIdResponseSuccess + | deletePetByIdResponseError; + +export const getDeletePetByIdUrl = (petId: string) => { + return `/pets/${encodeURIComponent(String(petId))}`; +}; + +/** + * @summary Deletes a specific pet + */ +export const deletePetById = async ( + petId: string, + options?: RequestInit, +): Promise => { + const res = await fetch(getDeletePetByIdUrl(petId), { + ...options, + method: 'DELETE', + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: deletePetByIdResponse['data'] = body + ? JSON.parse(body) + : undefined; + return { + data, + status: res.status, + headers: res.headers, + } as deletePetByIdResponse; +}; + +export type healthCheckResponse200 = { + data: string; + status: 200; +}; + +export type healthCheckResponseDefault = { + data: Error; + status: Exclude; +}; + +export type healthCheckResponseSuccess = healthCheckResponse200 & { + headers: Headers; +}; +export type healthCheckResponseError = healthCheckResponseDefault & { + headers: Headers; +}; + +export type healthCheckResponse = + | healthCheckResponseSuccess + | healthCheckResponseError; + +export const getHealthCheckUrl = () => { + return `/health`; +}; + +/** + * @summary health check + */ +export const healthCheck = async ( + options?: RequestInit, +): Promise => { + const res = await fetch(getHealthCheckUrl(), { + ...options, + method: 'GET', + }); + + const contentType = (res.headers.get('content-type') ?? '').toLowerCase(); + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: healthCheckResponse['data'] = body + ? contentType.includes('json') + ? JSON.parse(body) + : body + : {}; + return { + data, + status: res.status, + headers: res.headers, + } as healthCheckResponse; +}; + +export type showPetWithOwnerResponse200 = { + data: PetWithTag; + status: 200; +}; + +export type showPetWithOwnerResponseDefault = { + data: Error; + status: Exclude; +}; + +export type showPetWithOwnerResponseSuccess = showPetWithOwnerResponse200 & { + headers: Headers; +}; +export type showPetWithOwnerResponseError = showPetWithOwnerResponseDefault & { + headers: Headers; +}; + +export type showPetWithOwnerResponse = + | showPetWithOwnerResponseSuccess + | showPetWithOwnerResponseError; + +export const getShowPetWithOwnerUrl = (petId: string) => { + return `/pets/${encodeURIComponent(String(petId))}/owner`; +}; + +/** + * @summary combinate nullable and $ref + */ +export const showPetWithOwner = async ( + petId: string, + options?: RequestInit, +): Promise => { + const res = await fetch(getShowPetWithOwnerUrl(petId), { + ...options, + method: 'GET', + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: showPetWithOwnerResponse['data'] = body ? JSON.parse(body) : {}; + return { + data, + status: res.status, + headers: res.headers, + } as showPetWithOwnerResponse; +}; diff --git a/tests/__snapshots__/vue-query/combination-used-by-maxim-mazurok/endpoints.ts b/tests/__snapshots__/vue-query/combination-used-by-maxim-mazurok/endpoints.ts index 0f834108ec..91e6973ec3 100644 --- a/tests/__snapshots__/vue-query/combination-used-by-maxim-mazurok/endpoints.ts +++ b/tests/__snapshots__/vue-query/combination-used-by-maxim-mazurok/endpoints.ts @@ -350,7 +350,7 @@ export type showPetByIdResponse = | showPetByIdResponseError; export const getShowPetByIdUrl = (petId: string | undefined | null) => { - return `/pets/${petId}`; + return `/pets/${encodeURIComponent(String(petId))}`; }; /** @@ -473,7 +473,7 @@ export type deletePetByIdResponse = | deletePetByIdResponseError; export const getDeletePetByIdUrl = (petId: string | undefined | null) => { - return `/pets/${petId}`; + return `/pets/${encodeURIComponent(String(petId))}`; }; /** @@ -707,7 +707,7 @@ export type showPetWithOwnerResponse = | showPetWithOwnerResponseError; export const getShowPetWithOwnerUrl = (petId: string | undefined | null) => { - return `/pets/${petId}/owner`; + return `/pets/${encodeURIComponent(String(petId))}/owner`; }; /** diff --git a/tests/__snapshots__/vue-query/url-encode-parameters/endpoints.ts b/tests/__snapshots__/vue-query/url-encode-parameters/endpoints.ts index 17febb345f..454b27e735 100644 --- a/tests/__snapshots__/vue-query/url-encode-parameters/endpoints.ts +++ b/tests/__snapshots__/vue-query/url-encode-parameters/endpoints.ts @@ -350,7 +350,7 @@ export type showPetByIdResponse = | showPetByIdResponseError; export const getShowPetByIdUrl = (petId: string) => { - return `/pets/${petId}`; + return `/pets/${encodeURIComponent(String(petId))}`; }; /** @@ -471,7 +471,7 @@ export type deletePetByIdResponse = | deletePetByIdResponseError; export const getDeletePetByIdUrl = (petId: string) => { - return `/pets/${petId}`; + return `/pets/${encodeURIComponent(String(petId))}`; }; /** @@ -705,7 +705,7 @@ export type showPetWithOwnerResponse = | showPetWithOwnerResponseError; export const getShowPetWithOwnerUrl = (petId: string) => { - return `/pets/${petId}/owner`; + return `/pets/${encodeURIComponent(String(petId))}/owner`; }; /** diff --git a/tests/configs/fetch.config.ts b/tests/configs/fetch.config.ts index 2f4f950d43..f7541a25bb 100644 --- a/tests/configs/fetch.config.ts +++ b/tests/configs/fetch.config.ts @@ -301,6 +301,18 @@ export default defineConfig({ target: '../specifications/parameters.yaml', }, }, + urlEncodeParameters: { + output: { + target: '../generated/fetch/url-encode-parameters/endpoints.ts', + client: 'fetch', + urlEncodeParameters: true, + clean: true, + formatter: 'prettier', + }, + input: { + target: '../specifications/petstore.yaml', + }, + }, usedatesOnlyDateParams: { output: { target: '../generated/fetch/usedates-only-date-params/endpoints.ts',