Skip to content

Commit 561e5bf

Browse files
[kotlin][jvm-okhttp4] Fix multipart/form-data with JSON content-type (#22856)
* [kotlin][jvm-okhttp4] Fix multipart/form-data with JSON content-type Fixes #16457 Fixes two critical bugs in multipart/form-data handling when parts have Content-Type application/json: 1. IllegalArgumentException: OkHttp throws "Unexpected header: Content-Type" because Content-Type was passed in headers map instead of via asRequestBody(mediaType)/toRequestBody(mediaType) parameter. 2. Invalid JSON serialization: Non-file parts with application/json Content-Type were serialized using toString() instead of proper JSON serialization, producing invalid output like: "MyObject(field1=value, field2=123)" instead of '{"field1":"value","field2":123}' Changes: - Filter Content-Type from headers before passing to OkHttp - Check part Content-Type and use appropriate serializer (JSON vs toString) - Add integration tests with echo server to verify fix - Support all serialization libraries (gson, moshi, jackson, kotlinx) Fixes issues with multipart endpoints that mix file uploads with JSON metadata, common in REST APIs for document/image uploads. * Run mvn clean/package, and regenerate samples * Add fix for kotlinx serialisation issue * Refactor multipart helpers for reified type parameter support * Fix kotlinx.serialization multipart by adding serializer lambda to PartConfig * Fix internal Ktor API usage in multipart forms
1 parent 9547ebd commit 561e5bf

File tree

125 files changed

+3885
-331
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

125 files changed

+3885
-331
lines changed

.github/workflows/samples-kotlin-echo-api.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ jobs:
2020
- samples/client/echo_api/kotlin-jvm-spring-3-restclient
2121
- samples/client/echo_api/kotlin-model-prefix-type-mappings
2222
- samples/client/echo_api/kotlin-jvm-okhttp
23+
- samples/client/echo_api/kotlin-jvm-okhttp-multipart-json
2324
steps:
2425
- uses: actions/checkout@v5
2526
- uses: actions/setup-java@v5

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,9 @@ samples/client/petstore/kotlin*/src/main/kotlin/test/
229229
samples/client/petstore/kotlin*/build/
230230
samples/server/others/kotlin-server/jaxrs-spec/build/
231231
samples/client/echo_api/kotlin-jvm-spring-3-restclient/build/
232+
samples/client/echo_api/kotlin-jvm-spring-3-webclient/build/
232233
samples/client/echo_api/kotlin-jvm-okhttp/build/
234+
samples/client/echo_api/kotlin-jvm-okhttp-multipart-json/build/
233235

234236
# haskell
235237
.stack-work

modules/openapi-generator/src/main/resources/kotlin-client/infrastructure/PartConfig.kt.mustache

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,16 @@ package {{packageName}}.infrastructure
44
* Defines a config object for a given part of a multi-part request.
55
* NOTE: Headers is a Map<String,String> because rfc2616 defines
66
* multi-valued headers as csv-only.
7+
*
8+
* @property headers The headers for this part
9+
* @property body The body content for this part
10+
* @property serializer Optional custom serializer for JSON content. When provided, this will be
11+
* used instead of the default serialization for parts with application/json
12+
* content-type. This allows capturing type information at the call site to
13+
* avoid issues with type erasure in kotlinx.serialization.
714
*/
815
{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}data class PartConfig<T>(
916
val headers: MutableMap<String, String> = mutableMapOf(),
10-
val body: T? = null
17+
val body: T? = null,
18+
val serializer: ((Any?) -> String)? = null
1119
)

modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-okhttp/api.mustache

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ import {{packageName}}.infrastructure.RequestMethod
4646
import {{packageName}}.infrastructure.ResponseType
4747
import {{packageName}}.infrastructure.Success
4848
import {{packageName}}.infrastructure.toMultiValue
49+
{{#kotlinx_serialization}}
50+
import {{packageName}}.infrastructure.Serializer
51+
{{/kotlinx_serialization}}
4952

5053
{{#operations}}
5154
{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}open {{/nonPublicApi}}class {{classname}}(basePath: kotlin.String = defaultBasePath, client: Call.Factory = ApiClient.defaultClient) : ApiClient(basePath, client) {
@@ -199,7 +202,7 @@ import {{packageName}}.infrastructure.toMultiValue
199202
}}{{#bodyParams}}{{{paramName}}}{{/bodyParams}}{{/hasBodyParam}}{{^hasBodyParam}}{{!
200203
}}{{^hasFormParams}}null{{/hasFormParams}}{{!
201204
}}{{#hasFormParams}}mapOf({{#formParams}}
202-
"{{#lambda.escapeDollar}}{{{baseName}}}{{/lambda.escapeDollar}}" to PartConfig(body = {{{paramName}}}{{#isEnum}}{{^required}}?{{/required}}.value{{/isEnum}}, headers = mutableMapOf({{#contentType}}"Content-Type" to "{{contentType}}"{{/contentType}})),{{!
205+
"{{#lambda.escapeDollar}}{{{baseName}}}{{/lambda.escapeDollar}}" to PartConfig(body = {{{paramName}}}{{#isEnum}}{{^required}}?{{/required}}.value{{/isEnum}}, headers = mutableMapOf({{#contentType}}"Content-Type" to "{{contentType}}"{{/contentType}}){{#contentType}}{{^isFile}}, serializer = {{#kotlinx_serialization}}{ obj -> Serializer.kotlinxSerializationJson.encodeToString<{{{dataType}}}>(obj as {{{dataType}}}) }{{/kotlinx_serialization}}{{^kotlinx_serialization}}null{{/kotlinx_serialization}}{{/isFile}}{{/contentType}}),{{!
203206
}}{{/formParams}}){{/hasFormParams}}{{!
204207
}}{{/hasBodyParam}}
205208
val localVariableQuery: MultiValueMap = {{^hasQueryParams}}mutableMapOf()

modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-okhttp/infrastructure/ApiClient.kt.mustache

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,25 @@ import com.squareup.moshi.adapter
115115
return contentType ?: "application/octet-stream"
116116
}
117117

118+
/**
119+
* Builds headers for a multipart form-data part.
120+
* OkHttp requires Content-Type to be passed via the RequestBody parameter, not in headers.
121+
* This function filters out Content-Type and builds the appropriate Content-Disposition header.
122+
*
123+
* @param name The field name
124+
* @param headers The headers from the PartConfig (may include Content-Type)
125+
* @param filename Optional filename for file uploads
126+
* @return Headers object ready for addPart()
127+
*/
128+
protected fun buildPartHeaders(name: String, headers: Map<String, String>, filename: String? = null): Headers {
129+
val disposition = if (filename != null) {
130+
"form-data; name=\"$name\"; filename=\"$filename\""
131+
} else {
132+
"form-data; name=\"$name\""
133+
}
134+
return (headers.filterKeys { it != "Content-Type" } + ("Content-Disposition" to disposition)).toHeaders()
135+
}
136+
118137
/**
119138
* Adds a File to a MultipartBody.Builder
120139
* Defined a helper in the requestBody method to not duplicate code
@@ -127,15 +146,48 @@ import com.squareup.moshi.adapter
127146
* @see requestBody
128147
*/
129148
protected fun MultipartBody.Builder.addPartToMultiPart(name: String, headers: Map<String, String>, file: File) {
130-
val partHeaders = headers.toMutableMap() +
131-
("Content-Disposition" to "form-data; name=\"$name\"; filename=\"${file.name}\"")
132149
val fileMediaType = guessContentTypeFromFile(file).toMediaTypeOrNull()
133150
addPart(
134-
partHeaders.toHeaders(),
151+
buildPartHeaders(name, headers, file.name),
135152
file.asRequestBody(fileMediaType)
136153
)
137154
}
138155

156+
/**
157+
* Serializes a multipart body part based on its content type.
158+
* Uses JSON serialization for application/json content types, otherwise converts to string.
159+
*
160+
* @param obj The object to serialize
161+
* @param contentType The Content-Type header value, if any
162+
* @param serializer Optional custom serializer (used for kotlinx.serialization to preserve type info)
163+
* @return The serialized string representation
164+
*/
165+
protected fun serializePartBody(obj: Any?, contentType: String?, serializer: ((Any?) -> String)?): String {
166+
// Use custom serializer if provided (for kotlinx.serialization with captured type info)
167+
if (serializer != null) {
168+
return serializer(obj)
169+
}
170+
171+
return if (contentType?.contains("json") == true) {
172+
{{#moshi}}
173+
Serializer.moshi.adapter(Any::class.java).toJson(obj)
174+
{{/moshi}}
175+
{{#gson}}
176+
Serializer.gson.toJson(obj)
177+
{{/gson}}
178+
{{#jackson}}
179+
Serializer.jacksonObjectMapper.writeValueAsString(obj)
180+
{{/jackson}}
181+
{{#kotlinx_serialization}}
182+
// Note: Without a custom serializer, kotlinx.serialization cannot serialize Any?
183+
// The custom serializer should be provided at PartConfig creation to capture type info
184+
parameterToString(obj)
185+
{{/kotlinx_serialization}}
186+
} else {
187+
parameterToString(obj)
188+
}
189+
}
190+
139191
/**
140192
* Adds any type to a MultipartBody.Builder
141193
* Defined a helper in the requestBody method to not duplicate code
@@ -144,15 +196,17 @@ import com.squareup.moshi.adapter
144196
* @param name The field name to add in the request
145197
* @param headers The headers that are in the PartConfig
146198
* @param obj The field name to add in the request
199+
* @param serializer Optional custom serializer for this part
147200
* @return The method returns Unit but the new Part is added to the Builder that the extension function is applying on
148201
* @see requestBody
149202
*/
150-
protected fun <T> MultipartBody.Builder.addPartToMultiPart(name: String, headers: Map<String, String>, obj: T?) {
151-
val partHeaders = headers.toMutableMap() +
152-
("Content-Disposition" to "form-data; name=\"$name\"")
203+
protected fun MultipartBody.Builder.addPartToMultiPart(name: String, headers: Map<String, String>, obj: Any?, serializer: ((Any?) -> String)? = null) {
204+
val partContentType = headers["Content-Type"]
205+
val partMediaType = partContentType?.toMediaTypeOrNull()
206+
val partBody = serializePartBody(obj, partContentType, serializer)
153207
addPart(
154-
partHeaders.toHeaders(),
155-
parameterToString(obj).toRequestBody(null)
208+
buildPartHeaders(name, headers),
209+
partBody.toRequestBody(partMediaType)
156210
)
157211
}
158212

@@ -174,11 +228,11 @@ import com.squareup.moshi.adapter
174228
if (it is File) {
175229
addPartToMultiPart(name, part.headers, it)
176230
} else {
177-
addPartToMultiPart(name, part.headers, it)
231+
addPartToMultiPart(name, part.headers, it, part.serializer)
178232
}
179233
}
180234
}
181-
else -> addPartToMultiPart(name, part.headers, part.body)
235+
else -> addPartToMultiPart(name, part.headers, part.body, part.serializer)
182236
}
183237
}
184238
}.build()

modules/openapi-generator/src/main/resources/kotlin-client/libraries/multiplatform/api.mustache

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import io.ktor.client.request.forms.formData
1111
import io.ktor.client.engine.HttpClientEngine
1212
import kotlinx.serialization.json.Json
1313
import io.ktor.http.ParametersBuilder
14+
import io.ktor.http.Headers
15+
import io.ktor.http.HttpHeaders
16+
import io.ktor.http.ContentType
17+
import io.ktor.http.content.PartData
1418
import kotlinx.serialization.*
1519
import kotlinx.serialization.descriptors.*
1620
import kotlinx.serialization.encoding.*
@@ -76,11 +80,31 @@ import kotlinx.serialization.encoding.*
7680
{{#formParams}}
7781
{{#isArray}}
7882
{{{paramName}}}?.onEach {
79-
{{#isFile}}append(it){{/isFile}}{{^isFile}}append("{{{baseName}}}", it){{/isFile}}
83+
{{#isFile}}append(it){{/isFile}}{{^isFile}}append("{{{baseName}}}", it.toString()){{/isFile}}
8084
}
8185
{{/isArray}}
8286
{{^isArray}}
83-
{{{paramName}}}?.apply { {{#isFile}}append({{{baseName}}}){{/isFile}}{{^isFile}}append("{{{baseName}}}", {{^isEnumOrRef}}{{{paramName}}}{{/isEnumOrRef}}{{#isEnumOrRef}}{{{paramName}}}.value{{/isEnumOrRef}}){{/isFile}} }
87+
{{#isFile}}
88+
{{{paramName}}}?.apply { append({{{baseName}}}) }
89+
{{/isFile}}
90+
{{^isFile}}
91+
{{#isPrimitiveType}}
92+
{{#isString}}
93+
{{{paramName}}}?.apply { append("{{{baseName}}}", {{{paramName}}}) }
94+
{{/isString}}
95+
{{^isString}}
96+
{{{paramName}}}?.apply { append("{{{baseName}}}", {{{paramName}}}.toString()) }
97+
{{/isString}}
98+
{{/isPrimitiveType}}
99+
{{^isPrimitiveType}}
100+
{{#isEnumOrRef}}
101+
{{{paramName}}}?.apply { append("{{{baseName}}}", {{{paramName}}}.value.toString()) }
102+
{{/isEnumOrRef}}
103+
{{^isEnumOrRef}}
104+
{{{paramName}}}?.apply { append("{{{baseName}}}", ApiClient.JSON_DEFAULT.encodeToString({{{dataType}}}.serializer(), {{{paramName}}})) }
105+
{{/isEnumOrRef}}
106+
{{/isPrimitiveType}}
107+
{{/isFile}}
84108
{{/isArray}}
85109
{{/formParams}}
86110
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
openapi: 3.0.0
2+
servers:
3+
- url: 'http://localhost:3000/'
4+
info:
5+
version: 1.0.0
6+
title: Echo API for Kotlin Multipart JSON Test
7+
description: Echo server API to test multipart/form-data with JSON content-type
8+
license:
9+
name: Apache-2.0
10+
url: 'https://www.apache.org/licenses/LICENSE-2.0.html'
11+
tags:
12+
- name: body
13+
description: Test body operations
14+
paths:
15+
/body/multipart/formdata/with_json_part:
16+
post:
17+
tags:
18+
- body
19+
summary: Test multipart with JSON part
20+
description: Test multipart/form-data with a part that has Content-Type application/json
21+
operationId: testBodyMultipartFormdataWithJsonPart
22+
requestBody:
23+
required: true
24+
content:
25+
multipart/form-data:
26+
schema:
27+
type: object
28+
required:
29+
- metadata
30+
- file
31+
properties:
32+
metadata:
33+
$ref: '#/components/schemas/FileMetadata'
34+
file:
35+
type: string
36+
format: binary
37+
description: File to upload
38+
encoding:
39+
metadata:
40+
contentType: application/json
41+
file:
42+
contentType: image/jpeg
43+
responses:
44+
'200':
45+
description: Successful operation
46+
content:
47+
text/plain:
48+
schema:
49+
type: string
50+
components:
51+
schemas:
52+
FileMetadata:
53+
type: object
54+
required:
55+
- id
56+
- name
57+
properties:
58+
id:
59+
type: integer
60+
format: int64
61+
example: 12345
62+
name:
63+
type: string
64+
example: test-file
65+
tags:
66+
type: array
67+
items:
68+
type: string
69+
example: ["tag1", "tag2"]

modules/openapi-generator/src/test/resources/3_0/kotlin/polymorphism.yaml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,35 @@ paths:
3030
name: id
3131
in: path
3232
required: true
33+
'/v1/bird/upload':
34+
post:
35+
tags:
36+
- bird
37+
operationId: upload-bird-with-metadata
38+
requestBody:
39+
content:
40+
multipart/form-data:
41+
schema:
42+
type: object
43+
properties:
44+
metadata:
45+
$ref: '#/components/schemas/bird'
46+
file:
47+
type: string
48+
format: binary
49+
required:
50+
- metadata
51+
- file
52+
encoding:
53+
metadata:
54+
contentType: application/json
55+
responses:
56+
'200':
57+
description: Upload successful
58+
content:
59+
text/plain:
60+
schema:
61+
type: string
3362
components:
3463
schemas:
3564
animal:
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# OpenAPI Generator Ignore
2+
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
3+
4+
# Use this file to prevent files from being overwritten by the generator.
5+
# The patterns follow closely to .gitignore or .dockerignore.
6+
7+
# As an example, the C# client generator defines ApiClient.cs.
8+
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
9+
#ApiClient.cs
10+
11+
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
12+
#foo/*/qux
13+
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
14+
15+
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
16+
#foo/**/qux
17+
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
18+
19+
# You can also negate patterns with an exclamation (!).
20+
# For example, you can ignore all files in a docs folder with the file extension .md:
21+
#docs/*.md
22+
# Then explicitly reverse the ignore rule for a single file:
23+
#!docs/README.md
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
README.md
2+
build.gradle
3+
docs/BodyApi.md
4+
docs/FileMetadata.md
5+
gradle/wrapper/gradle-wrapper.jar
6+
gradle/wrapper/gradle-wrapper.properties
7+
gradlew
8+
gradlew.bat
9+
settings.gradle
10+
src/main/kotlin/org/openapitools/client/apis/BodyApi.kt
11+
src/main/kotlin/org/openapitools/client/infrastructure/ApiAbstractions.kt
12+
src/main/kotlin/org/openapitools/client/infrastructure/ApiClient.kt
13+
src/main/kotlin/org/openapitools/client/infrastructure/ApiResponse.kt
14+
src/main/kotlin/org/openapitools/client/infrastructure/ByteArrayAdapter.kt
15+
src/main/kotlin/org/openapitools/client/infrastructure/Errors.kt
16+
src/main/kotlin/org/openapitools/client/infrastructure/LocalDateAdapter.kt
17+
src/main/kotlin/org/openapitools/client/infrastructure/LocalDateTimeAdapter.kt
18+
src/main/kotlin/org/openapitools/client/infrastructure/OffsetDateTimeAdapter.kt
19+
src/main/kotlin/org/openapitools/client/infrastructure/PartConfig.kt
20+
src/main/kotlin/org/openapitools/client/infrastructure/RequestConfig.kt
21+
src/main/kotlin/org/openapitools/client/infrastructure/RequestMethod.kt
22+
src/main/kotlin/org/openapitools/client/infrastructure/ResponseExtensions.kt
23+
src/main/kotlin/org/openapitools/client/infrastructure/Serializer.kt
24+
src/main/kotlin/org/openapitools/client/models/FileMetadata.kt

0 commit comments

Comments
 (0)