From 3006aad850f340122c9e5b28d9ed13ebbae9f014 Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Fri, 4 Sep 2026 16:40:36 +0200 Subject: [PATCH] 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