Skip to content

Commit 50a7e12

Browse files
committed
[kotlin][jvm-okhttp4] Fix multipart/form-data with JSON content-type
Fixes OpenAPITools#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.
1 parent 82ad061 commit 50a7e12

35 files changed

Lines changed: 1753 additions & 3 deletions

File tree

.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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ 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/
232232
samples/client/echo_api/kotlin-jvm-okhttp/build/
233+
samples/client/echo_api/kotlin-jvm-okhttp-multipart-json/build/
233234

234235
# haskell
235236
.stack-work

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

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,9 @@ import com.squareup.moshi.adapter
127127
* @see requestBody
128128
*/
129129
protected fun MultipartBody.Builder.addPartToMultiPart(name: String, headers: Map<String, String>, file: File) {
130-
val partHeaders = headers.toMutableMap() +
130+
// Filter out Content-Type from headers as OkHttp requires it to be passed
131+
// separately via asRequestBody(mediaType), not in the headers map
132+
val partHeaders = headers.filterKeys { it != "Content-Type" }.toMutableMap() +
131133
("Content-Disposition" to "form-data; name=\"$name\"; filename=\"${file.name}\"")
132134
val fileMediaType = guessContentTypeFromFile(file).toMediaTypeOrNull()
133135
addPart(
@@ -148,11 +150,31 @@ import com.squareup.moshi.adapter
148150
* @see requestBody
149151
*/
150152
protected fun <T> MultipartBody.Builder.addPartToMultiPart(name: String, headers: Map<String, String>, obj: T?) {
151-
val partHeaders = headers.toMutableMap() +
153+
val partContentType = headers["Content-Type"]
154+
val partMediaType = partContentType?.toMediaTypeOrNull()
155+
// Filter out Content-Type from headers as OkHttp requires it to be passed
156+
// separately via toRequestBody(mediaType), not in the headers map
157+
val partHeaders = headers.filterKeys { it != "Content-Type" }.toMutableMap() +
152158
("Content-Disposition" to "form-data; name=\"$name\"")
159+
val partBody = if (partContentType?.contains("json") == true) {
160+
{{#moshi}}
161+
Serializer.moshi.adapter(Any::class.java).toJson(obj)
162+
{{/moshi}}
163+
{{#gson}}
164+
Serializer.gson.toJson(obj)
165+
{{/gson}}
166+
{{#jackson}}
167+
Serializer.jacksonObjectMapper.writeValueAsString(obj)
168+
{{/jackson}}
169+
{{#kotlinx_serialization}}
170+
Serializer.kotlinxSerializationJson.encodeToString(obj)
171+
{{/kotlinx_serialization}}
172+
} else {
173+
parameterToString(obj)
174+
}
153175
addPart(
154176
partHeaders.toHeaders(),
155-
parameterToString(obj).toRequestBody(null)
177+
partBody.toRequestBody(partMediaType)
156178
)
157179
}
158180

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"]
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
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
7.20.0-SNAPSHOT
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# org.openapitools.client - Kotlin client library for Echo API for Kotlin Multipart JSON Test
2+
3+
Echo server API to test multipart/form-data with JSON content-type
4+
5+
## Overview
6+
This API client was generated by the [OpenAPI Generator](https://openapi-generator.tech) project. By using the [openapi-spec](https://github.com/OAI/OpenAPI-Specification) from a remote server, you can easily generate an API client.
7+
8+
- API version: 1.0.0
9+
- Package version:
10+
- Generator version: 7.20.0-SNAPSHOT
11+
- Build package: org.openapitools.codegen.languages.KotlinClientCodegen
12+
13+
## Requires
14+
15+
* Kotlin 2.2.20
16+
* Gradle 8.14
17+
18+
## Build
19+
20+
First, create the gradle wrapper script:
21+
22+
```
23+
gradle wrapper
24+
```
25+
26+
Then, run:
27+
28+
```
29+
./gradlew check assemble
30+
```
31+
32+
This runs all tests and packages the library.
33+
34+
## Features/Implementation Notes
35+
36+
* Supports JSON inputs/outputs, File inputs, and Form inputs.
37+
* Supports collection formats for query parameters: csv, tsv, ssv, pipes.
38+
* Some Kotlin and Java types are fully qualified to avoid conflicts with types defined in OpenAPI definitions.
39+
* Implementation of ApiClient is intended to reduce method counts, specifically to benefit Android targets.
40+
41+
<a id="documentation-for-api-endpoints"></a>
42+
## Documentation for API Endpoints
43+
44+
All URIs are relative to *http://localhost:3000*
45+
46+
| Class | Method | HTTP request | Description |
47+
| ------------ | ------------- | ------------- | ------------- |
48+
| *BodyApi* | [**testBodyMultipartFormdataWithJsonPart**](docs/BodyApi.md#testbodymultipartformdatawithjsonpart) | **POST** /body/multipart/formdata/with_json_part | Test multipart with JSON part |
49+
50+
51+
<a id="documentation-for-models"></a>
52+
## Documentation for Models
53+
54+
- [org.openapitools.client.models.FileMetadata](docs/FileMetadata.md)
55+
56+
57+
<a id="documentation-for-authorization"></a>
58+
## Documentation for Authorization
59+
60+
Endpoints do not require authorization.
61+
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
group 'org.openapitools'
2+
version '1.0.0'
3+
4+
wrapper {
5+
gradleVersion = '8.14.3'
6+
distributionUrl = "https://services.gradle.org/distributions/gradle-$gradleVersion-all.zip"
7+
}
8+
9+
buildscript {
10+
ext.kotlin_version = '2.2.20'
11+
ext.spotless_version = "7.2.1"
12+
13+
repositories {
14+
maven { url "https://repo1.maven.org/maven2" }
15+
}
16+
dependencies {
17+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
18+
classpath "com.diffplug.spotless:spotless-plugin-gradle:$spotless_version"
19+
}
20+
}
21+
22+
apply plugin: 'kotlin'
23+
apply plugin: 'maven-publish'
24+
apply plugin: 'com.diffplug.spotless'
25+
26+
repositories {
27+
maven { url "https://repo1.maven.org/maven2" }
28+
}
29+
30+
// Use spotless plugin to automatically format code, remove unused import, etc
31+
// To apply changes directly to the file, run `gradlew spotlessApply`
32+
// Ref: https://github.com/diffplug/spotless/tree/main/plugin-gradle
33+
spotless {
34+
// comment out below to run spotless as part of the `check` task
35+
enforceCheck false
36+
37+
format 'misc', {
38+
// define the files (e.g. '*.gradle', '*.md') to apply `misc` to
39+
target '.gitignore'
40+
41+
// define the steps to apply to those files
42+
trimTrailingWhitespace()
43+
indentWithSpaces() // Takes an integer argument if you don't like 4
44+
endWithNewline()
45+
}
46+
kotlin {
47+
ktfmt()
48+
}
49+
}
50+
51+
test {
52+
useJUnitPlatform()
53+
}
54+
55+
dependencies {
56+
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
57+
implementation "com.google.code.gson:gson:2.13.2"
58+
implementation "com.squareup.okhttp3:okhttp:5.1.0"
59+
testImplementation "io.kotlintest:kotlintest-runner-junit5:3.4.2"
60+
}
61+
62+
java {
63+
withSourcesJar()
64+
}
65+
66+
publishing {
67+
publications {
68+
maven(MavenPublication) {
69+
groupId = 'org.openapitools'
70+
artifactId = 'kotlin-client'
71+
version = '1.0.0'
72+
from components.java
73+
}
74+
}
75+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# BodyApi
2+
3+
All URIs are relative to *http://localhost:3000*
4+
5+
| Method | HTTP request | Description |
6+
| ------------- | ------------- | ------------- |
7+
| [**testBodyMultipartFormdataWithJsonPart**](BodyApi.md#testBodyMultipartFormdataWithJsonPart) | **POST** /body/multipart/formdata/with_json_part | Test multipart with JSON part |
8+
9+
10+
<a id="testBodyMultipartFormdataWithJsonPart"></a>
11+
# **testBodyMultipartFormdataWithJsonPart**
12+
> kotlin.String testBodyMultipartFormdataWithJsonPart(metadata, file)
13+
14+
Test multipart with JSON part
15+
16+
Test multipart/form-data with a part that has Content-Type application/json
17+
18+
### Example
19+
```kotlin
20+
// Import classes:
21+
//import org.openapitools.client.infrastructure.*
22+
//import org.openapitools.client.models.*
23+
24+
val apiInstance = BodyApi()
25+
val metadata : FileMetadata = // FileMetadata |
26+
val file : java.io.File = BINARY_DATA_HERE // java.io.File | File to upload
27+
try {
28+
val result : kotlin.String = apiInstance.testBodyMultipartFormdataWithJsonPart(metadata, file)
29+
println(result)
30+
} catch (e: ClientException) {
31+
println("4xx response calling BodyApi#testBodyMultipartFormdataWithJsonPart")
32+
e.printStackTrace()
33+
} catch (e: ServerException) {
34+
println("5xx response calling BodyApi#testBodyMultipartFormdataWithJsonPart")
35+
e.printStackTrace()
36+
}
37+
```
38+
39+
### Parameters
40+
| **metadata** | [**FileMetadata**](FileMetadata.md)| | |
41+
| Name | Type | Description | Notes |
42+
| ------------- | ------------- | ------------- | ------------- |
43+
| **file** | **java.io.File**| File to upload | |
44+
45+
### Return type
46+
47+
**kotlin.String**
48+
49+
### Authorization
50+
51+
No authorization required
52+
53+
### HTTP request headers
54+
55+
- **Content-Type**: multipart/form-data
56+
- **Accept**: text/plain
57+

0 commit comments

Comments
 (0)