Skip to content

feat(core): add better support for form-data arrays #2026

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Apr 17, 2025
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
139 changes: 138 additions & 1 deletion docs/src/pages/reference/configuration/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -1935,6 +1936,142 @@ export const customFormDataFn = <Body>(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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may not be necessary.
If necessary, why not consider specifying explode separately?

https://swagger.io/docs/specification/v3_0/serialization/

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And if this is removed, it can be made into a simple option like override.formData.serializeWithBrackets: true 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But that doesn't allow you to specify how body data in multipart/form-data requests are serialized, right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought that if override.formData.serializeWithBrackets was set to true, brackets would be added, and if it was set to false, it would behave as before.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I was thinking about the explode, how would you configure it to explode form data?
What I need for it to work with my ASP.Net backend is instead of

data.forEach(
  (value, index) => {
    formData.append(
      `receptionFacilitiesPerFaction`,
/* 
 * value is of type
 * {
 *   fractionId: number
 *   receptionFacilityIds: number[]
 * }
*/
      JSON.serialize(value),
    );
  },
);

I need to set

data.forEach(
  (value, index) => {
    formData.append(
      `receptionFacilitiesPerFaction[${index}].fractionId`,
      value.fractionId.toString(),
    );
    value.receptionFacilityIds.forEach((v, rIndex) => {
      formData.append(
        `receptionFacilitiesPerFaction[${index}].receptionFacilityIds[${rIndex}]`,
        v.toString(),
      );
    });
  },
);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, so ASP.net can't accept it unless it's in a format like receptionFacilitiesPerFaction[0]&receptionFacilitiesPerFaction[1]?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was surprised as well, I couldn't get it to work with sending json objects in a multipart/form-data, so I asked ChatGPT, which gave me this solution, and it worked!
https://chatgpt.com/share/67ff8e02-1fb0-8000-901b-61e336ddad0e

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, I didn't know that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AllieJonsson
Okey, then we already have override.formData so let's just move up the options level like override.formData.arrayHandling.


```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`.
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/axios/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/generators/verbs-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
asyncReduce,
camel,
dynamicImport,
isBoolean,
isObject,
isString,
isVerb,
Expand Down Expand Up @@ -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,
})
Expand Down
66 changes: 59 additions & 7 deletions packages/core/src/getters/res-req-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]) => {
Expand All @@ -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<SchemaObject>(
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') ||
Expand All @@ -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') ||
Expand All @@ -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;
Expand All @@ -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 (
Expand Down
38 changes: 34 additions & 4 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export type NormalizedOverrideOutput = {
mock?: OverrideMockOptions;
contentType?: OverrideOutputContentType;
header: false | ((info: InfoObject) => string[] | string);
formData: boolean | NormalizedMutator;
formData: NormalizedFormDataType<NormalizedMutator>;
formUrlEncoded: boolean | NormalizedMutator;
paramsSerializer?: NormalizedMutator;
paramsSerializerOptions?: NormalizedParamsSerializerOptions;
Expand Down Expand Up @@ -148,7 +148,7 @@ export type NormalizedOperationOptions = {
verb: Verbs,
) => string;
fetch?: FetchOptions;
formData?: boolean | NormalizedMutator;
formData?: NormalizedFormDataType<NormalizedMutator>;
formUrlEncoded?: boolean | NormalizedMutator;
paramsSerializer?: NormalizedMutator;
requestOptions?: object | boolean;
Expand Down Expand Up @@ -379,6 +379,36 @@ export type ParamsSerializerOptions = {
qs?: Record<string, any>;
};

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<TMutator> =
| {
disabled: true;
mutator?: never;
arrayHandling: FormDataArrayHandling;
}
| {
disabled: false;
mutator?: TMutator;
arrayHandling: FormDataArrayHandling;
};
export type FormDataType<TMutator> =
| {
mutator: TMutator;
arrayHandling?: FormDataArrayHandling;
}
| {
mutator?: TMutator;
arrayHandling: FormDataArrayHandling;
};

export type OverrideOutput = {
title?: (title: string) => string;
transformer?: OutputTransformer;
Expand All @@ -388,7 +418,7 @@ export type OverrideOutput = {
mock?: OverrideMockOptions;
contentType?: OverrideOutputContentType;
header?: boolean | ((info: InfoObject) => string[] | string);
formData?: boolean | Mutator;
formData?: boolean | Mutator | FormDataType<Mutator>;
formUrlEncoded?: boolean | Mutator;
paramsSerializer?: Mutator;
paramsSerializerOptions?: ParamsSerializerOptions;
Expand Down Expand Up @@ -605,7 +635,7 @@ export type OperationOptions = {
verb: Verbs,
) => string;
fetch?: FetchOptions;
formData?: boolean | Mutator;
formData?: boolean | Mutator | FormDataType<Mutator>;
formUrlEncoded?: boolean | Mutator;
paramsSerializer?: Mutator;
requestOptions?: object | boolean;
Expand Down
2 changes: 1 addition & 1 deletion packages/fetch/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
Loading