Skip to content

[ruby] fix: explode object query parameters - #24869

Merged
wing328 merged 3 commits into
OpenAPITools:masterfrom
wiebren:fix/exploded-object-query-parameters-ruby
Sep 23, 2026
Merged

wing328 merged 3 commits into
OpenAPITools:masterfrom
wiebren:fix/exploded-object-query-parameters-ruby

Conversation

@wiebren

@wiebren wiebren commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Bug

A query parameter whose schema is an object, with style/explode left at their defaults (form, true), must go on the wire as one parameter per entry, keyed by the property name alone. The ruby client assigned the whole hash under the parameter name, and every http library then bracketed it.

parameters:
  - in: query
    name: filter
    schema:
      type: object

called with {"category": "books", "createdDate:gte": "2023-01-01"}:

on the wire
expected category=books&createdDate%3Agte=2023-01-01
ruby before filter%5Bcategory%5D=books&filter%5BcreatedDate%3Agte%5D=2023-01-01

Series

One of six per-language PRs for the same bug: #24797 go, #24802 python, #24803 typescript-fetch, #24867 kotlin, #24868 dart, #24869 ruby. No shared main/ code; each adds the same fixture, 3_0/exploded-object-query-param.yaml.

Fix

ruby-client/api.mustache merges an exploded map into query_params entry by entry at the call site, leaving nil entries out, the way nil optional parameters already are:

opts[:'filter'].compact.each { |name, value| query_params[name.to_s] = value } if !opts[:'filter'].nil?

That fixes typhoeus, faraday and httpx at once, since they all consume the query_params hash. deepObject and explode: false parameters, and every other parameter shape, produce the line they did before.

Name collisions. An exploded entry uses a string key where declared parameters use symbols, so on a name clash typhoeus and httpx send both, while faraday (whose params hash is string-keyed) keeps the one assigned last.

Verified

A client generated from the fixture, against a server that echoes its raw query string (typhoeus and faraday give the same result):

parameter on the wire
filter (object, defaults) category=books&createdDate%3Agte=2023-01-01
typedFilter (map, defaults) category=books&createdDate%3Agte=2023-01-01
filter with {"k": nil, "category": "books"} category=books (without .compact: k= with typhoeus, a bare k with faraday)
deepFilter (style: deepObject) deepFilter%5Bcategory%5D=books&… (as on master, and already correct)
flatFilter (explode: false) flatFilter%5Bcategory%5D=books&… (as on master, see Known gaps)

RubyClientCodegenTest#testExplodedObjectQueryParameter fails without the fix.

Known gaps

  • explode: false keeps its bracket encoding, as on master; the spec wants flatFilter=category,books,….
  • Declared object models ($ref, isModel rather than isMap) still go on the wire whole.
  • A list entry goes out the way ruby sends every array, declared multi arrays included: the encoding is up to the http library and config.params_encoding (bracketed by default, tld%5B0%5D=com), and so is a nil item inside it.

PR checklist

  • Read the contribution guidelines.
  • Built the project and updated samples (./bin/generate-samples.sh bin/configs/ruby*.yaml): 4 files, fake_api.rb in ruby, ruby-autoload, ruby-faraday and ruby-httpx, from the petstore fixture's language parameter (a declared map with the default style).
  • Technical committee: @meganemura @dkliban @HoneyryderChuck

Generated with Claude Code

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 ruby client assigned
the whole hash under the parameter name, leaving the http library to serialize
it in rails bracket style (filter[tld]=com&filter[createdDate%3Agte]=...).

The generated api now merges an exploded map into query_params entry by entry,
which fixes all three http libraries (typhoeus, faraday, httpx) at once, since
they all consume the query_params hash the api builds. 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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CxDNCjqJycKTfzVWg2SeTJ

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

2 issues found across 7 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="samples/client/petstore/ruby-faraday/lib/petstore/api/fake_api.rb">

<violation number="1" location="samples/client/petstore/ruby-faraday/lib/petstore/api/fake_api.rb:1575">
P3: The exploded entries use string keys (`query_params[name.to_s]`), while every other declared parameter in this method is set with a symbol key (`query_params[:'pipe']`, `query_params[:'context']`, etc.). Because Ruby treats `:"pipe"` and `"pipe"` as distinct hash keys, a `language` map entry whose name collides with a declared parameter is not overwritten as the PR description claims - it is serialized in addition to the declared parameter, producing two same-named query parameters on the wire (e.g. the array `context` plus `context=<string>`). Use the same key namespace or document the collision behavior; mixing symbol-keyed declared params with string-keyed exploded parts makes the "no overwriting" guarantee produce ambiguous duplicate params instead.</violation>
</file>

<file name="modules/openapi-generator/src/main/resources/ruby-client/api.mustache">

<violation number="1" location="modules/openapi-generator/src/main/resources/ruby-client/api.mustache:187">
P3: The PR description claims exploded-map collisions with other declared parameters are "handled without overwriting", but `query_params[name.to_s] = value` is a plain hash assignment that overwrites any existing entry. If a map key equals another declared query parameter's name, the value depends purely on parameter ordering in the generated method and the earlier entry is silently dropped. Either drop the collision claim or guard the assignment (e.g. `query_params[name.to_s] = value unless query_params.key?(name.to_s)`), keeping the explicit map entries from clobbering declared parameters.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

query_params[:'allowEmpty'] = allow_empty
query_params[:'language'] = opts[:'language'] if !opts[:'language'].nil?
# form style explodes an object into one query parameter per entry, keyed by the property name alone
opts[:'language'].each { |name, value| query_params[name.to_s] = value } if !opts[:'language'].nil?

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: The exploded entries use string keys (query_params[name.to_s]), while every other declared parameter in this method is set with a symbol key (query_params[:'pipe'], query_params[:'context'], etc.). Because Ruby treats :"pipe" and "pipe" as distinct hash keys, a language map entry whose name collides with a declared parameter is not overwritten as the PR description claims - it is serialized in addition to the declared parameter, producing two same-named query parameters on the wire (e.g. the array context plus context=<string>). Use the same key namespace or document the collision behavior; mixing symbol-keyed declared params with string-keyed exploded parts makes the "no overwriting" guarantee produce ambiguous duplicate params instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/client/petstore/ruby-faraday/lib/petstore/api/fake_api.rb, line 1575:

<comment>The exploded entries use string keys (`query_params[name.to_s]`), while every other declared parameter in this method is set with a symbol key (`query_params[:'pipe']`, `query_params[:'context']`, etc.). Because Ruby treats `:"pipe"` and `"pipe"` as distinct hash keys, a `language` map entry whose name collides with a declared parameter is not overwritten as the PR description claims - it is serialized in addition to the declared parameter, producing two same-named query parameters on the wire (e.g. the array `context` plus `context=<string>`). Use the same key namespace or document the collision behavior; mixing symbol-keyed declared params with string-keyed exploded parts makes the "no overwriting" guarantee produce ambiguous duplicate params instead.</comment>

<file context>
@@ -1571,7 +1571,8 @@ def test_query_parameter_collection_format_with_http_info(pipe, ioutil, http, ur
       query_params[:'allowEmpty'] = allow_empty
-      query_params[:'language'] = opts[:'language'] if !opts[:'language'].nil?
+      # form style explodes an object into one query parameter per entry, keyed by the property name alone
+      opts[:'language'].each { |name, value| query_params[name.to_s] = value } if !opts[:'language'].nil?
 
       # header parameters
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You are right that the string and symbol keys do not collide in the hash, but whether both then reach the wire depends on the http library. I checked it against the regenerated samples by calling test_query_parameter_collection_format with language: {"context" => "exploded"} next to the declared context:

  • ruby (typhoeus): ...&context%5B0%5D=declared&...&context=exploded, both sent
  • ruby-httpx: ...&context%5B%5D=declared&...&context=exploded, both sent
  • ruby-faraday: only context=exploded. request.params = query_params copies into the string-keyed Faraday::Utils::ParamsHash, so the two keys collapse and whichever was assigned later wins.

Sending both matches the go port (#24797), which Adds exploded entries to url.Values. Making faraday do the same would mean changing how the faraday client encodes params, which is out of scope here, and the spec does not define what such a collision should mean. So I have left the code alone and rewritten the "Name collisions" paragraph in the PR description to describe the behaviour per library.

{{#isExplode}}
{{^isDeepObject}}
# form style explodes an object into one query parameter per entry, keyed by the property name alone
{{{paramName}}}.each { |name, value| query_params[name.to_s] = value }

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: The PR description claims exploded-map collisions with other declared parameters are "handled without overwriting", but query_params[name.to_s] = value is a plain hash assignment that overwrites any existing entry. If a map key equals another declared query parameter's name, the value depends purely on parameter ordering in the generated method and the earlier entry is silently dropped. Either drop the collision claim or guard the assignment (e.g. query_params[name.to_s] = value unless query_params.key?(name.to_s)), keeping the explicit map entries from clobbering declared parameters.

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/ruby-client/api.mustache, line 187:

<comment>The PR description claims exploded-map collisions with other declared parameters are "handled without overwriting", but `query_params[name.to_s] = value` is a plain hash assignment that overwrites any existing entry. If a map key equals another declared query parameter's name, the value depends purely on parameter ordering in the generated method and the earlier entry is silently dropped. Either drop the collision claim or guard the assignment (e.g. `query_params[name.to_s] = value unless query_params.key?(name.to_s)`), keeping the explicit map entries from clobbering declared parameters.</comment>

<file context>
@@ -180,7 +180,23 @@ module {{moduleName}}
+      {{#isExplode}}
+      {{^isDeepObject}}
+      # form style explodes an object into one query parameter per entry, keyed by the property name alone
+      {{{paramName}}}.each { |name, value| query_params[name.to_s] = value }
+      {{/isDeepObject}}
+      {{#isDeepObject}}
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, the old description was wrong. query_params[name.to_s] = value is a plain assignment. It overwrites an earlier string key: another exploded map with the same entry name, or a string key passed in through opts[:query_params]. In every library the later assignment wins. Declared parameters use symbol keys, so the hash keeps them separately, and the wire result then depends on the library. typhoeus and httpx send both, the same as the go port. faraday collapses them into its string-keyed ParamsHash and the later assignment wins. I checked this against the regenerated ruby, ruby-faraday and ruby-httpx samples.

I did not add the unless query_params.key? guard. It only covers a declared parameter assigned before the map, so it would not make the result the same across parameter orders or libraries, and it would silently drop a value the caller passed on purpose. The PR description now documents the actual behaviour instead of claiming there is no overwrite.

wiebren and others added 2 commits September 23, 2026 08:59
A nil entry went out as k= with typhoeus and as a bare k with faraday
and httpx. Optional parameters are already skipped when nil, so skip
nil entries too, with Hash#compact. Also shorten the shipped comment,
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

2 issues found across 7 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="modules/openapi-generator/src/main/resources/ruby-client/api.mustache">

<violation number="1" location="modules/openapi-generator/src/main/resources/ruby-client/api.mustache:187">
P2: A required exploded map passed as `nil` with `client_side_validation` disabled now raises `NoMethodError` before the request is sent. Add the same nil guard used by the optional branch.</violation>

<violation number="2" location="modules/openapi-generator/src/main/resources/ruby-client/api.mustache:187">
P2: `Hash#compact` requires Ruby 2.4, but `gemRequiredRubyVersion` supports values as low as `>= 1.9`; clients targeting Ruby 1.9–2.3 raise `NoMethodError` for exploded maps. Replace `compact` with an `each` that skips nil values in both new branches, or raise the advertised minimum to 2.4.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

{{#isExplode}}
{{^isDeepObject}}
# form style, explode: one query parameter per entry, keyed by the property name; nil is left out
{{{paramName}}}.compact.each { |name, value| query_params[name.to_s] = value }

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: A required exploded map passed as nil with client_side_validation disabled now raises NoMethodError before the request is sent. Add the same nil guard used by the optional branch.

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/ruby-client/api.mustache, line 187:

<comment>A required exploded map passed as `nil` with `client_side_validation` disabled now raises `NoMethodError` before the request is sent. Add the same nil guard used by the optional branch.</comment>

<file context>
@@ -180,7 +180,23 @@ module {{moduleName}}
+      {{#isExplode}}
+      {{^isDeepObject}}
+      # form style, explode: one query parameter per entry, keyed by the property name; nil is left out
+      {{{paramName}}}.compact.each { |name, value| query_params[name.to_s] = value }
+      {{/isDeepObject}}
+      {{#isDeepObject}}
</file context>
Suggested change
{{{paramName}}}.compact.each { |name, value| query_params[name.to_s] = value }
{{{paramName}}}.compact.each { |name, value| query_params[name.to_s] = value } if !{{{paramName}}}.nil?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

True, but it only arises for a required parameter passed as nil with client_side_validation turned off, which is a caller error the validation exists to catch. With validation on (the default) the call fails earlier with the usual "Missing the required parameter" message. I have left it rather than add a nil guard to the required branch, since a guard there would silently send the request without a required parameter.

{{#isExplode}}
{{^isDeepObject}}
# form style, explode: one query parameter per entry, keyed by the property name; nil is left out
{{{paramName}}}.compact.each { |name, value| query_params[name.to_s] = value }

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: Hash#compact requires Ruby 2.4, but gemRequiredRubyVersion supports values as low as >= 1.9; clients targeting Ruby 1.9–2.3 raise NoMethodError for exploded maps. Replace compact with an each that skips nil values in both new branches, or raise the advertised minimum to 2.4.

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/ruby-client/api.mustache, line 187:

<comment>`Hash#compact` requires Ruby 2.4, but `gemRequiredRubyVersion` supports values as low as `>= 1.9`; clients targeting Ruby 1.9–2.3 raise `NoMethodError` for exploded maps. Replace `compact` with an `each` that skips nil values in both new branches, or raise the advertised minimum to 2.4.</comment>

<file context>
@@ -180,7 +180,23 @@ module {{moduleName}}
+      {{#isExplode}}
+      {{^isDeepObject}}
+      # form style, explode: one query parameter per entry, keyed by the property name; nil is left out
+      {{{paramName}}}.compact.each { |name, value| query_params[name.to_s] = value }
+      {{/isDeepObject}}
+      {{#isDeepObject}}
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The generated client already needs a newer ruby than that: master's base_object.mustache calls attributes.transform_keys(&:to_sym) in every model's build_from_hash, and Hash#transform_keys is Ruby 2.5. So Hash#compact (2.4) does not raise the real floor; the >= 1.9 default of gemRequiredRubyVersion is already out of date on master, which is worth its own fix rather than avoiding 2.4 methods here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

agreed. will file a pr to update ruby to newer version instead.

@wing328
wing328 merged commit 05b61f3 into OpenAPITools:master Sep 23, 2026
15 checks passed
@wing328 wing328 added this to the 7.26.0 milestone Sep 23, 2026
@wing328

wing328 commented Sep 23, 2026

Copy link
Copy Markdown
Member

@wiebren thanks for the PR to improve the Ruby client generator.

Worth mentioning there's another generator ruby-nextgen to generate Ruby client and this new generator will eventually replace the current one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants