Skip to content
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
1 change: 1 addition & 0 deletions .github/workflows/samples-kotlin-echo-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
- samples/client/echo_api/kotlin-jvm-spring-3-restclient
- samples/client/echo_api/kotlin-model-prefix-type-mappings
- samples/client/echo_api/kotlin-jvm-okhttp
- samples/client/echo_api/kotlin-jvm-okhttp-multipart-json
steps:
- uses: actions/checkout@v5
- uses: actions/setup-java@v5
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,9 @@ samples/client/petstore/kotlin*/src/main/kotlin/test/
samples/client/petstore/kotlin*/build/
samples/server/others/kotlin-server/jaxrs-spec/build/
samples/client/echo_api/kotlin-jvm-spring-3-restclient/build/
samples/client/echo_api/kotlin-jvm-spring-3-webclient/build/
samples/client/echo_api/kotlin-jvm-okhttp/build/
samples/client/echo_api/kotlin-jvm-okhttp-multipart-json/build/

# haskell
.stack-work
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,16 @@ package {{packageName}}.infrastructure
* Defines a config object for a given part of a multi-part request.
* NOTE: Headers is a Map<String,String> because rfc2616 defines
* multi-valued headers as csv-only.
*
* @property headers The headers for this part
* @property body The body content for this part
* @property serializer Optional custom serializer for JSON content. When provided, this will be
* used instead of the default serialization for parts with application/json
* content-type. This allows capturing type information at the call site to
* avoid issues with type erasure in kotlinx.serialization.
*/
{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}data class PartConfig<T>(
val headers: MutableMap<String, String> = mutableMapOf(),
val body: T? = null
val body: T? = null,
val serializer: ((Any?) -> String)? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ import {{packageName}}.infrastructure.RequestMethod
import {{packageName}}.infrastructure.ResponseType
import {{packageName}}.infrastructure.Success
import {{packageName}}.infrastructure.toMultiValue
{{#kotlinx_serialization}}
import {{packageName}}.infrastructure.Serializer
{{/kotlinx_serialization}}

{{#operations}}
{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}open {{/nonPublicApi}}class {{classname}}(basePath: kotlin.String = defaultBasePath, client: Call.Factory = ApiClient.defaultClient) : ApiClient(basePath, client) {
Expand Down Expand Up @@ -199,7 +202,7 @@ import {{packageName}}.infrastructure.toMultiValue
}}{{#bodyParams}}{{{paramName}}}{{/bodyParams}}{{/hasBodyParam}}{{^hasBodyParam}}{{!
}}{{^hasFormParams}}null{{/hasFormParams}}{{!
}}{{#hasFormParams}}mapOf({{#formParams}}
"{{#lambda.escapeDollar}}{{{baseName}}}{{/lambda.escapeDollar}}" to PartConfig(body = {{{paramName}}}{{#isEnum}}{{^required}}?{{/required}}.value{{/isEnum}}, headers = mutableMapOf({{#contentType}}"Content-Type" to "{{contentType}}"{{/contentType}})),{{!
"{{#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}}),{{!
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 1, 2026

Choose a reason for hiding this comment

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

P2: Serializer lambda casts multipart part bodies to the declared data type even when the body is a String enum value or null, which will crash at runtime for enum and optional parameters.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-okhttp/api.mustache, line 205:

<comment>Serializer lambda casts multipart part bodies to the declared data type even when the body is a String enum value or null, which will crash at runtime for enum and optional parameters.</comment>

<file context>
@@ -199,7 +202,7 @@ import {{packageName}}.infrastructure.toMultiValue
           }}{{^hasFormParams}}null{{/hasFormParams}}{{!
           }}{{#hasFormParams}}mapOf({{#formParams}}
-            "{{#lambda.escapeDollar}}{{{baseName}}}{{/lambda.escapeDollar}}" to PartConfig(body = {{{paramName}}}{{#isEnum}}{{^required}}?{{/required}}.value{{/isEnum}}, headers = mutableMapOf({{#contentType}}"Content-Type" to "{{contentType}}"{{/contentType}})),{{!
+            "{{#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}}),{{!
             }}{{/formParams}}){{/hasFormParams}}{{!
         }}{{/hasBodyParam}}
</file context>
Fix with Cubic

}}{{/formParams}}){{/hasFormParams}}{{!
}}{{/hasBodyParam}}
val localVariableQuery: MultiValueMap = {{^hasQueryParams}}mutableMapOf()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,25 @@ import com.squareup.moshi.adapter
return contentType ?: "application/octet-stream"
}

/**
* Builds headers for a multipart form-data part.
* OkHttp requires Content-Type to be passed via the RequestBody parameter, not in headers.
* This function filters out Content-Type and builds the appropriate Content-Disposition header.
*
* @param name The field name
* @param headers The headers from the PartConfig (may include Content-Type)
* @param filename Optional filename for file uploads
* @return Headers object ready for addPart()
*/
protected fun buildPartHeaders(name: String, headers: Map<String, String>, filename: String? = null): Headers {
val disposition = if (filename != null) {
"form-data; name=\"$name\"; filename=\"$filename\""
} else {
"form-data; name=\"$name\""
}
return (headers.filterKeys { it != "Content-Type" } + ("Content-Disposition" to disposition)).toHeaders()
}

/**
* Adds a File to a MultipartBody.Builder
* Defined a helper in the requestBody method to not duplicate code
Expand All @@ -127,15 +146,48 @@ import com.squareup.moshi.adapter
* @see requestBody
*/
protected fun MultipartBody.Builder.addPartToMultiPart(name: String, headers: Map<String, String>, file: File) {
val partHeaders = headers.toMutableMap() +
("Content-Disposition" to "form-data; name=\"$name\"; filename=\"${file.name}\"")
val fileMediaType = guessContentTypeFromFile(file).toMediaTypeOrNull()
addPart(
partHeaders.toHeaders(),
buildPartHeaders(name, headers, file.name),
file.asRequestBody(fileMediaType)
)
}

/**
* Serializes a multipart body part based on its content type.
* Uses JSON serialization for application/json content types, otherwise converts to string.
*
* @param obj The object to serialize
* @param contentType The Content-Type header value, if any
* @param serializer Optional custom serializer (used for kotlinx.serialization to preserve type info)
* @return The serialized string representation
*/
protected fun serializePartBody(obj: Any?, contentType: String?, serializer: ((Any?) -> String)?): String {
// Use custom serializer if provided (for kotlinx.serialization with captured type info)
if (serializer != null) {
return serializer(obj)
}

return if (contentType?.contains("json") == true) {
{{#moshi}}
Serializer.moshi.adapter(Any::class.java).toJson(obj)
{{/moshi}}
{{#gson}}
Serializer.gson.toJson(obj)
{{/gson}}
{{#jackson}}
Serializer.jacksonObjectMapper.writeValueAsString(obj)
{{/jackson}}
{{#kotlinx_serialization}}
// Note: Without a custom serializer, kotlinx.serialization cannot serialize Any?
// The custom serializer should be provided at PartConfig creation to capture type info
parameterToString(obj)
{{/kotlinx_serialization}}
} else {
parameterToString(obj)
}
}

/**
* Adds any type to a MultipartBody.Builder
* Defined a helper in the requestBody method to not duplicate code
Expand All @@ -144,15 +196,17 @@ import com.squareup.moshi.adapter
* @param name The field name to add in the request
* @param headers The headers that are in the PartConfig
* @param obj The field name to add in the request
* @param serializer Optional custom serializer for this part
* @return The method returns Unit but the new Part is added to the Builder that the extension function is applying on
* @see requestBody
*/
protected fun <T> MultipartBody.Builder.addPartToMultiPart(name: String, headers: Map<String, String>, obj: T?) {
val partHeaders = headers.toMutableMap() +
("Content-Disposition" to "form-data; name=\"$name\"")
protected fun MultipartBody.Builder.addPartToMultiPart(name: String, headers: Map<String, String>, obj: Any?, serializer: ((Any?) -> String)? = null) {
val partContentType = headers["Content-Type"]
val partMediaType = partContentType?.toMediaTypeOrNull()
val partBody = serializePartBody(obj, partContentType, serializer)
addPart(
partHeaders.toHeaders(),
parameterToString(obj).toRequestBody(null)
buildPartHeaders(name, headers),
partBody.toRequestBody(partMediaType)
)
}

Expand All @@ -174,11 +228,11 @@ import com.squareup.moshi.adapter
if (it is File) {
addPartToMultiPart(name, part.headers, it)
} else {
addPartToMultiPart(name, part.headers, it)
addPartToMultiPart(name, part.headers, it, part.serializer)
}
}
}
else -> addPartToMultiPart(name, part.headers, part.body)
else -> addPartToMultiPart(name, part.headers, part.body, part.serializer)
}
}
}.build()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import io.ktor.client.request.forms.formData
import io.ktor.client.engine.HttpClientEngine
import kotlinx.serialization.json.Json
import io.ktor.http.ParametersBuilder
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.ContentType
import io.ktor.http.content.PartData
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
Expand Down Expand Up @@ -76,11 +80,31 @@ import kotlinx.serialization.encoding.*
{{#formParams}}
{{#isArray}}
{{{paramName}}}?.onEach {
{{#isFile}}append(it){{/isFile}}{{^isFile}}append("{{{baseName}}}", it){{/isFile}}
{{#isFile}}append(it){{/isFile}}{{^isFile}}append("{{{baseName}}}", it.toString()){{/isFile}}
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 4, 2026

Choose a reason for hiding this comment

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

P2: Multipart arrays of non-file objects are still serialized with toString(), so arrays of models will send class representations instead of JSON, unlike the updated single-object handling.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/resources/kotlin-client/libraries/multiplatform/api.mustache, line 83:

<comment>Multipart arrays of non-file objects are still serialized with `toString()`, so arrays of models will send class representations instead of JSON, unlike the updated single-object handling.</comment>

<file context>
@@ -76,11 +80,31 @@ import kotlinx.serialization.encoding.*
                 {{#isArray}}
                 {{{paramName}}}?.onEach {
-                    {{#isFile}}append(it){{/isFile}}{{^isFile}}append("{{{baseName}}}", it){{/isFile}}
+                    {{#isFile}}append(it){{/isFile}}{{^isFile}}append("{{{baseName}}}", it.toString()){{/isFile}}
                 }
                 {{/isArray}}
</file context>
Fix with Cubic

}
{{/isArray}}
{{^isArray}}
{{{paramName}}}?.apply { {{#isFile}}append({{{baseName}}}){{/isFile}}{{^isFile}}append("{{{baseName}}}", {{^isEnumOrRef}}{{{paramName}}}{{/isEnumOrRef}}{{#isEnumOrRef}}{{{paramName}}}.value{{/isEnumOrRef}}){{/isFile}} }
{{#isFile}}
{{{paramName}}}?.apply { append({{{baseName}}}) }
{{/isFile}}
{{^isFile}}
{{#isPrimitiveType}}
{{#isString}}
{{{paramName}}}?.apply { append("{{{baseName}}}", {{{paramName}}}) }
{{/isString}}
{{^isString}}
{{{paramName}}}?.apply { append("{{{baseName}}}", {{{paramName}}}.toString()) }
{{/isString}}
{{/isPrimitiveType}}
{{^isPrimitiveType}}
{{#isEnumOrRef}}
{{{paramName}}}?.apply { append("{{{baseName}}}", {{{paramName}}}.value.toString()) }
{{/isEnumOrRef}}
{{^isEnumOrRef}}
{{{paramName}}}?.apply { append("{{{baseName}}}", ApiClient.JSON_DEFAULT.encodeToString({{{dataType}}}.serializer(), {{{paramName}}})) }
{{/isEnumOrRef}}
{{/isPrimitiveType}}
{{/isFile}}
{{/isArray}}
{{/formParams}}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
openapi: 3.0.0
servers:
- url: 'http://localhost:3000/'
info:
version: 1.0.0
title: Echo API for Kotlin Multipart JSON Test
description: Echo server API to test multipart/form-data with JSON content-type
license:
name: Apache-2.0
url: 'https://www.apache.org/licenses/LICENSE-2.0.html'
tags:
- name: body
description: Test body operations
paths:
/body/multipart/formdata/with_json_part:
post:
tags:
- body
summary: Test multipart with JSON part
description: Test multipart/form-data with a part that has Content-Type application/json
operationId: testBodyMultipartFormdataWithJsonPart
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required:
- metadata
- file
properties:
metadata:
$ref: '#/components/schemas/FileMetadata'
file:
type: string
format: binary
description: File to upload
encoding:
metadata:
contentType: application/json
file:
contentType: image/jpeg
responses:
'200':
description: Successful operation
content:
text/plain:
schema:
type: string
components:
schemas:
FileMetadata:
type: object
required:
- id
- name
properties:
id:
type: integer
format: int64
example: 12345
name:
type: string
example: test-file
tags:
type: array
items:
type: string
example: ["tag1", "tag2"]
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,35 @@ paths:
name: id
in: path
required: true
'/v1/bird/upload':
post:
tags:
- bird
operationId: upload-bird-with-metadata
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
metadata:
$ref: '#/components/schemas/bird'
file:
type: string
format: binary
required:
- metadata
- file
encoding:
metadata:
contentType: application/json
responses:
'200':
description: Upload successful
content:
text/plain:
schema:
type: string
components:
schemas:
animal:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# OpenAPI Generator Ignore
# Generated by openapi-generator https://github.com/openapitools/openapi-generator

# Use this file to prevent files from being overwritten by the generator.
# The patterns follow closely to .gitignore or .dockerignore.

# As an example, the C# client generator defines ApiClient.cs.
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
#ApiClient.cs

# You can match any string of characters against a directory, file or extension with a single asterisk (*):
#foo/*/qux
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux

# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
#foo/**/qux
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux

# You can also negate patterns with an exclamation (!).
# For example, you can ignore all files in a docs folder with the file extension .md:
#docs/*.md
# Then explicitly reverse the ignore rule for a single file:
#!docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
README.md
build.gradle
docs/BodyApi.md
docs/FileMetadata.md
gradle/wrapper/gradle-wrapper.jar
gradle/wrapper/gradle-wrapper.properties
gradlew
gradlew.bat
settings.gradle
src/main/kotlin/org/openapitools/client/apis/BodyApi.kt
src/main/kotlin/org/openapitools/client/infrastructure/ApiAbstractions.kt
src/main/kotlin/org/openapitools/client/infrastructure/ApiClient.kt
src/main/kotlin/org/openapitools/client/infrastructure/ApiResponse.kt
src/main/kotlin/org/openapitools/client/infrastructure/ByteArrayAdapter.kt
src/main/kotlin/org/openapitools/client/infrastructure/Errors.kt
src/main/kotlin/org/openapitools/client/infrastructure/LocalDateAdapter.kt
src/main/kotlin/org/openapitools/client/infrastructure/LocalDateTimeAdapter.kt
src/main/kotlin/org/openapitools/client/infrastructure/OffsetDateTimeAdapter.kt
src/main/kotlin/org/openapitools/client/infrastructure/PartConfig.kt
src/main/kotlin/org/openapitools/client/infrastructure/RequestConfig.kt
src/main/kotlin/org/openapitools/client/infrastructure/RequestMethod.kt
src/main/kotlin/org/openapitools/client/infrastructure/ResponseExtensions.kt
src/main/kotlin/org/openapitools/client/infrastructure/Serializer.kt
src/main/kotlin/org/openapitools/client/models/FileMetadata.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
7.20.0-SNAPSHOT
Loading
Loading