Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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())) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: When an exploded object key matches another query parameter name, put silently overwrites that parameter's values. Append to the existing list so both query values remain on the wire.

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 226:

<comment>When an exploded object key matches another query parameter name, `put` silently overwrites that parameter's values. Append to the existing list so both query values remain on the wire.</comment>

<file context>
@@ -219,7 +219,23 @@ import {{packageName}}.infrastructure.Serializer
+                    {{#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}}
</file context>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: When a map value is a collection, this sends Kotlin's collection representation as one query value instead of the declared wire format. Serialize each map value according to its inner schema, including collection and date formatting.

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 226:

<comment>When a map value is a collection, this sends Kotlin's collection representation as one query value instead of the declared wire format. Serialize each map value according to its inner schema, including collection and date formatting.</comment>

<file context>
@@ -219,7 +219,23 @@ import {{packageName}}.infrastructure.Serializer
+                    {{#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}}
</file context>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: Map entries whose value (or key) is null serialize as the literal string "null" on the wire. Kotlin resolves value.toString() on the nullable Any? lambda receiver to the stdlib Any?.toString() extension, which returns "null" for a null receiver instead of throwing, so mapOf("a" to null) becomes a=null rather than omitting the entry. Guard the null case so null-valued entries are skipped.

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 226:

<comment>Map entries whose value (or key) is null serialize as the literal string `"null"` on the wire. Kotlin resolves `value.toString()` on the nullable `Any?` lambda receiver to the stdlib `Any?.toString()` extension, which returns `"null"` for a null receiver instead of throwing, so `mapOf("a" to null)` becomes `a=null` rather than omitting the entry. Guard the null case so null-valued entries are skipped.</comment>

<file context>
@@ -219,7 +219,23 @@ import {{packageName}}.infrastructure.Serializer
+                    {{#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}}
</file context>

{{/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}}
Expand All @@ -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}}
Expand All @@ -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}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<File> 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<String> lines, String line) {
Assert.assertListContains(lines, s -> s.equals(line), line);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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