diff --git a/docs/src/pages/reference/configuration/output.md b/docs/src/pages/reference/configuration/output.md
index 98c84b049..aa3f7a0c5 100644
--- a/docs/src/pages/reference/configuration/output.md
+++ b/docs/src/pages/reference/configuration/output.md
@@ -1902,7 +1902,8 @@ Use this property to provide a config to your http client or completely remove t
Type: `Boolean` or `String` or `Object`.
-Valid values: path of the formData function or object with a path and name.
+Valid values: path of the formData function or object with a path and name. You can also define how
+the names of form entries are handled regarding arrays.
Use this property to disable the auto generation of form data if you use multipart
@@ -1935,6 +1936,142 @@ export const customFormDataFn =
(body: Body): FormData => {
};
```
+##### mutator
+
+Type: `String` | `Object`
+
+Same as defining the mutator directly on `formData`, but this way you can specify `arrayHandling` as well.
+
+```js
+module.exports = {
+ petstore: {
+ output: {
+ override: {
+ formData: {
+ mutator: {
+ path: './api/mutator/custom-form-data-fn.ts',
+ name: 'customFormDataFn',
+ },
+ },
+ },
+ },
+ },
+};
+```
+
+##### arrayHandling
+
+Type: `serialize` | `serialize-with-brackets` | `explode`
+
+Default Value: `serialize`
+
+Decides how FormData generation handles arrays.
+
+```js
+module.exports = {
+ petstore: {
+ output: {
+ override: {
+ formData: {
+ mutator: {
+ path: './api/mutator/custom-form-data-fn.ts',
+ name: 'customFormDataFn',
+ },
+ arrayHandling: 'serialize-with-brackets',
+ },
+ },
+ },
+ },
+};
+```
+
+For all of the following examples, this specificaiton is used:
+
+```yaml
+components:
+ schemas:
+ Pet:
+ type: object
+ properties:
+ name:
+ type: string
+ age:
+ type: number
+ relatives:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ colors:
+ type: array
+ items:
+ type: string
+ enum:
+ - white
+ - black
+ - green
+```
+
+Type `serialize` setting results in the following generated code:
+
+```ts
+const formData = new FormData();
+if (pet.name !== undefined) {
+ formData.append(`name`, pet.name);
+}
+if (pet.age !== undefined) {
+ formData.append(`age`, pet.age.toString());
+}
+if (pet.relatives !== undefined) {
+ pet.relatives.forEach((value) =>
+ formData.append(`relatives`, JSON.stringify(value)),
+ );
+}
+```
+
+Type `serialize-with-brackets` setting results in the following generated code:
+
+```ts
+const formData = new FormData();
+if (pet.name !== undefined) {
+ formData.append(`name`, pet.name);
+}
+if (pet.age !== undefined) {
+ formData.append(`age`, pet.age.toString());
+}
+if (pet.relatives !== undefined) {
+ pet.relatives.forEach((value) =>
+ formData.append(`relatives[]`, JSON.stringify(value)),
+ );
+}
+```
+
+Type `explode` setting results in the following generated code:
+
+```ts
+const formData = new FormData();
+if (pet.name !== undefined) {
+ formData.append(`name`, pet.name);
+}
+if (pet.age !== undefined) {
+ formData.append(`age`, pet.age.toString());
+}
+if (pet.relatives !== undefined) {
+ pet.relatives.forEach((value, index) => {
+ if (value.name !== undefined) {
+ formData.append(`relatives[${index}].name`, value.name);
+ }
+ if (value.colors !== undefined) {
+ value.colors.forEach((value, index1) =>
+ formData.append(`relatives[${index}].colors[${index1}]`, value),
+ );
+ }
+ });
+}
+```
+
#### formUrlEncoded
Type: `Boolean` or `String` or `Object`.
diff --git a/packages/angular/src/index.ts b/packages/angular/src/index.ts
index d29a6f06a..b8ae9b157 100644
--- a/packages/angular/src/index.ts
+++ b/packages/angular/src/index.ts
@@ -131,7 +131,7 @@ const generateImplementation = (
{ route, context }: GeneratorOptions,
) => {
const isRequestOptions = override?.requestOptions !== false;
- const isFormData = override?.formData !== false;
+ const isFormData = override?.formData.disabled === false;
const isFormUrlEncoded = override?.formUrlEncoded !== false;
const isExactOptionalPropertyTypes =
!!context.output.tsconfig?.compilerOptions?.exactOptionalPropertyTypes;
diff --git a/packages/axios/src/index.ts b/packages/axios/src/index.ts
index 16de7b481..c87f62b2e 100644
--- a/packages/axios/src/index.ts
+++ b/packages/axios/src/index.ts
@@ -77,7 +77,7 @@ const generateAxiosImplementation = (
{ route, context }: GeneratorOptions,
) => {
const isRequestOptions = override?.requestOptions !== false;
- const isFormData = override?.formData !== false;
+ const isFormData = override?.formData.disabled === false;
const isFormUrlEncoded = override?.formUrlEncoded !== false;
const isExactOptionalPropertyTypes =
!!context.output.tsconfig?.compilerOptions?.exactOptionalPropertyTypes;
diff --git a/packages/core/src/generators/verbs-options.ts b/packages/core/src/generators/verbs-options.ts
index fa42cac93..aaf219139 100644
--- a/packages/core/src/generators/verbs-options.ts
+++ b/packages/core/src/generators/verbs-options.ts
@@ -29,6 +29,7 @@ import {
asyncReduce,
camel,
dynamicImport,
+ isBoolean,
isObject,
isString,
isVerb,
@@ -145,12 +146,11 @@ const generateVerbOptions = async ({
});
const formData =
- (isString(override?.formData) || isObject(override?.formData)) &&
- body.formData
+ !override.formData.disabled && body.formData
? await generateMutator({
output: output.target,
name: operationName,
- mutator: override.formData,
+ mutator: override.formData.mutator,
workspace: context.workspace,
tsconfig: context.output.tsconfig,
})
diff --git a/packages/core/src/getters/res-req-types.ts b/packages/core/src/getters/res-req-types.ts
index 0a55cfa1c..1c10a8d6c 100644
--- a/packages/core/src/getters/res-req-types.ts
+++ b/packages/core/src/getters/res-req-types.ts
@@ -11,7 +11,12 @@ import {
} from 'openapi3-ts/oas30';
import { resolveObject } from '../resolvers/object';
import { resolveExampleRefs, resolveRef } from '../resolvers/ref';
-import { ContextSpecs, GeneratorImport, ResReqTypesValue } from '../types';
+import {
+ ContextSpecs,
+ FormDataArrayHandling,
+ GeneratorImport,
+ ResReqTypesValue,
+} from '../types';
import { camel } from '../utils';
import { isReference } from '../utils/assertion';
import { pascal } from '../utils/case';
@@ -381,12 +386,16 @@ const resolveSchemaPropertiesToFormData = ({
propName,
context,
isRequestBodyOptional,
+ keyPrefix = '',
+ depth = 0,
}: {
schema: SchemaObject;
variableName: string;
propName: string;
context: ContextSpecs;
isRequestBodyOptional: boolean;
+ keyPrefix?: string;
+ depth?: number;
}) => {
const formDataValues = Object.entries(schema.properties ?? {}).reduce(
(acc, [key, value]) => {
@@ -407,16 +416,50 @@ const resolveSchemaPropertiesToFormData = ({
const nonOptionalValueKey = `${propName}${formattedKey}`;
if (property.type === 'object') {
- formDataValue = `${variableName}.append('${key}', JSON.stringify(${nonOptionalValueKey}));\n`;
+ if (
+ context.output.override.formData.arrayHandling ===
+ FormDataArrayHandling.EXPLODE
+ ) {
+ formDataValue = resolveSchemaPropertiesToFormData({
+ schema: property,
+ variableName,
+ propName: nonOptionalValueKey,
+ context,
+ isRequestBodyOptional,
+ keyPrefix: `${keyPrefix}${key}.`,
+ depth: depth + 1,
+ });
+ } else {
+ formDataValue = `${variableName}.append(\`${keyPrefix}${key}\`, JSON.stringify(${nonOptionalValueKey}));\n`;
+ }
} else if (property.type === 'array') {
let valueStr = 'value';
+ let hasNonPrimitiveChild = false;
if (property.items) {
const { schema: itemSchema } = resolveRef(
property.items,
context,
);
if (itemSchema.type === 'object' || itemSchema.type === 'array') {
- valueStr = 'JSON.stringify(value)';
+ if (
+ context.output.override.formData.arrayHandling ===
+ FormDataArrayHandling.EXPLODE
+ ) {
+ hasNonPrimitiveChild = true;
+ const resolvedValue = resolveSchemaPropertiesToFormData({
+ schema: itemSchema,
+ variableName,
+ propName: 'value',
+ context,
+ isRequestBodyOptional,
+ keyPrefix: `${keyPrefix}${key}[\${index${depth > 0 ? depth : ''}}].`,
+ depth: depth + 1,
+ });
+ formDataValue = `${valueKey}.forEach((value, index${depth > 0 ? depth : ''}) => {
+ ${resolvedValue}});\n`;
+ } else {
+ valueStr = 'JSON.stringify(value)';
+ }
} else if (
itemSchema.type === 'number' ||
itemSchema.type?.includes('number') ||
@@ -428,7 +471,16 @@ const resolveSchemaPropertiesToFormData = ({
valueStr = 'value.toString()';
}
}
- formDataValue = `${valueKey}.forEach(value => ${variableName}.append('${key}', ${valueStr}));\n`;
+ if (
+ context.output.override.formData.arrayHandling ===
+ FormDataArrayHandling.EXPLODE
+ ) {
+ if (!hasNonPrimitiveChild) {
+ formDataValue = `${valueKey}.forEach((value, index${depth > 0 ? depth : ''}) => ${variableName}.append(\`${keyPrefix}${key}[\${index${depth > 0 ? depth : ''}}]\`, ${valueStr}));\n`;
+ }
+ } else {
+ formDataValue = `${valueKey}.forEach(value => ${variableName}.append(\`${keyPrefix}${key}${context.output.override.formData.arrayHandling === FormDataArrayHandling.SERIALIZE_WITH_BRACKETS ? '[]' : ''}\`, ${valueStr}));\n`;
+ }
} else if (
property.type === 'number' ||
property.type?.includes('number') ||
@@ -437,9 +489,9 @@ const resolveSchemaPropertiesToFormData = ({
property.type === 'boolean' ||
property.type?.includes('boolean')
) {
- formDataValue = `${variableName}.append('${key}', ${nonOptionalValueKey}.toString())\n`;
+ formDataValue = `${variableName}.append(\`${keyPrefix}${key}\`, ${nonOptionalValueKey}.toString())\n`;
} else {
- formDataValue = `${variableName}.append('${key}', ${nonOptionalValueKey})\n`;
+ formDataValue = `${variableName}.append(\`${keyPrefix}${key}\`, ${nonOptionalValueKey})\n`;
}
let existSubSchemaNullable = false;
@@ -453,7 +505,7 @@ const resolveSchemaPropertiesToFormData = ({
return ['number', 'integer', 'boolean'].includes(subSchema.type);
})
) {
- formDataValue = `${variableName}.append('${key}', ${nonOptionalValueKey}.toString())\n`;
+ formDataValue = `${variableName}.append(\`${key}\`, ${nonOptionalValueKey}.toString())\n`;
}
if (
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 0ba721d0a..9cb82ec3b 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -81,7 +81,7 @@ export type NormalizedOverrideOutput = {
mock?: OverrideMockOptions;
contentType?: OverrideOutputContentType;
header: false | ((info: InfoObject) => string[] | string);
- formData: boolean | NormalizedMutator;
+ formData: NormalizedFormDataType;
formUrlEncoded: boolean | NormalizedMutator;
paramsSerializer?: NormalizedMutator;
paramsSerializerOptions?: NormalizedParamsSerializerOptions;
@@ -148,7 +148,7 @@ export type NormalizedOperationOptions = {
verb: Verbs,
) => string;
fetch?: FetchOptions;
- formData?: boolean | NormalizedMutator;
+ formData?: NormalizedFormDataType;
formUrlEncoded?: boolean | NormalizedMutator;
paramsSerializer?: NormalizedMutator;
requestOptions?: object | boolean;
@@ -379,6 +379,36 @@ export type ParamsSerializerOptions = {
qs?: Record;
};
+export const FormDataArrayHandling = {
+ SERIALIZE: 'serialize',
+ EXPLODE: 'explode',
+ SERIALIZE_WITH_BRACKETS: 'serialize-with-brackets',
+} as const;
+
+export type FormDataArrayHandling =
+ (typeof FormDataArrayHandling)[keyof typeof FormDataArrayHandling];
+
+export type NormalizedFormDataType =
+ | {
+ disabled: true;
+ mutator?: never;
+ arrayHandling: FormDataArrayHandling;
+ }
+ | {
+ disabled: false;
+ mutator?: TMutator;
+ arrayHandling: FormDataArrayHandling;
+ };
+export type FormDataType =
+ | {
+ mutator: TMutator;
+ arrayHandling?: FormDataArrayHandling;
+ }
+ | {
+ mutator?: TMutator;
+ arrayHandling: FormDataArrayHandling;
+ };
+
export type OverrideOutput = {
title?: (title: string) => string;
transformer?: OutputTransformer;
@@ -388,7 +418,7 @@ export type OverrideOutput = {
mock?: OverrideMockOptions;
contentType?: OverrideOutputContentType;
header?: boolean | ((info: InfoObject) => string[] | string);
- formData?: boolean | Mutator;
+ formData?: boolean | Mutator | FormDataType;
formUrlEncoded?: boolean | Mutator;
paramsSerializer?: Mutator;
paramsSerializerOptions?: ParamsSerializerOptions;
@@ -605,7 +635,7 @@ export type OperationOptions = {
verb: Verbs,
) => string;
fetch?: FetchOptions;
- formData?: boolean | Mutator;
+ formData?: boolean | Mutator | FormDataType;
formUrlEncoded?: boolean | Mutator;
paramsSerializer?: Mutator;
requestOptions?: object | boolean;
diff --git a/packages/fetch/src/index.ts b/packages/fetch/src/index.ts
index 4c265abce..e70a05352 100644
--- a/packages/fetch/src/index.ts
+++ b/packages/fetch/src/index.ts
@@ -37,7 +37,7 @@ export const generateRequestFunction = (
{ route, context, pathRoute }: GeneratorOptions,
) => {
const isRequestOptions = override?.requestOptions !== false;
- const isFormData = override?.formData !== false;
+ const isFormData = override?.formData.disabled === false;
const isFormUrlEncoded = override?.formUrlEncoded !== false;
const getUrlFnName = camel(`get-${operationName}-url`);
diff --git a/packages/orval/src/utils/options.ts b/packages/orval/src/utils/options.ts
index 4b9d3b874..471468e07 100644
--- a/packages/orval/src/utils/options.ts
+++ b/packages/orval/src/utils/options.ts
@@ -2,6 +2,7 @@ import {
ClientMockBuilder,
ConfigExternal,
createLogger,
+ FormDataArrayHandling,
GlobalMockOptions,
GlobalOptions,
HonoOptions,
@@ -23,12 +24,15 @@ import {
NormalizedMutator,
NormalizedOperationOptions,
NormalizedOptions,
+ NormalizedOverrideOutput,
NormalizedQueryOptions,
OperationOptions,
OptionsExport,
OutputClient,
OutputHttpClient,
OutputMode,
+ OutputOptions,
+ OverrideOutput,
PropertySortOrder,
QueryOptions,
RefComponentSuffix,
@@ -50,6 +54,34 @@ export function defineConfig(options: ConfigExternal): ConfigExternal {
return options;
}
+const createFormData = (
+ workspace: string,
+ formData: OverrideOutput['formData'],
+): NormalizedOverrideOutput['formData'] => {
+ const defaultArrayHandling = FormDataArrayHandling.SERIALIZE;
+ if (formData === undefined)
+ return { disabled: false, arrayHandling: defaultArrayHandling };
+ if (isBoolean(formData))
+ return { disabled: !formData, arrayHandling: defaultArrayHandling };
+ if (isString(formData))
+ return {
+ disabled: false,
+ mutator: normalizeMutator(workspace, formData),
+ arrayHandling: defaultArrayHandling,
+ };
+ if ('mutator' in formData || 'arrayHandling' in formData)
+ return {
+ disabled: false,
+ mutator: normalizeMutator(workspace, formData.mutator),
+ arrayHandling: formData.arrayHandling ?? defaultArrayHandling,
+ };
+ return {
+ disabled: false,
+ mutator: normalizeMutator(workspace, formData),
+ arrayHandling: defaultArrayHandling,
+ };
+};
+
export const normalizeOptions = async (
optionsExport: OptionsExport,
workspace = process.cwd(),
@@ -193,13 +225,10 @@ export const normalizeOptions = async (
outputWorkspace,
outputOptions.override?.mutator,
),
- formData:
- (!isBoolean(outputOptions.override?.formData)
- ? normalizeMutator(
- outputWorkspace,
- outputOptions.override?.formData,
- )
- : outputOptions.override?.formData) ?? true,
+ formData: createFormData(
+ outputWorkspace,
+ outputOptions.override?.formData,
+ ),
formUrlEncoded:
(!isBoolean(outputOptions.override?.formUrlEncoded)
? normalizeMutator(
@@ -514,13 +543,7 @@ const normalizeOperationsAndTags = (
...(mutator
? { mutator: normalizeMutator(workspace, mutator) }
: {}),
- ...(formData
- ? {
- formData: !isBoolean(formData)
- ? normalizeMutator(workspace, formData)
- : formData,
- }
- : {}),
+ ...createFormData(workspace, formData),
...(formUrlEncoded
? {
formUrlEncoded: !isBoolean(formUrlEncoded)
diff --git a/packages/query/src/client.ts b/packages/query/src/client.ts
index d5d3530ed..49b9ff71e 100644
--- a/packages/query/src/client.ts
+++ b/packages/query/src/client.ts
@@ -86,7 +86,7 @@ export const generateAxiosRequestFunction = (
}
const isRequestOptions = override.requestOptions !== false;
- const isFormData = override.formData !== false;
+ const isFormData = override.formData.disabled === false;
const isFormUrlEncoded = override.formUrlEncoded !== false;
const hasSignal = getHasSignal({
overrideQuerySignal: override.query.signal,
diff --git a/packages/swr/src/client.ts b/packages/swr/src/client.ts
index bc4cc3c58..be9f015f9 100644
--- a/packages/swr/src/client.ts
+++ b/packages/swr/src/client.ts
@@ -66,7 +66,7 @@ const generateAxiosRequestFunction = (
{ route, context }: GeneratorOptions,
) => {
const isRequestOptions = override?.requestOptions !== false;
- const isFormData = override?.formData !== false;
+ const isFormData = override?.formData.disabled === false;
const isFormUrlEncoded = override?.formUrlEncoded !== false;
const isExactOptionalPropertyTypes =
!!context.output.tsconfig?.compilerOptions?.exactOptionalPropertyTypes;
diff --git a/tests/configs/default.config.ts b/tests/configs/default.config.ts
index 175d58b17..79ec52e8b 100644
--- a/tests/configs/default.config.ts
+++ b/tests/configs/default.config.ts
@@ -374,4 +374,18 @@ export default defineConfig({
target: '../specifications/enums.yaml',
},
},
+ formDataExplode: {
+ output: {
+ target: '../generated/default/form-data-explode/endpoints.ts',
+ schemas: '../generated/default/form-data-explode/model',
+ override: {
+ formData: {
+ arrayHandling: 'explode',
+ },
+ },
+ },
+ input: {
+ target: '../specifications/form-data-nested.yaml',
+ },
+ },
});
diff --git a/tests/mutators/test.ts b/tests/mutators/test.ts
new file mode 100644
index 000000000..ce9272604
--- /dev/null
+++ b/tests/mutators/test.ts
@@ -0,0 +1,11 @@
+import Axios, { AxiosError, AxiosRequestConfig } from 'axios';
+
+export const AXIOS_INSTANCE = Axios.create();
+
+export default function customInstance(
+ config: AxiosRequestConfig,
+): Promise {
+ return AXIOS_INSTANCE({ ...config }).then(({ data }) => data);
+}
+
+export type ErrorType = AxiosError;
diff --git a/tests/specifications/form-data-nested.yaml b/tests/specifications/form-data-nested.yaml
new file mode 100644
index 000000000..d4e99f826
--- /dev/null
+++ b/tests/specifications/form-data-nested.yaml
@@ -0,0 +1,52 @@
+openapi: '3.0.0'
+info:
+ version: 1.0.0
+ title: Swagger Petstore
+ license:
+ name: MIT
+servers:
+ - url: http://petstore.swagger.io/v1
+paths:
+ /pets:
+ post:
+ summary: Create a pet
+ operationId: createPets
+ tags:
+ - pets
+ requestBody:
+ required: true
+ content:
+ multipart/form-data:
+ schema:
+ $ref: '#/components/schemas/Pet'
+ responses:
+ '200':
+ description: Created Pet
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Pet'
+components:
+ schemas:
+ Pet:
+ type: object
+ properties:
+ name:
+ type: string
+ age:
+ type: number
+ relatives:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ colors:
+ type: array
+ items:
+ type: string
+ enum:
+ - white
+ - black
+ - green