fix(kotlin-client): escape `$` in wire-name string literals

KH
21h ago
+268-66

In the kotlin client, a $ in a wire name (the name that goes on the wire: the operation path, a path placeholder, a query/header/multipart key or an API-key name) is written into a Kotlin string literal unescaped in most libraries. Kotlin reads $name inside a string as a template for the variable name, so OData-style names such as $filter, $top, /odata/$count or {$id} either fail to compile or are sent under a different key.

Reproducer

modules/openapi-generator/src/test/resources/3_0/kotlin/kotlin-dollar-wire-name.yaml (added by this PR) declares:

  • query parameters $filter, $top and filter, and a header $x-head;
  • an exploded object query parameter opts (each property is sent as its own key) with a property $a, and a deepObject parameter $dobj with a property $f;
  • a multipart property $fprop, the paths /items/{$id} and /odata/$count, and an API key named $api_key.

What master generates from it:

  • $top, $id and $count have no variable of that name in scope. They are unresolved references, so the file does not compile.
  • $filter resolves to the filter parameter that the fixture also declares. In jvm-ktor, jvm-vertx, multiplatform, jvm-volley and the two jvm-spring-* libraries that literal is an ordinary string, so it compiles and the query key becomes that parameter's value instead of $filter. (jvm-okhttp already escaped its query keys.)
  • In jvm-retrofit2 every wire name sits in an annotation argument (@GET, @Path, @Query), which must be a compile-time constant, so a template there is a compile error even for $filter.

jvm-ktor on master:

"/items/{$id}".replace("{" + "$id" + "}", "$dollarId"),
dollarFilter?.apply { localVariableQuery["$filter"] = listOf("$dollarFilter") }
dollarTop?.apply { localVariableQuery["$top"] = listOf("$dollarTop") }
opts?.dollarA?.let { localVariableQuery["$a"] = listOf("$it") }
"/odata/$count",

After this PR:

"/items/{\$id}".replace("{" + "\$id" + "}", "$dollarId"),
dollarFilter?.apply { localVariableQuery["\$filter"] = listOf("$dollarFilter") }
dollarTop?.apply { localVariableQuery["\$top"] = listOf("$dollarTop") }
opts?.dollarA?.let { localVariableQuery["\$a"] = listOf("$it") }
"/odata/\$count",

Fix

The wire names are wrapped in {{#lambda.escapeDollar}}…{{/lambda.escapeDollar}}. The lambda already exists in AbstractKotlinCodegen; it turns $ into \$ and leaves an already escaped \$ alone. Some templates already used it, for example the query keys in jvm-okhttp. Only string literals are wrapped:

  • the operation path / URL template in the eight libraries below (in jvm-vertx only the {{{path}}} part, so the $basePath interpolation is kept);
  • path placeholders, query keys (including exploded, deepObject and map query parameters), header keys and multipart part names;
  • the API-key name (keyParamName) in the ApiClient templates of jvm-okhttp, jvm-ktor, jvm-retrofit2 and multiplatform, and in jvm-vertx/api.mustache.

jvm-microprofile already escaped these names on master and is not touched.

Deliberately left out, to keep this PR from editing the same lines as the open sibling PRs #25623 and #25624:

  • the urlencoded form keys of jvm-ktor, multiplatform and jvm-vertx, and the header keys of jvm-vertx;
  • the multipart file part of multiplatform, where #25624 replaces the wire name with the parameter.

I will follow up on these once those PRs are merged.

Also not changed: in jvm-retrofit2, the Kotlin parameter generated for each property of an exploded object query parameter still uses the raw schema name (@Query("\$a") $a: …). Changing it would rename public arguments for everyone who calls them by name.

Caveat for jvm-retrofit2 path placeholders: the generated code now compiles, but Retrofit itself only accepts @Path names matching [a-zA-Z][a-zA-Z0-9_-]*, so a placeholder such as {$id} is still rejected by Retrofit at runtime. Query keys like @Query("\$filter") are not affected by that check.

Behavior change

For specs with a $ in a wire name, the escaped sites now send the literal name. Where the old code compiled only because $name happened to match a variable ($filter next to a filter parameter), the key sent changes from that variable's value to the literal $filter.

Tests

Test (new)LibrariesAsserts
testDollarWireNamesAreEscapedjvm-ktor, jvm-okhttp4, jvm-retrofit2, multiplatform, jvm-vertx, jvm-volley, jvm-spring-webclient, jvm-spring-restclientevery library: odata/\$count and \$id are emitted, and no unescaped "$top" or odata/$count" literal. Per library, the escaped keys it emits, e.g. localVariableQuery["\$dobj[\$f]"] and localVariableHeaders["\$x-head"] (jvm-ktor), @Query("\$a"), @Header("\$x-head"), @Path("\$id") and @Part("\$fprop") (jvm-retrofit2), apiKey["\$api_key"] (jvm-vertx). For the four libraries that put the API key in ApiClient.kt, that file contains \$api_key.

./mvnw -nsu -Denforcer.skip=true -pl modules/openapi-generator -am -Dtest=KotlinClientCodegenApiTest -Dsurefire.failIfNoSpecifiedTests=false test

Tests runResult
master + this PR400 failures, 0 skipped
this PR with the template changes reverted (test kept; only the new test run)88 failures — every row of testDollarWireNamesAreEscaped

The test checks generated text; it does not compile the generated Kotlin.

Samples

No sample changes. Of the 26 specs used by the kotlin-client configs, only 3_0/parameter-test-spec.yaml has a $ in a wire name (the query parameter $query-$dollar-sign). Its config, kotlin-jvm-okhttp-parameter-tests, uses jvm-okhttp4, which already escaped that key on master (put("\$query-\$dollar-sign", …)). The Samples up-to-date CI job checks the rest.

This PR and its sibling PRs #25622, #25623 and #25624 all touch kotlin-client templates. git merge-tree shows no template conflicts between this branch and any of them, and the new test is added at the end of KotlinClientCodegenApiTest to stay clear of where the siblings add theirs.

cc @wing328 and the Kotlin technical committee: @karismann @Zomzog @andrewemery @4brunu @yutaka0m @stefankoppier @e5l

PR checklist

  • Read the contribution guidelines.
  • Run the following to build the project and update samples — scoped to this change: built the CLI (./mvnw -pl modules/openapi-generator-cli -am package -DskipTests) and regenerated the affected kotlin client configs with ./bin/generate-samples.sh; there is no sample diff. export_docs_generators.sh not run: no generator option or metadata changes.
    ./mvnw clean package || exit
    ./bin/generate-samples.sh ./bin/configs/*.yaml || exit
    ./bin/utils/export_docs_generators.sh || exit
    
    (For Windows users, please run the script in WSL)
    Commit all changed files.
    This is important, as CI jobs will verify all generator outputs of your HEAD commit as it would merge with master.
    These must match the expectations made by your contribution.
    You may regenerate an individual generator by passing the relevant config(s) as an argument to the script, for example ./bin/generate-samples.sh bin/configs/java*.
    IMPORTANT: Do NOT purge/delete any folders/files (e.g. tests) when regenerating the samples as manually written tests may be removed.
  • If your PR is targeting a particular programming language, @mention the technical committee members, so they are more likely to review the pull request.

🤖 Generated with Claude Code


Summary by cubic

Fixes the kotlin-client generators emitting wire names containing $ unescaped, so OData-style names like $filter, $top, /odata/$count, and {$id} either broke Kotlin string interpolation or silently used a same-named parameter's value. Wire-name string literals (operation paths, path placeholders, query/header/multipart keys, API-key names) now pass through the existing escapeDollar lambda, which turns $ into \$, in eight generated libraries.

Behavior change

  • Where an unescaped $name previously matched a parameter, the generated key now goes out as the literal wire name (e.g. $filter) instead of that parameter's value.
  • jvm-retrofit2 annotations now compile, but Retrofit's runtime validation still rejects {$id}-style path placeholders; query and header keys are unaffected.
  • Left unescaped for sibling PRs: urlencoded form keys in jvm-ktor, multiplatform, and jvm-vertx, and the multipart file part in multiplatform.

Tests

  • New testDollarWireNamesAreEscaped covers all eight libraries via the shared clientLibraries provider, using the new kotlin-dollar-wire-name.yaml fixture.