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