From cd9d7a553733e1ba821e06b17cc81ee5376273a0 Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Fri, 4 Sep 2026 20:26:35 +0200 Subject: [PATCH 1/3] fix: [ruby] explode object query parameters 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 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 Claude-Session: https://claude.ai/code/session_01CxDNCjqJycKTfzVWg2SeTJ --- .../main/resources/ruby-client/api.mustache | 32 ++++++++++++ .../codegen/ruby/RubyClientCodegenTest.java | 36 +++++++++++++ .../3_0/exploded-object-query-param.yaml | 51 +++++++++++++++++++ .../lib/petstore/api/fake_api.rb | 3 +- .../ruby-faraday/lib/petstore/api/fake_api.rb | 3 +- .../ruby-httpx/lib/petstore/api/fake_api.rb | 3 +- .../ruby/lib/petstore/api/fake_api.rb | 3 +- 7 files changed, 127 insertions(+), 4 deletions(-) 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/ruby-client/api.mustache b/modules/openapi-generator/src/main/resources/ruby-client/api.mustache index 97f38c704126..76b19e26df94 100644 --- a/modules/openapi-generator/src/main/resources/ruby-client/api.mustache +++ b/modules/openapi-generator/src/main/resources/ruby-client/api.mustache @@ -180,7 +180,23 @@ module {{moduleName}} query_params[:'{{{baseName}}}'] = {{{paramName}}}.to_json {{/queryIsJsonMimeType}} {{^queryIsJsonMimeType}} + {{#isMap}} + {{#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}} + query_params[:'{{{baseName}}}'] = {{{paramName}}} + {{/isDeepObject}} + {{/isExplode}} + {{^isExplode}} + query_params[:'{{{baseName}}}'] = {{{paramName}}} + {{/isExplode}} + {{/isMap}} + {{^isMap}} query_params[:'{{{baseName}}}'] = {{#collectionFormat}}@api_client.build_collection_param({{{paramName}}}, :{{{collectionFormat}}}){{/collectionFormat}}{{^collectionFormat}}{{{paramName}}}{{/collectionFormat}} + {{/isMap}} {{/queryIsJsonMimeType}} {{/required}} {{/queryParams}} @@ -190,7 +206,23 @@ module {{moduleName}} query_params[:'{{{baseName}}}'] = opts[:'{{{paramName}}}'].to_json if !opts[:'{{{paramName}}}'].nil? {{/queryIsJsonMimeType}} {{^queryIsJsonMimeType}} + {{#isMap}} + {{#isExplode}} + {{^isDeepObject}} + # form style explodes an object into one query parameter per entry, keyed by the property name alone + opts[:'{{{paramName}}}'].each { |name, value| query_params[name.to_s] = value } if !opts[:'{{{paramName}}}'].nil? + {{/isDeepObject}} + {{#isDeepObject}} + query_params[:'{{{baseName}}}'] = opts[:'{{{paramName}}}'] if !opts[:'{{{paramName}}}'].nil? + {{/isDeepObject}} + {{/isExplode}} + {{^isExplode}} + query_params[:'{{{baseName}}}'] = opts[:'{{{paramName}}}'] if !opts[:'{{{paramName}}}'].nil? + {{/isExplode}} + {{/isMap}} + {{^isMap}} query_params[:'{{{baseName}}}'] = {{#collectionFormat}}@api_client.build_collection_param(opts[:'{{{paramName}}}'], :{{{collectionFormat}}}){{/collectionFormat}}{{^collectionFormat}}opts[:'{{{paramName}}}']{{/collectionFormat}} if !opts[:'{{{paramName}}}'].nil? + {{/isMap}} {{/queryIsJsonMimeType}} {{/required}} {{/queryParams}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/ruby/RubyClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/ruby/RubyClientCodegenTest.java index ed256377bc80..a98d034b968a 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/ruby/RubyClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/ruby/RubyClientCodegenTest.java @@ -71,6 +71,42 @@ public void testGenerateRubyClientWithHtmlEntity() throws Exception { } } + @Test(description = "Verify an object query parameter is exploded, whether or not it declares its properties") + public void testExplodedObjectQueryParameter() throws Exception { + final File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final OpenAPI openAPI = TestUtils.parseFlattenSpec("src/test/resources/3_0/exploded-object-query-param.yaml"); + RubyClientCodegen codegen = new RubyClientCodegen(); + codegen.setOutputDir(output.getAbsolutePath()); + + ClientOptInput clientOptInput = new ClientOptInput().openAPI(openAPI).config(codegen); + DefaultGenerator generator = new DefaultGenerator(); + List files = generator.opts(clientOptInput).generate(); + files.forEach(File::deleteOnExit); + + File apiFile = files.stream() + .filter(f -> f.getName().equals("default_api.rb")) + .findFirst() + .orElseThrow(() -> new AssertionError("default_api.rb not found in generated files")); + + // form style with explode - the default - puts every entry on the wire under its own + // property name. Assigning the whole hash under the parameter name left the http + // library to serialize it in bracket style, which is what used to happen. + TestUtils.assertFileContains(apiFile.toPath(), + "opts[:'filter'].each { |name, value| query_params[name.to_s] = value } if !opts[:'filter'].nil?"); + TestUtils.assertFileNotContains(apiFile.toPath(), "query_params[:'filter']"); + + // a declared map behaves the same way + TestUtils.assertFileContains(apiFile.toPath(), + "opts[:'typed_filter'].each { |name, value| query_params[name.to_s] = value } if !opts[:'typed_filter'].nil?"); + + // deepObject and form without explode both keep a single parameter + TestUtils.assertFileContains(apiFile.toPath(), + "query_params[:'deepFilter'] = opts[:'deep_filter'] if !opts[:'deep_filter'].nil?", + "query_params[:'flatFilter'] = opts[:'flat_filter'] if !opts[:'flat_filter'].nil?"); + } + @Test public void testInitialConfigValues() { final RubyClientCodegen codegen = new RubyClientCodegen(); 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 diff --git a/samples/client/petstore/ruby-autoload/lib/petstore/api/fake_api.rb b/samples/client/petstore/ruby-autoload/lib/petstore/api/fake_api.rb index a46c90375e88..90ae492d58e5 100644 --- a/samples/client/petstore/ruby-autoload/lib/petstore/api/fake_api.rb +++ b/samples/client/petstore/ruby-autoload/lib/petstore/api/fake_api.rb @@ -1556,7 +1556,8 @@ def test_query_parameter_collection_format_with_http_info(pipe, ioutil, http, ur query_params[:'url'] = @api_client.build_collection_param(url, :csv) query_params[:'context'] = @api_client.build_collection_param(context, :multi) 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 header_params = opts[:header_params] || {} diff --git a/samples/client/petstore/ruby-faraday/lib/petstore/api/fake_api.rb b/samples/client/petstore/ruby-faraday/lib/petstore/api/fake_api.rb index 7efdb0244dea..c4fd19700a59 100644 --- a/samples/client/petstore/ruby-faraday/lib/petstore/api/fake_api.rb +++ b/samples/client/petstore/ruby-faraday/lib/petstore/api/fake_api.rb @@ -1571,7 +1571,8 @@ def test_query_parameter_collection_format_with_http_info(pipe, ioutil, http, ur query_params[:'url'] = @api_client.build_collection_param(url, :csv) query_params[:'context'] = @api_client.build_collection_param(context, :multi) 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 header_params = opts[:header_params] || {} diff --git a/samples/client/petstore/ruby-httpx/lib/petstore/api/fake_api.rb b/samples/client/petstore/ruby-httpx/lib/petstore/api/fake_api.rb index 7efdb0244dea..c4fd19700a59 100644 --- a/samples/client/petstore/ruby-httpx/lib/petstore/api/fake_api.rb +++ b/samples/client/petstore/ruby-httpx/lib/petstore/api/fake_api.rb @@ -1571,7 +1571,8 @@ def test_query_parameter_collection_format_with_http_info(pipe, ioutil, http, ur query_params[:'url'] = @api_client.build_collection_param(url, :csv) query_params[:'context'] = @api_client.build_collection_param(context, :multi) 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 header_params = opts[:header_params] || {} diff --git a/samples/client/petstore/ruby/lib/petstore/api/fake_api.rb b/samples/client/petstore/ruby/lib/petstore/api/fake_api.rb index 09633923caf0..28dec0f0e2fa 100644 --- a/samples/client/petstore/ruby/lib/petstore/api/fake_api.rb +++ b/samples/client/petstore/ruby/lib/petstore/api/fake_api.rb @@ -1571,7 +1571,8 @@ def test_query_parameter_collection_format_with_http_info(pipe, ioutil, http, ur query_params[:'url'] = @api_client.build_collection_param(url, :csv) query_params[:'context'] = @api_client.build_collection_param(context, :multi) 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 header_params = opts[:header_params] || {} From c3c50a8ea4be4562a4a46929325d7ef1f37e4fee Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Wed, 23 Sep 2026 08:59:26 +0200 Subject: [PATCH 2/3] fix: [ruby] leave nil entries of an exploded object out of the query 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) --- .../src/main/resources/ruby-client/api.mustache | 8 ++++---- .../openapitools/codegen/ruby/RubyClientCodegenTest.java | 9 +++++---- .../test/resources/3_0/exploded-object-query-param.yaml | 6 +++--- .../petstore/ruby-autoload/lib/petstore/api/fake_api.rb | 4 ++-- .../petstore/ruby-faraday/lib/petstore/api/fake_api.rb | 4 ++-- .../petstore/ruby-httpx/lib/petstore/api/fake_api.rb | 4 ++-- .../client/petstore/ruby/lib/petstore/api/fake_api.rb | 4 ++-- 7 files changed, 20 insertions(+), 19 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/ruby-client/api.mustache b/modules/openapi-generator/src/main/resources/ruby-client/api.mustache index 76b19e26df94..60bc7672aaac 100644 --- a/modules/openapi-generator/src/main/resources/ruby-client/api.mustache +++ b/modules/openapi-generator/src/main/resources/ruby-client/api.mustache @@ -183,8 +183,8 @@ module {{moduleName}} {{#isMap}} {{#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 } + # 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}} query_params[:'{{{baseName}}}'] = {{{paramName}}} @@ -209,8 +209,8 @@ module {{moduleName}} {{#isMap}} {{#isExplode}} {{^isDeepObject}} - # form style explodes an object into one query parameter per entry, keyed by the property name alone - opts[:'{{{paramName}}}'].each { |name, value| query_params[name.to_s] = value } if !opts[:'{{{paramName}}}'].nil? + # form style, explode: one query parameter per entry, keyed by the property name; nil is left out + opts[:'{{{paramName}}}'].compact.each { |name, value| query_params[name.to_s] = value } if !opts[:'{{{paramName}}}'].nil? {{/isDeepObject}} {{#isDeepObject}} query_params[:'{{{baseName}}}'] = opts[:'{{{paramName}}}'] if !opts[:'{{{paramName}}}'].nil? diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/ruby/RubyClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/ruby/RubyClientCodegenTest.java index a98d034b968a..f67bb6720788 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/ruby/RubyClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/ruby/RubyClientCodegenTest.java @@ -71,7 +71,7 @@ public void testGenerateRubyClientWithHtmlEntity() throws Exception { } } - @Test(description = "Verify an object query parameter is exploded, whether or not it declares its properties") + @Test(description = "Verify a form style, exploded map query parameter goes on the wire one entry per parameter") public void testExplodedObjectQueryParameter() throws Exception { final File output = Files.createTempDirectory("test").toFile(); output.deleteOnExit(); @@ -92,14 +92,15 @@ public void testExplodedObjectQueryParameter() throws Exception { // form style with explode - the default - puts every entry on the wire under its own // property name. Assigning the whole hash under the parameter name left the http - // library to serialize it in bracket style, which is what used to happen. + // library to serialize it in bracket style, which is what used to happen. A nil entry is + // left out. TestUtils.assertFileContains(apiFile.toPath(), - "opts[:'filter'].each { |name, value| query_params[name.to_s] = value } if !opts[:'filter'].nil?"); + "opts[:'filter'].compact.each { |name, value| query_params[name.to_s] = value } if !opts[:'filter'].nil?"); TestUtils.assertFileNotContains(apiFile.toPath(), "query_params[:'filter']"); // a declared map behaves the same way TestUtils.assertFileContains(apiFile.toPath(), - "opts[:'typed_filter'].each { |name, value| query_params[name.to_s] = value } if !opts[:'typed_filter'].nil?"); + "opts[:'typed_filter'].compact.each { |name, value| query_params[name.to_s] = value } if !opts[:'typed_filter'].nil?"); // deepObject and form without explode both keep a single parameter TestUtils.assertFileContains(apiFile.toPath(), 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 index fd6fce7d5807..a24c4c916d5e 100644 --- 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 @@ -2,9 +2,9 @@ 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. + Object typed query parameters under form/explode (as a free-form object and as a typed map), + deepObject, and form without explode. The free-form variant matters because it is flagged + isMap but not isContainer. version: 1.0.0 servers: - url: localhost:8080 diff --git a/samples/client/petstore/ruby-autoload/lib/petstore/api/fake_api.rb b/samples/client/petstore/ruby-autoload/lib/petstore/api/fake_api.rb index 90ae492d58e5..47e0a9fb6062 100644 --- a/samples/client/petstore/ruby-autoload/lib/petstore/api/fake_api.rb +++ b/samples/client/petstore/ruby-autoload/lib/petstore/api/fake_api.rb @@ -1556,8 +1556,8 @@ def test_query_parameter_collection_format_with_http_info(pipe, ioutil, http, ur query_params[:'url'] = @api_client.build_collection_param(url, :csv) query_params[:'context'] = @api_client.build_collection_param(context, :multi) query_params[:'allowEmpty'] = allow_empty - # 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? + # form style, explode: one query parameter per entry, keyed by the property name; nil is left out + opts[:'language'].compact.each { |name, value| query_params[name.to_s] = value } if !opts[:'language'].nil? # header parameters header_params = opts[:header_params] || {} diff --git a/samples/client/petstore/ruby-faraday/lib/petstore/api/fake_api.rb b/samples/client/petstore/ruby-faraday/lib/petstore/api/fake_api.rb index c4fd19700a59..ae6a4046fbcf 100644 --- a/samples/client/petstore/ruby-faraday/lib/petstore/api/fake_api.rb +++ b/samples/client/petstore/ruby-faraday/lib/petstore/api/fake_api.rb @@ -1571,8 +1571,8 @@ def test_query_parameter_collection_format_with_http_info(pipe, ioutil, http, ur query_params[:'url'] = @api_client.build_collection_param(url, :csv) query_params[:'context'] = @api_client.build_collection_param(context, :multi) query_params[:'allowEmpty'] = allow_empty - # 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? + # form style, explode: one query parameter per entry, keyed by the property name; nil is left out + opts[:'language'].compact.each { |name, value| query_params[name.to_s] = value } if !opts[:'language'].nil? # header parameters header_params = opts[:header_params] || {} diff --git a/samples/client/petstore/ruby-httpx/lib/petstore/api/fake_api.rb b/samples/client/petstore/ruby-httpx/lib/petstore/api/fake_api.rb index c4fd19700a59..ae6a4046fbcf 100644 --- a/samples/client/petstore/ruby-httpx/lib/petstore/api/fake_api.rb +++ b/samples/client/petstore/ruby-httpx/lib/petstore/api/fake_api.rb @@ -1571,8 +1571,8 @@ def test_query_parameter_collection_format_with_http_info(pipe, ioutil, http, ur query_params[:'url'] = @api_client.build_collection_param(url, :csv) query_params[:'context'] = @api_client.build_collection_param(context, :multi) query_params[:'allowEmpty'] = allow_empty - # 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? + # form style, explode: one query parameter per entry, keyed by the property name; nil is left out + opts[:'language'].compact.each { |name, value| query_params[name.to_s] = value } if !opts[:'language'].nil? # header parameters header_params = opts[:header_params] || {} diff --git a/samples/client/petstore/ruby/lib/petstore/api/fake_api.rb b/samples/client/petstore/ruby/lib/petstore/api/fake_api.rb index 28dec0f0e2fa..9c1318f50723 100644 --- a/samples/client/petstore/ruby/lib/petstore/api/fake_api.rb +++ b/samples/client/petstore/ruby/lib/petstore/api/fake_api.rb @@ -1571,8 +1571,8 @@ def test_query_parameter_collection_format_with_http_info(pipe, ioutil, http, ur query_params[:'url'] = @api_client.build_collection_param(url, :csv) query_params[:'context'] = @api_client.build_collection_param(context, :multi) query_params[:'allowEmpty'] = allow_empty - # 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? + # form style, explode: one query parameter per entry, keyed by the property name; nil is left out + opts[:'language'].compact.each { |name, value| query_params[name.to_s] = value } if !opts[:'language'].nil? # header parameters header_params = opts[:header_params] || {} From 4dd7c362ed497579c40b5a11a3f22d6886d8f96d Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Wed, 23 Sep 2026 09:00:15 +0200 Subject: [PATCH 3/3] test: [ruby] keep the shared fixture byte-identical with the other ports Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/test/resources/3_0/exploded-object-query-param.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 index a24c4c916d5e..2f94dce6c7a6 100644 --- 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 @@ -2,9 +2,7 @@ openapi: 3.0.3 info: title: Exploded object query parameters description: > - Object typed query parameters under form/explode (as a free-form object and as a typed map), - deepObject, and form without explode. The free-form variant matters because it is flagged - isMap but not isContainer. + Object typed query parameters under form/explode (as a free-form object and as a typed map), deepObject, and form without explode. The free-form variant matters because it is flagged isMap but not isContainer. version: 1.0.0 servers: - url: localhost:8080