From 3006aad850f340122c9e5b28d9ed13ebbae9f014 Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Fri, 4 Sep 2026 16:40:36 +0200 Subject: [PATCH 1/4] fix: [kotlin] explode object query parameters in the jvm-okhttp client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A query parameter whose schema is an object and whose style/explode are left at their defaults — style: form, explode: true — must go on the wire as one parameter per entry, keyed by the property name alone. The jvm-okhttp client serialized the whole map with toString() into a single parameter instead (filter={tld=com, ...}), and a declared map came out as concatenated Pair.toString()s. The jvm-ktor library already explodes these correctly. The query building in api.mustache now iterates an exploded map entry by entry, the same way jvm-ktor does. deepObject and explode: false objects keep their previous wire format, byte for byte. The new test fixture covers the four style/explode combinations that decide the wire format; the test fails without the template change. No sample changes: no kotlin sample spec declares a free-form or map-typed query parameter with the default style, the echo api's object query parameters are all model-typed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CxDNCjqJycKTfzVWg2SeTJ --- .../libraries/jvm-okhttp/api.mustache | 48 +++++++++++++++++ .../kotlin/KotlinClientCodegenApiTest.java | 27 ++++++++++ .../3_0/exploded-object-query-param.yaml | 51 +++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 modules/openapi-generator/src/test/resources/3_0/exploded-object-query-param.yaml diff --git a/modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-okhttp/api.mustache b/modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-okhttp/api.mustache index 529bfc926130..361415fb7ab7 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-okhttp/api.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-client/libraries/jvm-okhttp/api.mustache @@ -219,7 +219,23 @@ import {{packageName}}.infrastructure.Serializer {{/vars}} {{/isModel}} {{^isModel}} + {{#isMap}} + {{#isExplode}} + {{^isDeepObject}} + // form style explodes an object into one query parameter per entry, keyed by the property name alone + ({{{paramName}}} as? kotlin.collections.Map<*, *>)?.forEach { (key, value) -> put(key.toString(), listOf(value.toString())) } + {{/isDeepObject}} + {{#isDeepObject}} put("{{#lambda.escapeDollar}}{{baseName}}{{/lambda.escapeDollar}}", {{#isContainer}}toMultiValue({{{paramName}}}.toList(), "{{collectionFormat}}"){{/isContainer}}{{^isContainer}}listOf({{#isDateTime}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDateTime}}{{#isDate}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDate}}{{#isEnum}}{{#isString}}{{{paramName}}}.value{{/isString}}{{^isString}}{{{paramName}}}.toString(){{/isString}}{{/isEnum}}{{^isEnum}}{{^isDateTime}}{{^isDate}}{{{paramName}}}.toString(){{/isDate}}{{/isDateTime}}{{/isEnum}}){{/isContainer}}) + {{/isDeepObject}} + {{/isExplode}} + {{^isExplode}} + put("{{#lambda.escapeDollar}}{{baseName}}{{/lambda.escapeDollar}}", {{#isContainer}}toMultiValue({{{paramName}}}.toList(), "{{collectionFormat}}"){{/isContainer}}{{^isContainer}}listOf({{#isDateTime}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDateTime}}{{#isDate}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDate}}{{#isEnum}}{{#isString}}{{{paramName}}}.value{{/isString}}{{^isString}}{{{paramName}}}.toString(){{/isString}}{{/isEnum}}{{^isEnum}}{{^isDateTime}}{{^isDate}}{{{paramName}}}.toString(){{/isDate}}{{/isDateTime}}{{/isEnum}}){{/isContainer}}) + {{/isExplode}} + {{/isMap}} + {{^isMap}} + put("{{#lambda.escapeDollar}}{{baseName}}{{/lambda.escapeDollar}}", {{#isContainer}}toMultiValue({{{paramName}}}.toList(), "{{collectionFormat}}"){{/isContainer}}{{^isContainer}}listOf({{#isDateTime}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDateTime}}{{#isDate}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDate}}{{#isEnum}}{{#isString}}{{{paramName}}}.value{{/isString}}{{^isString}}{{{paramName}}}.toString(){{/isString}}{{/isEnum}}{{^isEnum}}{{^isDateTime}}{{^isDate}}{{{paramName}}}.toString(){{/isDate}}{{/isDateTime}}{{/isEnum}}){{/isContainer}}) + {{/isMap}} {{/isModel}} } {{/required}} @@ -234,7 +250,23 @@ import {{packageName}}.infrastructure.Serializer {{/vars}} {{/isModel}} {{^isModel}} + {{#isMap}} + {{#isExplode}} + {{^isDeepObject}} + // form style explodes an object into one query parameter per entry, keyed by the property name alone + ({{{paramName}}} as? kotlin.collections.Map<*, *>)?.forEach { (key, value) -> put(key.toString(), listOf(value.toString())) } + {{/isDeepObject}} + {{#isDeepObject}} + put("{{#lambda.escapeDollar}}{{baseName}}{{/lambda.escapeDollar}}", {{#isContainer}}toMultiValue({{{paramName}}}.toList(), "{{collectionFormat}}"){{/isContainer}}{{^isContainer}}listOf({{#isDateTime}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDateTime}}{{#isDate}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDate}}{{#isEnum}}{{#isString}}{{{paramName}}}.value{{/isString}}{{^isString}}{{{paramName}}}.toString(){{/isString}}{{/isEnum}}{{^isEnum}}{{^isDateTime}}{{^isDate}}{{{paramName}}}.toString(){{/isDate}}{{/isDateTime}}{{/isEnum}}){{/isContainer}}) + {{/isDeepObject}} + {{/isExplode}} + {{^isExplode}} put("{{#lambda.escapeDollar}}{{baseName}}{{/lambda.escapeDollar}}", {{#isContainer}}toMultiValue({{{paramName}}}.toList(), "{{collectionFormat}}"){{/isContainer}}{{^isContainer}}listOf({{#isDateTime}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDateTime}}{{#isDate}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDate}}{{#isEnum}}{{#isString}}{{{paramName}}}.value{{/isString}}{{^isString}}{{{paramName}}}.toString(){{/isString}}{{/isEnum}}{{^isEnum}}{{^isDateTime}}{{^isDate}}{{{paramName}}}.toString(){{/isDate}}{{/isDateTime}}{{/isEnum}}){{/isContainer}}) + {{/isExplode}} + {{/isMap}} + {{^isMap}} + put("{{#lambda.escapeDollar}}{{baseName}}{{/lambda.escapeDollar}}", {{#isContainer}}toMultiValue({{{paramName}}}.toList(), "{{collectionFormat}}"){{/isContainer}}{{^isContainer}}listOf({{#isDateTime}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDateTime}}{{#isDate}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDate}}{{#isEnum}}{{#isString}}{{{paramName}}}.value{{/isString}}{{^isString}}{{{paramName}}}.toString(){{/isString}}{{/isEnum}}{{^isEnum}}{{^isDateTime}}{{^isDate}}{{{paramName}}}.toString(){{/isDate}}{{/isDateTime}}{{/isEnum}}){{/isContainer}}) + {{/isMap}} {{/isModel}} } {{/isNullable}} @@ -247,7 +279,23 @@ import {{packageName}}.infrastructure.Serializer {{/vars}} {{/isModel}} {{^isModel}} + {{#isMap}} + {{#isExplode}} + {{^isDeepObject}} + // form style explodes an object into one query parameter per entry, keyed by the property name alone + ({{{paramName}}} as? kotlin.collections.Map<*, *>)?.forEach { (key, value) -> put(key.toString(), listOf(value.toString())) } + {{/isDeepObject}} + {{#isDeepObject}} + put("{{#lambda.escapeDollar}}{{baseName}}{{/lambda.escapeDollar}}", {{#isContainer}}toMultiValue({{{paramName}}}.toList(), "{{collectionFormat}}"){{/isContainer}}{{^isContainer}}listOf({{#isDateTime}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDateTime}}{{#isDate}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDate}}{{#isEnum}}{{#isString}}{{{paramName}}}.value{{/isString}}{{^isString}}{{{paramName}}}.toString(){{/isString}}{{/isEnum}}{{^isEnum}}{{^isDateTime}}{{^isDate}}{{{paramName}}}.toString(){{/isDate}}{{/isDateTime}}{{/isEnum}}){{/isContainer}}) + {{/isDeepObject}} + {{/isExplode}} + {{^isExplode}} + put("{{#lambda.escapeDollar}}{{baseName}}{{/lambda.escapeDollar}}", {{#isContainer}}toMultiValue({{{paramName}}}.toList(), "{{collectionFormat}}"){{/isContainer}}{{^isContainer}}listOf({{#isDateTime}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDateTime}}{{#isDate}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDate}}{{#isEnum}}{{#isString}}{{{paramName}}}.value{{/isString}}{{^isString}}{{{paramName}}}.toString(){{/isString}}{{/isEnum}}{{^isEnum}}{{^isDateTime}}{{^isDate}}{{{paramName}}}.toString(){{/isDate}}{{/isDateTime}}{{/isEnum}}){{/isContainer}}) + {{/isExplode}} + {{/isMap}} + {{^isMap}} put("{{#lambda.escapeDollar}}{{baseName}}{{/lambda.escapeDollar}}", {{#isContainer}}toMultiValue({{{paramName}}}.toList(), "{{collectionFormat}}"){{/isContainer}}{{^isContainer}}listOf({{#isDateTime}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDateTime}}{{#isDate}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDate}}{{#isEnum}}{{#isString}}{{{paramName}}}.value{{/isString}}{{^isString}}{{{paramName}}}.toString(){{/isString}}{{/isEnum}}{{^isEnum}}{{^isDateTime}}{{^isDate}}{{{paramName}}}.toString(){{/isDate}}{{/isDateTime}}{{/isEnum}}){{/isContainer}}) + {{/isMap}} {{/isModel}} {{/isNullable}} {{/required}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenApiTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenApiTest.java index 04d59b0700f4..d62aae4a9618 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenApiTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenApiTest.java @@ -229,6 +229,33 @@ public void testJvmKtorQueryParamWithTypeObject() throws IOException { assertFileNotContains(defaultApi.toPath(), "mapDeep?.apply {"); } + @Test(description = "Verify an object query parameter is exploded, whether or not it declares its properties") + public void testExplodedObjectQueryParameterJvmOkhttp() throws IOException { + OpenAPI openAPI = readOpenAPI("src/test/resources/3_0/exploded-object-query-param.yaml"); + + KotlinClientCodegen codegen = createCodegen(ClientLibrary.JVM_OKHTTP4); + DefaultGenerator generator = new DefaultGenerator(); + enableOnlyApiGeneration(generator); + + List files = generator.opts(createClientOptInput(openAPI, codegen)).generate(); + File defaultApi = files.stream().filter(file -> file.getName().equals("DefaultApi.kt")).findAny().orElseThrow(); + + // form style with explode - the default - puts every entry on the wire under its own + // property name. Serializing the whole map with toString() is what used to happen. + assertFileContains(defaultApi.toPath(), + "(filter as? kotlin.collections.Map<*, *>)?.forEach { (key, value) -> put(key.toString(), listOf(value.toString())) }"); + assertFileNotContains(defaultApi.toPath(), "put(\"filter\", listOf(filter.toString()))"); + + // a declared map behaves the same way + assertFileContains(defaultApi.toPath(), + "(typedFilter as? kotlin.collections.Map<*, *>)?.forEach { (key, value) -> put(key.toString(), listOf(value.toString())) }"); + + // deepObject and form without explode both keep a single parameter + assertFileContains(defaultApi.toPath(), + "put(\"deepFilter\", listOf(deepFilter.toString()))", + "put(\"flatFilter\", listOf(flatFilter.toString()))"); + } + private static void assertFileContainsLine(List lines, String line) { Assert.assertListContains(lines, s -> s.equals(line), line); } diff --git a/modules/openapi-generator/src/test/resources/3_0/exploded-object-query-param.yaml b/modules/openapi-generator/src/test/resources/3_0/exploded-object-query-param.yaml new file mode 100644 index 000000000000..fd6fce7d5807 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/exploded-object-query-param.yaml @@ -0,0 +1,51 @@ +openapi: 3.0.3 +info: + title: Exploded object query parameters + description: > + Object typed query parameters, covering the four combinations of style and explode that + decide how an object is put on the wire. The free-form variants matter because a + free-form object is flagged isMap but not isContainer. + version: 1.0.0 +servers: + - url: localhost:8080 +paths: + /items: + get: + operationId: listItems + parameters: + # style and explode both left out, so the form/true defaults apply: every entry + # becomes its own parameter, keyed by the property name alone. + - in: query + name: filter + schema: + type: object + # the same, but declared as a map rather than as a free-form object + - in: query + name: typedFilter + schema: + type: object + additionalProperties: + type: string + # deepObject nests each entry under the parameter name: deepFilter[key]=value + - in: query + name: deepFilter + style: deepObject + explode: true + schema: + type: object + # form without explode keeps a single parameter carrying the whole object + - in: query + name: flatFilter + style: form + explode: false + schema: + type: object + responses: + '200': + description: a list of items + content: + application/json: + schema: + type: array + items: + type: string From 6f228141c5060fd28966bb1dbe7525605c213cb1 Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Mon, 21 Sep 2026 15:22:55 +0200 Subject: [PATCH 2/4] fix: [kotlin] skip nulls, repeat collections and append exploded query entries Follow-up to the review of the exploded object query parameters in the jvm-okhttp client: - a null map entry is left out instead of going on the wire as the literal "null" (Any?.toString() returns "null" rather than throwing) - a collection value repeats the key once per element instead of being sent as the collection's toString(); scalar values and elements go through parameterToString, so dates get the same format as other query parameters - an exploded entry named like another query parameter no longer overwrites it, or gets overwritten by it: the exploded parameters are now emitted after every declared query parameter and append to an existing entry, so both values end up on the wire To place the exploded parameters after the declared ones, the codegen flags them with x-kotlin-explode-form-object (jvm-okhttp libraries only) and api.mustache emits them in a second pass; the three duplicated required/optional/nullable branches of the first version are gone. deepObject and explode: false parameters are unchanged, and no sample changes. Co-Authored-By: Claude Opus 5 (1M context) --- .../languages/KotlinClientCodegen.java | 8 +++ .../libraries/jvm-okhttp/api.mustache | 69 ++++++------------- .../kotlin/KotlinClientCodegenApiTest.java | 22 +++++- 3 files changed, 48 insertions(+), 51 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinClientCodegen.java index 00e523fd449f..0de87c7b0f45 100755 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinClientCodegen.java @@ -1110,6 +1110,14 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List>() .apply { {{#queryParams}} + {{^vendorExtensions.x-kotlin-explode-form-object}} {{^required}} if ({{{paramName}}} != null) { {{#isModel}} @@ -219,23 +220,7 @@ import {{packageName}}.infrastructure.Serializer {{/vars}} {{/isModel}} {{^isModel}} - {{#isMap}} - {{#isExplode}} - {{^isDeepObject}} - // form style explodes an object into one query parameter per entry, keyed by the property name alone - ({{{paramName}}} as? kotlin.collections.Map<*, *>)?.forEach { (key, value) -> put(key.toString(), listOf(value.toString())) } - {{/isDeepObject}} - {{#isDeepObject}} put("{{#lambda.escapeDollar}}{{baseName}}{{/lambda.escapeDollar}}", {{#isContainer}}toMultiValue({{{paramName}}}.toList(), "{{collectionFormat}}"){{/isContainer}}{{^isContainer}}listOf({{#isDateTime}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDateTime}}{{#isDate}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDate}}{{#isEnum}}{{#isString}}{{{paramName}}}.value{{/isString}}{{^isString}}{{{paramName}}}.toString(){{/isString}}{{/isEnum}}{{^isEnum}}{{^isDateTime}}{{^isDate}}{{{paramName}}}.toString(){{/isDate}}{{/isDateTime}}{{/isEnum}}){{/isContainer}}) - {{/isDeepObject}} - {{/isExplode}} - {{^isExplode}} - put("{{#lambda.escapeDollar}}{{baseName}}{{/lambda.escapeDollar}}", {{#isContainer}}toMultiValue({{{paramName}}}.toList(), "{{collectionFormat}}"){{/isContainer}}{{^isContainer}}listOf({{#isDateTime}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDateTime}}{{#isDate}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDate}}{{#isEnum}}{{#isString}}{{{paramName}}}.value{{/isString}}{{^isString}}{{{paramName}}}.toString(){{/isString}}{{/isEnum}}{{^isEnum}}{{^isDateTime}}{{^isDate}}{{{paramName}}}.toString(){{/isDate}}{{/isDateTime}}{{/isEnum}}){{/isContainer}}) - {{/isExplode}} - {{/isMap}} - {{^isMap}} - put("{{#lambda.escapeDollar}}{{baseName}}{{/lambda.escapeDollar}}", {{#isContainer}}toMultiValue({{{paramName}}}.toList(), "{{collectionFormat}}"){{/isContainer}}{{^isContainer}}listOf({{#isDateTime}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDateTime}}{{#isDate}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDate}}{{#isEnum}}{{#isString}}{{{paramName}}}.value{{/isString}}{{^isString}}{{{paramName}}}.toString(){{/isString}}{{/isEnum}}{{^isEnum}}{{^isDateTime}}{{^isDate}}{{{paramName}}}.toString(){{/isDate}}{{/isDateTime}}{{/isEnum}}){{/isContainer}}) - {{/isMap}} {{/isModel}} } {{/required}} @@ -250,23 +235,7 @@ import {{packageName}}.infrastructure.Serializer {{/vars}} {{/isModel}} {{^isModel}} - {{#isMap}} - {{#isExplode}} - {{^isDeepObject}} - // form style explodes an object into one query parameter per entry, keyed by the property name alone - ({{{paramName}}} as? kotlin.collections.Map<*, *>)?.forEach { (key, value) -> put(key.toString(), listOf(value.toString())) } - {{/isDeepObject}} - {{#isDeepObject}} - put("{{#lambda.escapeDollar}}{{baseName}}{{/lambda.escapeDollar}}", {{#isContainer}}toMultiValue({{{paramName}}}.toList(), "{{collectionFormat}}"){{/isContainer}}{{^isContainer}}listOf({{#isDateTime}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDateTime}}{{#isDate}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDate}}{{#isEnum}}{{#isString}}{{{paramName}}}.value{{/isString}}{{^isString}}{{{paramName}}}.toString(){{/isString}}{{/isEnum}}{{^isEnum}}{{^isDateTime}}{{^isDate}}{{{paramName}}}.toString(){{/isDate}}{{/isDateTime}}{{/isEnum}}){{/isContainer}}) - {{/isDeepObject}} - {{/isExplode}} - {{^isExplode}} - put("{{#lambda.escapeDollar}}{{baseName}}{{/lambda.escapeDollar}}", {{#isContainer}}toMultiValue({{{paramName}}}.toList(), "{{collectionFormat}}"){{/isContainer}}{{^isContainer}}listOf({{#isDateTime}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDateTime}}{{#isDate}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDate}}{{#isEnum}}{{#isString}}{{{paramName}}}.value{{/isString}}{{^isString}}{{{paramName}}}.toString(){{/isString}}{{/isEnum}}{{^isEnum}}{{^isDateTime}}{{^isDate}}{{{paramName}}}.toString(){{/isDate}}{{/isDateTime}}{{/isEnum}}){{/isContainer}}) - {{/isExplode}} - {{/isMap}} - {{^isMap}} put("{{#lambda.escapeDollar}}{{baseName}}{{/lambda.escapeDollar}}", {{#isContainer}}toMultiValue({{{paramName}}}.toList(), "{{collectionFormat}}"){{/isContainer}}{{^isContainer}}listOf({{#isDateTime}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDateTime}}{{#isDate}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDate}}{{#isEnum}}{{#isString}}{{{paramName}}}.value{{/isString}}{{^isString}}{{{paramName}}}.toString(){{/isString}}{{/isEnum}}{{^isEnum}}{{^isDateTime}}{{^isDate}}{{{paramName}}}.toString(){{/isDate}}{{/isDateTime}}{{/isEnum}}){{/isContainer}}) - {{/isMap}} {{/isModel}} } {{/isNullable}} @@ -279,26 +248,30 @@ import {{packageName}}.infrastructure.Serializer {{/vars}} {{/isModel}} {{^isModel}} - {{#isMap}} - {{#isExplode}} - {{^isDeepObject}} - // form style explodes an object into one query parameter per entry, keyed by the property name alone - ({{{paramName}}} as? kotlin.collections.Map<*, *>)?.forEach { (key, value) -> put(key.toString(), listOf(value.toString())) } - {{/isDeepObject}} - {{#isDeepObject}} - put("{{#lambda.escapeDollar}}{{baseName}}{{/lambda.escapeDollar}}", {{#isContainer}}toMultiValue({{{paramName}}}.toList(), "{{collectionFormat}}"){{/isContainer}}{{^isContainer}}listOf({{#isDateTime}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDateTime}}{{#isDate}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDate}}{{#isEnum}}{{#isString}}{{{paramName}}}.value{{/isString}}{{^isString}}{{{paramName}}}.toString(){{/isString}}{{/isEnum}}{{^isEnum}}{{^isDateTime}}{{^isDate}}{{{paramName}}}.toString(){{/isDate}}{{/isDateTime}}{{/isEnum}}){{/isContainer}}) - {{/isDeepObject}} - {{/isExplode}} - {{^isExplode}} - put("{{#lambda.escapeDollar}}{{baseName}}{{/lambda.escapeDollar}}", {{#isContainer}}toMultiValue({{{paramName}}}.toList(), "{{collectionFormat}}"){{/isContainer}}{{^isContainer}}listOf({{#isDateTime}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDateTime}}{{#isDate}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDate}}{{#isEnum}}{{#isString}}{{{paramName}}}.value{{/isString}}{{^isString}}{{{paramName}}}.toString(){{/isString}}{{/isEnum}}{{^isEnum}}{{^isDateTime}}{{^isDate}}{{{paramName}}}.toString(){{/isDate}}{{/isDateTime}}{{/isEnum}}){{/isContainer}}) - {{/isExplode}} - {{/isMap}} - {{^isMap}} put("{{#lambda.escapeDollar}}{{baseName}}{{/lambda.escapeDollar}}", {{#isContainer}}toMultiValue({{{paramName}}}.toList(), "{{collectionFormat}}"){{/isContainer}}{{^isContainer}}listOf({{#isDateTime}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDateTime}}{{#isDate}}parseDateToQueryString<{{{dataType}}}>({{{paramName}}}){{/isDate}}{{#isEnum}}{{#isString}}{{{paramName}}}.value{{/isString}}{{^isString}}{{{paramName}}}.toString(){{/isString}}{{/isEnum}}{{^isEnum}}{{^isDateTime}}{{^isDate}}{{{paramName}}}.toString(){{/isDate}}{{/isDateTime}}{{/isEnum}}){{/isContainer}}) - {{/isMap}} {{/isModel}} {{/isNullable}} {{/required}} + {{/vendorExtensions.x-kotlin-explode-form-object}} + {{/queryParams}} + {{#queryParams}} + {{#vendorExtensions.x-kotlin-explode-form-object}} + // form style explodes an object into one query parameter per entry, keyed by the property name alone + ({{{paramName}}} as? kotlin.collections.Map<*, *>)?.forEach { (key, value) -> + // a null entry is left out, a collection repeats the key once per element + val values = when (value) { + null -> emptyList() + is kotlin.collections.Iterable<*> -> value.filterNotNull().map { parameterToString(it) } + is kotlin.Array<*> -> value.filterNotNull().map { parameterToString(it) } + else -> listOf(parameterToString(value)) + } + // appended after the declared parameters, so an entry named like one of them adds a value instead of replacing it + if (key != null && values.isNotEmpty()) { + val name = key.toString() + put(name, getOrElse(name) { emptyList() } + values) + } + } + {{/vendorExtensions.x-kotlin-explode-form-object}} {{/queryParams}} } {{/hasQueryParams}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenApiTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenApiTest.java index d62aae4a9618..3f58117ca9cc 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenApiTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenApiTest.java @@ -15,6 +15,7 @@ import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.util.List; @@ -240,20 +241,35 @@ public void testExplodedObjectQueryParameterJvmOkhttp() throws IOException { List files = generator.opts(createClientOptInput(openAPI, codegen)).generate(); File defaultApi = files.stream().filter(file -> file.getName().equals("DefaultApi.kt")).findAny().orElseThrow(); + String content = new String(Files.readAllBytes(defaultApi.toPath()), StandardCharsets.UTF_8); + // form style with explode - the default - puts every entry on the wire under its own // property name. Serializing the whole map with toString() is what used to happen. - assertFileContains(defaultApi.toPath(), - "(filter as? kotlin.collections.Map<*, *>)?.forEach { (key, value) -> put(key.toString(), listOf(value.toString())) }"); + assertFileContains(defaultApi.toPath(), "(filter as? kotlin.collections.Map<*, *>)?.forEach { (key, value) ->"); assertFileNotContains(defaultApi.toPath(), "put(\"filter\", listOf(filter.toString()))"); // a declared map behaves the same way + assertFileContains(defaultApi.toPath(), "(typedFilter as? kotlin.collections.Map<*, *>)?.forEach { (key, value) ->"); + assertFileNotContains(defaultApi.toPath(), "put(\"typedFilter\""); + + // a null entry is left out rather than sent as "null", a collection repeats the key + // once per element rather than going out as its toString(), and an entry is appended + // rather than replacing a query parameter of the same name assertFileContains(defaultApi.toPath(), - "(typedFilter as? kotlin.collections.Map<*, *>)?.forEach { (key, value) -> put(key.toString(), listOf(value.toString())) }"); + "null -> emptyList()", + "is kotlin.collections.Iterable<*> -> value.filterNotNull().map { parameterToString(it) }", + "else -> listOf(parameterToString(value))", + "put(name, getOrElse(name) { emptyList() } + values)"); // deepObject and form without explode both keep a single parameter assertFileContains(defaultApi.toPath(), "put(\"deepFilter\", listOf(deepFilter.toString()))", "put(\"flatFilter\", listOf(flatFilter.toString()))"); + + // the exploded entries are added after every declared parameter, so a declared + // parameter's put cannot overwrite an entry that happens to share its name + Assert.assertTrue(content.indexOf("(filter as? kotlin.collections.Map") > content.indexOf("put(\"flatFilter\""), + "exploded entries must be added after the declared query parameters"); } private static void assertFileContainsLine(List lines, String line) { From 3334cab21693d2c87fc8ea8b42ed4092bb2de808 Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Wed, 23 Sep 2026 08:59:22 +0200 Subject: [PATCH 3/4] chore: [kotlin] trim the exploded query comment and simplify the entry loop One comment line above the loop instead of three; the when now only picks the elements and filterNotNull/parameterToString run once. Drop the defensive !isModel check, which no other port has: a map-typed parameter is never isModel. Fix the fixture description, which claimed four style/explode combinations where it covers three, and retitle the test. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../codegen/languages/KotlinClientCodegen.java | 2 +- .../kotlin-client/libraries/jvm-okhttp/api.mustache | 13 +++++-------- .../codegen/kotlin/KotlinClientCodegenApiTest.java | 7 +++---- .../resources/3_0/exploded-object-query-param.yaml | 6 +++--- 4 files changed, 12 insertions(+), 16 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinClientCodegen.java index 0de87c7b0f45..520866c38e99 100755 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinClientCodegen.java @@ -1114,7 +1114,7 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List)?.forEach { (key, value) -> - // a null entry is left out, a collection repeats the key once per element val values = when (value) { - null -> emptyList() - is kotlin.collections.Iterable<*> -> value.filterNotNull().map { parameterToString(it) } - is kotlin.Array<*> -> value.filterNotNull().map { parameterToString(it) } - else -> listOf(parameterToString(value)) - } - // appended after the declared parameters, so an entry named like one of them adds a value instead of replacing it + is kotlin.collections.Iterable<*> -> value.toList() + is kotlin.Array<*> -> value.toList() + else -> listOf(value) + }.filterNotNull().map { parameterToString(it) } if (key != null && values.isNotEmpty()) { val name = key.toString() put(name, getOrElse(name) { emptyList() } + values) diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenApiTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenApiTest.java index 3f58117ca9cc..f8f28388769c 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenApiTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenApiTest.java @@ -230,7 +230,7 @@ public void testJvmKtorQueryParamWithTypeObject() throws IOException { assertFileNotContains(defaultApi.toPath(), "mapDeep?.apply {"); } - @Test(description = "Verify an object query parameter is exploded, whether or not it declares its properties") + @Test(description = "Verify a form style, exploded map query parameter goes on the wire one entry per parameter") public void testExplodedObjectQueryParameterJvmOkhttp() throws IOException { OpenAPI openAPI = readOpenAPI("src/test/resources/3_0/exploded-object-query-param.yaml"); @@ -256,9 +256,8 @@ public void testExplodedObjectQueryParameterJvmOkhttp() throws IOException { // once per element rather than going out as its toString(), and an entry is appended // rather than replacing a query parameter of the same name assertFileContains(defaultApi.toPath(), - "null -> emptyList()", - "is kotlin.collections.Iterable<*> -> value.filterNotNull().map { parameterToString(it) }", - "else -> listOf(parameterToString(value))", + "is kotlin.collections.Iterable<*> -> value.toList()", + "}.filterNotNull().map { parameterToString(it) }", "put(name, getOrElse(name) { emptyList() } + values)"); // deepObject and form without explode both keep a single parameter diff --git a/modules/openapi-generator/src/test/resources/3_0/exploded-object-query-param.yaml b/modules/openapi-generator/src/test/resources/3_0/exploded-object-query-param.yaml index fd6fce7d5807..a24c4c916d5e 100644 --- a/modules/openapi-generator/src/test/resources/3_0/exploded-object-query-param.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/exploded-object-query-param.yaml @@ -2,9 +2,9 @@ openapi: 3.0.3 info: title: Exploded object query parameters description: > - Object typed query parameters, covering the four combinations of style and explode that - decide how an object is put on the wire. The free-form variants matter because a - free-form object is flagged isMap but not isContainer. + Object typed query parameters under form/explode (as a free-form object and as a typed map), + deepObject, and form without explode. The free-form variant matters because it is flagged + isMap but not isContainer. version: 1.0.0 servers: - url: localhost:8080 From c265d144bcc057169c55869d140bd7368dafe26c Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Wed, 23 Sep 2026 09:00:14 +0200 Subject: [PATCH 4/4] test: [kotlin] keep the shared fixture byte-identical with the other ports Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/test/resources/3_0/exploded-object-query-param.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/modules/openapi-generator/src/test/resources/3_0/exploded-object-query-param.yaml b/modules/openapi-generator/src/test/resources/3_0/exploded-object-query-param.yaml index a24c4c916d5e..2f94dce6c7a6 100644 --- a/modules/openapi-generator/src/test/resources/3_0/exploded-object-query-param.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/exploded-object-query-param.yaml @@ -2,9 +2,7 @@ openapi: 3.0.3 info: title: Exploded object query parameters description: > - Object typed query parameters under form/explode (as a free-form object and as a typed map), - deepObject, and form without explode. The free-form variant matters because it is flagged - isMap but not isContainer. + Object typed query parameters under form/explode (as a free-form object and as a typed map), deepObject, and form without explode. The free-form variant matters because it is flagged isMap but not isContainer. version: 1.0.0 servers: - url: localhost:8080