Skip to content

Commit 807ee97

Browse files
authored
Merge branch 'master' into fix/tag-identity-filterparams-3634
2 parents f02c346 + 7320d75 commit 807ee97

15 files changed

Lines changed: 549 additions & 16 deletions

File tree

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

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1822,6 +1822,35 @@ Custom JSON reviver function (useful for date parsing).
18221822

18231823
Enable Zod runtime validation for fetch client responses. Requires `schemas: { type: 'zod' }`. When enabled, JSON responses are validated via `Schema.parse()` before being returned.
18241824

1825+
### arrayFormat
1826+
1827+
**Type:** `'repeat' | 'brackets' | 'comma'`
1828+
1829+
Controls how array query parameters are serialized when the OpenAPI spec does not explicitly set `explode` on a parameter. The spec's own `explode` property always takes precedence.
1830+
1831+
| Value | Output |
1832+
|-------|--------|
1833+
| `repeat` | `?tags=a&tags=b` |
1834+
| `brackets` | `?tags[]=a&tags[]=b` |
1835+
| `comma` | `?tags=a%2Cb` |
1836+
1837+
```ts title="orval.config.ts"
1838+
export default defineConfig({
1839+
petstore: {
1840+
output: {
1841+
client: 'fetch',
1842+
override: {
1843+
fetch: {
1844+
arrayFormat: 'repeat',
1845+
},
1846+
},
1847+
},
1848+
},
1849+
});
1850+
```
1851+
1852+
For full control over serialization (including custom encoding, nested objects, etc.) use [`override.paramsSerializer`](#paramsserializer) instead.
1853+
18251854
### useRuntimeFetcher
18261855

18271856
**Type:** `Boolean`
@@ -2582,9 +2611,9 @@ export const customFormUrlEncodedFn = <Body>(body: Body): URLSearchParams => {
25822611

25832612
**Type:** `String | Object`
25842613

2585-
> **Note:** Only valid when using Axios or Angular.
2614+
> **Note:** Valid for Axios, Angular, and the fetch client.
25862615
2587-
Custom parameter serializer for query parameters:
2616+
Custom parameter serializer for query parameters. When set, the generated URL helper delegates query string building entirely to this function instead of using the built-in logic.
25882617

25892618
```ts title="orval.config.ts"
25902619
export default defineConfig({
@@ -2601,7 +2630,10 @@ export default defineConfig({
26012630
});
26022631
```
26032632

2633+
For Axios and Angular the function receives the params object and can return any value Axios/Angular accepts. For the fetch client it must return a `string` (the raw query string without the leading `?`):
2634+
26042635
```ts title="custom-params-serializer-fn.ts"
2636+
// Axios / Angular
26052637
export const customParamsSerializerFn = (
26062638
params: Record<string, any>,
26072639
): string => {
@@ -2610,6 +2642,20 @@ export const customParamsSerializerFn = (
26102642
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
26112643
.join('&');
26122644
};
2645+
2646+
// fetch client — must return a string
2647+
export const customParamsSerializer = (
2648+
params: Record<string, unknown> | undefined,
2649+
): string =>
2650+
new URLSearchParams(
2651+
Object.entries(params ?? {})
2652+
.filter(([_, v]) => v !== undefined)
2653+
.flatMap(([k, v]) =>
2654+
Array.isArray(v)
2655+
? v.map((item) => [k, String(item)])
2656+
: [[k, String(v)]],
2657+
),
2658+
).toString();
26132659
```
26142660

26152661
### paramsSerializerOptions

packages/core/src/types.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1079,6 +1079,15 @@ export interface NormalizedFetchOptions {
10791079
jsonReviver?: Mutator;
10801080
runtimeValidation: boolean;
10811081
useRuntimeFetcher: boolean;
1082+
/**
1083+
* Serialization format for array query parameters that do not have an explicit
1084+
* `explode` setting in the OpenAPI spec.
1085+
*
1086+
* - `repeat` — repeat the key for each value: `foo=1&foo=2`
1087+
* - `brackets` — append `[]` to the key: `foo[]=1&foo[]=2`
1088+
* - `comma` — join values with a comma: `foo=1,2`
1089+
*/
1090+
arrayFormat?: 'repeat' | 'brackets' | 'comma';
10821091
}
10831092

10841093
export interface FetchOptions {
@@ -1087,6 +1096,15 @@ export interface FetchOptions {
10871096
jsonReviver?: Mutator;
10881097
runtimeValidation?: boolean;
10891098
useRuntimeFetcher?: boolean;
1099+
/**
1100+
* Serialization format for array query parameters that do not have an explicit
1101+
* `explode` setting in the OpenAPI spec.
1102+
*
1103+
* - `repeat` — repeat the key for each value: `foo=1&foo=2`
1104+
* - `brackets` — append `[]` to the key: `foo[]=1&foo[]=2`
1105+
* - `comma` — join values with a comma: `foo=1,2`
1106+
*/
1107+
arrayFormat?: 'repeat' | 'brackets' | 'comma';
10901108
}
10911109

10921110
export type InputTransformerFn = (

packages/fetch/src/index.ts

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,18 @@ const FETCH_DEPENDENCIES: GeneratorDependency[] = [
5353
},
5454
];
5555

56+
/** Returns the list of generator dependencies required by the fetch client (e.g. zod). */
5657
export const getFetchDependencies = () => FETCH_DEPENDENCIES;
5758

5859
const isRawRequestBodyContentType = (contentType: string) =>
5960
contentType === 'text/plain' || isBinaryContentType(contentType);
6061

62+
/**
63+
* Generates the URL helper function and the fetch request function for a single
64+
* OpenAPI operation. Handles query-param serialization (explode, arrayFormat,
65+
* paramsSerializer), request body encoding, response parsing, and optional
66+
* runtime Zod validation.
67+
*/
6168
export const generateRequestFunction = (
6269
{
6370
queryParams,
@@ -73,6 +80,7 @@ export const generateRequestFunction = (
7380
formUrlEncoded,
7481
override,
7582
doc,
83+
paramsSerializer,
7684
}: GeneratorVerbOptions,
7785
{ route: _route, context, pathRoute }: GeneratorOptions,
7886
) => {
@@ -104,17 +112,15 @@ export const generateRequestFunction = (
104112
return schema as OpenApiParameterObject;
105113
});
106114

107-
const explodeParameters = parameterObjects.filter((parameterObject) => {
108-
if (!parameterObject.schema) {
109-
return false;
110-
}
115+
const arrayFormat = override.fetch.arrayFormat;
111116

117+
const isArrayLikeParam = (parameterObject: OpenApiParameterObject) => {
118+
if (!parameterObject.schema) return false;
112119
const { schema: schemaObject } = resolveSchemaRef(
113120
parameterObject.schema,
114121
context,
115122
);
116-
117-
const isArrayLike =
123+
return (
118124
schemaObject.type === 'array' ||
119125
(
120126
(schemaObject.oneOf as
@@ -130,16 +136,34 @@ export const generateRequestFunction = (
130136
(schemaObject.allOf as
131137
| (OpenApiSchemaObject | OpenApiReferenceObject)[]
132138
| undefined) ?? []
133-
).some((s) => resolveSchemaRef(s, context).schema.type === 'array');
134-
135-
return (
136-
parameterObject.in === 'query' && isArrayLike && parameterObject.explode
139+
).some((s) => resolveSchemaRef(s, context).schema.type === 'array')
137140
);
138-
});
141+
};
142+
143+
const explodeParameters = parameterObjects.filter(
144+
(parameterObject) =>
145+
parameterObject.in === 'query' &&
146+
isArrayLikeParam(parameterObject) &&
147+
parameterObject.explode,
148+
);
149+
150+
// Array params where the spec does not explicitly set explode — arrayFormat applies here.
151+
const arrayFormatParameters = arrayFormat
152+
? parameterObjects.filter(
153+
(parameterObject) =>
154+
parameterObject.in === 'query' &&
155+
isArrayLikeParam(parameterObject) &&
156+
parameterObject.explode === undefined,
157+
)
158+
: [];
139159

140160
const explodeParametersNames = explodeParameters.map(
141161
(parameter) => parameter.name,
142162
);
163+
const arrayFormatParametersNames = arrayFormatParameters.map(
164+
(parameter) => parameter.name,
165+
);
166+
143167
const hasExplodedDateParams =
144168
context.output.override.useDates &&
145169
explodeParameters.some((parameter) => {
@@ -151,6 +175,17 @@ export const generateRequestFunction = (
151175
return schema.format === 'date-time';
152176
});
153177

178+
const hasArrayFormatDateParams =
179+
context.output.override.useDates &&
180+
arrayFormatParameters.some((parameter) => {
181+
if (!parameter.schema) {
182+
return false;
183+
}
184+
185+
const { schema } = resolveSchemaRef(parameter.schema, context);
186+
return schema.format === 'date-time';
187+
});
188+
154189
const explodeArrayImplementation =
155190
explodeParameters.length > 0
156191
? `const explodeParameters = ${JSON.stringify(explodeParametersNames)};
@@ -164,8 +199,26 @@ export const generateRequestFunction = (
164199
`
165200
: '';
166201

202+
const arrayFormatImplementation =
203+
arrayFormatParameters.length > 0
204+
? `const arrayFormatParameters = ${JSON.stringify(arrayFormatParametersNames)};
205+
206+
if (Array.isArray(value) && arrayFormatParameters.includes(key)) {
207+
${
208+
arrayFormat === 'repeat'
209+
? `value.forEach((v) => { normalizedParams.append(key, v === null ? 'null' : ${hasArrayFormatDateParams ? 'v instanceof Date ? v.toISOString() : ' : ''}String(v)); });`
210+
: arrayFormat === 'brackets'
211+
? `value.forEach((v) => { normalizedParams.append(key + '[]', v === null ? 'null' : ${hasArrayFormatDateParams ? 'v instanceof Date ? v.toISOString() : ' : ''}String(v)); });`
212+
: `normalizedParams.append(key, value.map((v) => v === null ? 'null' : ${hasArrayFormatDateParams ? 'v instanceof Date ? v.toISOString() : ' : ''}String(v)).join(','));`
213+
}
214+
return;
215+
}
216+
`
217+
: '';
218+
167219
const isExplodeParametersOnly =
168-
explodeParameters.length === parameters.length;
220+
explodeParameters.length + arrayFormatParameters.length ===
221+
parameterObjects.filter((p) => p.in === 'query').length;
169222

170223
const hasDateParams =
171224
context.output.override.useDates &&
@@ -182,13 +235,27 @@ export const generateRequestFunction = (
182235
normalizedParams.append(key, value === null ? 'null' : ${hasDateParams ? 'value instanceof Date ? value.toISOString() : ' : ''}String(value))
183236
}`;
184237

185-
const getUrlFnImplementation = `export const ${getUrlFnName} = (${getUrlFnProps}) => {
238+
const getUrlFnImplementation = paramsSerializer
239+
? `export const ${getUrlFnName} = (${getUrlFnProps}) => {
240+
${
241+
queryParams
242+
? ` const stringifiedParams = ${paramsSerializer.name}(params);`
243+
: ''
244+
}
245+
246+
${
247+
queryParams
248+
? `return stringifiedParams.length > 0 ? \`${route}?\${stringifiedParams}\` : \`${route}\``
249+
: `return \`${route}\``
250+
}
251+
}\n`
252+
: `export const ${getUrlFnName} = (${getUrlFnProps}) => {
186253
${
187254
queryParams
188255
? ` const normalizedParams = new URLSearchParams();
189256
190257
Object.entries(params || {}).forEach(([key, value]) => {
191-
${explodeArrayImplementation}
258+
${explodeArrayImplementation}${arrayFormatImplementation}
192259
${isExplodeParametersOnly ? '' : normalParamsImplementation}
193260
});`
194261
: ''
@@ -598,6 +665,11 @@ ${override.fetch.forceSuccessResponse && hasSuccess ? '' : `export type ${respon
598665
);
599666
};
600667

668+
/**
669+
* Derives the TypeScript response type name for a fetch operation.
670+
* Returns the operation-scoped name when `includeHttpResponseReturnType` is
671+
* enabled, otherwise falls back to the success response definition name.
672+
*/
601673
export const fetchResponseTypeName = (
602674
includeHttpResponseReturnType: boolean | undefined,
603675
definitionSuccessResponse: string,
@@ -608,6 +680,7 @@ export const fetchResponseTypeName = (
608680
: definitionSuccessResponse;
609681
};
610682

683+
/** Builds the full fetch client output (imports + implementation) for one verb. */
611684
export const generateClient: ClientBuilder = (verbOptions, options) => {
612685
const isZodOutput =
613686
typeof options.context.output.schemas === 'object' &&
@@ -684,6 +757,7 @@ const HTTP_STATUS_CODE_SHARED_TYPES: SharedTypeDeclaration[] = [
684757
},
685758
];
686759

760+
/** Emits HTTP status-code union types at the top of the generated file when they are needed. */
687761
export const generateFetchHeader: ClientHeaderBuilder = ({
688762
clientImplementation,
689763
}) => {
@@ -704,6 +778,7 @@ const fetchClientBuilder: ClientGeneratorsBuilder = {
704778
dependencies: getFetchDependencies,
705779
};
706780

781+
/** Returns the fetch client builder factory used by orval's plugin system. */
707782
export const builder = () => () => fetchClientBuilder;
708783

709784
export default builder;

packages/orval/src/utils/options.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,9 @@ export async function normalizeOptions(
622622
outputOptions.override?.fetch?.runtimeValidation ?? false,
623623
useRuntimeFetcher:
624624
outputOptions.override?.fetch?.useRuntimeFetcher ?? false,
625+
...(outputOptions.override?.fetch?.arrayFormat
626+
? { arrayFormat: outputOptions.override.fetch.arrayFormat }
627+
: {}),
625628
...outputOptions.override?.fetch,
626629
...(outputOptions.override?.fetch?.jsonReviver
627630
? {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Generated by orval v8.18.0 🍺
3+
* Do not edit manually.
4+
* Array format serialization
5+
* OpenAPI spec version: 1.0.0
6+
*/
7+
export type ListItemsParams = {
8+
tags?: string[];
9+
};
10+
11+
export type listItemsResponse200 = {
12+
data: string[];
13+
status: 200;
14+
};
15+
16+
export type listItemsResponseSuccess = listItemsResponse200 & {
17+
headers: Headers;
18+
};
19+
export type listItemsResponse = listItemsResponseSuccess;
20+
21+
export const getListItemsUrl = (params?: ListItemsParams) => {
22+
const normalizedParams = new URLSearchParams();
23+
24+
Object.entries(params || {}).forEach(([key, value]) => {
25+
const arrayFormatParameters = ['tags'];
26+
27+
if (Array.isArray(value) && arrayFormatParameters.includes(key)) {
28+
value.forEach((v) => {
29+
normalizedParams.append(key + '[]', v === null ? 'null' : String(v));
30+
});
31+
return;
32+
}
33+
});
34+
35+
const stringifiedParams = normalizedParams.toString();
36+
37+
return stringifiedParams.length > 0
38+
? `/api/items?${stringifiedParams}`
39+
: `/api/items`;
40+
};
41+
42+
export const listItems = async (
43+
params?: ListItemsParams,
44+
options?: RequestInit,
45+
): Promise<listItemsResponse> => {
46+
const res = await fetch(getListItemsUrl(params), {
47+
...options,
48+
method: 'GET',
49+
});
50+
51+
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
52+
53+
const data: listItemsResponse['data'] = body ? JSON.parse(body) : {};
54+
return {
55+
data,
56+
status: res.status,
57+
headers: res.headers,
58+
} as listItemsResponse;
59+
};

0 commit comments

Comments
 (0)