fix(kotlin-client): escape `$` in wire-name string literals
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,$topandfilter, 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$dobjwith 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,$idand$counthave no variable of that name in scope. They are unresolved references, so the file does not compile.$filterresolves to thefilterparameter that the fixture also declares. Injvm-ktor,jvm-vertx,multiplatform,jvm-volleyand the twojvm-spring-*libraries that literal is an ordinary string, so it compiles and the query key becomes that parameter's value instead of$filter. (jvm-okhttpalready escaped its query keys.)- In
jvm-retrofit2every 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-vertxonly the{{{path}}}part, so the$basePathinterpolation 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 theApiClienttemplates ofjvm-okhttp,jvm-ktor,jvm-retrofit2andmultiplatform, and injvm-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,multiplatformandjvm-vertx, and the header keys ofjvm-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) | Libraries | Asserts |
|---|---|---|
testDollarWireNamesAreEscaped | jvm-ktor, jvm-okhttp4, jvm-retrofit2, multiplatform, jvm-vertx, jvm-volley, jvm-spring-webclient, jvm-spring-restclient | every 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 run | Result | |
|---|---|---|
| master + this PR | 40 | 0 failures, 0 skipped |
| this PR with the template changes reverted (test kept; only the new test run) | 8 | 8 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.shnot run: no generator option or metadata changes.
(For Windows users, please run the script in WSL)./mvnw clean package || exit ./bin/generate-samples.sh ./bin/configs/*.yaml || exit ./bin/utils/export_docs_generators.sh || exit
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
$namepreviously matched a parameter, the generated key now goes out as the literal wire name (e.g.$filter) instead of that parameter's value. jvm-retrofit2annotations 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, andjvm-vertx, and the multipart file part inmultiplatform.
Tests
- New
testDollarWireNamesAreEscapedcovers all eight libraries via the sharedclientLibrariesprovider, using the newkotlin-dollar-wire-name.yamlfixture.