Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
Copy Markdown
Contributor

@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
@@ -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
61 changes: 61 additions & 0 deletions samples/client/echo_api/kotlin-jvm-okhttp-multipart-json/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# org.openapitools.client - Kotlin client library for Echo API for Kotlin Multipart JSON Test

Echo server API to test multipart/form-data with JSON content-type

## Overview
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.

- API version: 1.0.0
- Package version:
- Generator version: 7.20.0-SNAPSHOT
- Build package: org.openapitools.codegen.languages.KotlinClientCodegen

## Requires

* Kotlin 2.2.20
* Gradle 8.14

## Build

First, create the gradle wrapper script:

```
gradle wrapper
```

Then, run:

```
./gradlew check assemble
```

This runs all tests and packages the library.

## Features/Implementation Notes

* Supports JSON inputs/outputs, File inputs, and Form inputs.
* Supports collection formats for query parameters: csv, tsv, ssv, pipes.
* Some Kotlin and Java types are fully qualified to avoid conflicts with types defined in OpenAPI definitions.
* Implementation of ApiClient is intended to reduce method counts, specifically to benefit Android targets.

<a id="documentation-for-api-endpoints"></a>
## Documentation for API Endpoints

All URIs are relative to *http://localhost:3000*

| Class | Method | HTTP request | Description |
| ------------ | ------------- | ------------- | ------------- |
| *BodyApi* | [**testBodyMultipartFormdataWithJsonPart**](docs/BodyApi.md#testbodymultipartformdatawithjsonpart) | **POST** /body/multipart/formdata/with_json_part | Test multipart with JSON part |


<a id="documentation-for-models"></a>
## Documentation for Models

- [org.openapitools.client.models.FileMetadata](docs/FileMetadata.md)


<a id="documentation-for-authorization"></a>
## Documentation for Authorization

Endpoints do not require authorization.

Loading
Loading