From bbcc510c695135abde25fa86db25fc4a2073d5fa Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Fri, 4 Sep 2026 20:22:33 +0200 Subject: [PATCH 1/4] fix: [dart] 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 dart client handed the whole map to _queryParams, which stringified it with Map.toString() into a single parameter (filter={tld: com, createdDate:gte: 2023-01-01}). The generated api now iterates an exploded map entry by entry at the call site; _queryParams is left alone, since it never receives style or explode and is still the right fallback for every other parameter. A free-form object is typed Object in dart and needs a cast to Map; a declared map does not. 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 --- .../src/main/resources/dart2/api.mustache | 16 ++++++ .../codegen/dart/DartClientCodegenTest.java | 27 ++++++++++ .../3_0/exploded-object-query-param.yaml | 51 +++++++++++++++++++ .../lib/api/fake_api.dart | 3 +- 4 files changed, 96 insertions(+), 1 deletion(-) 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/dart2/api.mustache b/modules/openapi-generator/src/main/resources/dart2/api.mustache index 1c995009e018..6c7ffbfdd0a1 100644 --- a/modules/openapi-generator/src/main/resources/dart2/api.mustache +++ b/modules/openapi-generator/src/main/resources/dart2/api.mustache @@ -67,7 +67,23 @@ class {{{classname}}} { {{^required}} if ({{{paramName}}} != null) { {{/required}} + {{#isMap}} + {{#isExplode}} + {{^isDeepObject}} + // form style explodes an object into one query parameter per entry, keyed by the property name alone + {{#isFreeFormObject}}({{{paramName}}} as Map){{/isFreeFormObject}}{{^isFreeFormObject}}{{{paramName}}}{{/isFreeFormObject}}.forEach((entryKey, entryValue) => queryParams.addAll(_queryParams('', entryKey.toString(), entryValue))); + {{/isDeepObject}} + {{#isDeepObject}} queryParams.addAll(_queryParams('{{{collectionFormat}}}', '{{{baseName}}}', {{{paramName}}})); + {{/isDeepObject}} + {{/isExplode}} + {{^isExplode}} + queryParams.addAll(_queryParams('{{{collectionFormat}}}', '{{{baseName}}}', {{{paramName}}})); + {{/isExplode}} + {{/isMap}} + {{^isMap}} + queryParams.addAll(_queryParams('{{{collectionFormat}}}', '{{{baseName}}}', {{{paramName}}})); + {{/isMap}} {{^required}} } {{/required}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/DartClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/DartClientCodegenTest.java index 2215908d3d65..d8101905cae9 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/DartClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/DartClientCodegenTest.java @@ -137,6 +137,33 @@ public void testObjectArrayDoesNotUseListFromJson() throws Exception { TestUtils.assertFileContains(modelFile.toPath(), "cast"); } + @Test(description = "Verify an object query parameter is exploded, whether or not it declares its properties") + public void testExplodedObjectQueryParameter() throws Exception { + List files = generateDartNativeFromSpec( + "src/test/resources/3_0/exploded-object-query-param.yaml"); + + File apiFile = files.stream() + .filter(f -> f.getName().equals("default_api.dart")) + .findFirst() + .orElseThrow(() -> new AssertionError("default_api.dart not found in generated files")); + + // form style with explode - the default - puts every entry on the wire under its own + // property name. Handing the whole map to _queryParams stringifies it with + // Map.toString(), which is what used to happen. + TestUtils.assertFileContains(apiFile.toPath(), + "(filter as Map).forEach((entryKey, entryValue) => queryParams.addAll(_queryParams('', entryKey.toString(), entryValue)));"); + TestUtils.assertFileNotContains(apiFile.toPath(), "_queryParams('', 'filter', filter)"); + + // a declared map behaves the same way, and needs no cast + TestUtils.assertFileContains(apiFile.toPath(), + "typedFilter.forEach((entryKey, entryValue) => queryParams.addAll(_queryParams('', entryKey.toString(), entryValue)));"); + + // deepObject and form without explode both keep a single parameter + TestUtils.assertFileContains(apiFile.toPath(), + "_queryParams('', 'deepFilter', deepFilter)", + "_queryParams('', 'flatFilter', flatFilter)"); + } + @Test(description = "Enum properties with defaults should emit enum constructor, not string literal") public void testEnumDefaultUsesEnumConstructor() throws Exception { List files = generateDartNativeFromSpec( 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/openapi3/client/petstore/dart2/petstore_client_lib_fake/lib/api/fake_api.dart b/samples/openapi3/client/petstore/dart2/petstore_client_lib_fake/lib/api/fake_api.dart index c61373a87937..35e2a4864080 100644 --- a/samples/openapi3/client/petstore/dart2/petstore_client_lib_fake/lib/api/fake_api.dart +++ b/samples/openapi3/client/petstore/dart2/petstore_client_lib_fake/lib/api/fake_api.dart @@ -1382,7 +1382,8 @@ class FakeApi { queryParams.addAll(_queryParams('csv', 'url', url)); queryParams.addAll(_queryParams('multi', 'context', context)); if (language != null) { - queryParams.addAll(_queryParams('', 'language', language)); + // form style explodes an object into one query parameter per entry, keyed by the property name alone + language.forEach((entryKey, entryValue) => queryParams.addAll(_queryParams('', entryKey.toString(), entryValue))); } queryParams.addAll(_queryParams('', 'allowEmpty', allowEmpty)); From 4b2987697fd092f043f5deac121d23031994a635 Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Mon, 21 Sep 2026 15:33:54 +0200 Subject: [PATCH 2/4] fix: [dart] repeat the key per element for a collection in an exploded object An exploded form-style object query parameter handed each entry to _queryParams with an empty collection format, which defaults to csv, so a list value went out comma joined (tld=com%2Cnet) instead of once per element (tld=com&tld=net). A Set went out as its toString(), since _queryParams only expands a List, and a null element went out as an empty value. Each entry is now passed with the 'multi' collection format, and an Iterable value is turned into a List with its null elements dropped first, so the key repeats once per non-null element and an empty collection adds nothing. The closure parameter is typed dynamic so the Iterable check still compiles for a map with a declared value type. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/main/resources/dart2/api.mustache | 5 +++-- .../openapitools/codegen/dart/DartClientCodegenTest.java | 8 +++++--- .../dart2/petstore_client_lib_fake/lib/api/fake_api.dart | 5 +++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/dart2/api.mustache b/modules/openapi-generator/src/main/resources/dart2/api.mustache index 6c7ffbfdd0a1..07febaaf3cd1 100644 --- a/modules/openapi-generator/src/main/resources/dart2/api.mustache +++ b/modules/openapi-generator/src/main/resources/dart2/api.mustache @@ -70,8 +70,9 @@ class {{{classname}}} { {{#isMap}} {{#isExplode}} {{^isDeepObject}} - // form style explodes an object into one query parameter per entry, keyed by the property name alone - {{#isFreeFormObject}}({{{paramName}}} as Map){{/isFreeFormObject}}{{^isFreeFormObject}}{{{paramName}}}{{/isFreeFormObject}}.forEach((entryKey, entryValue) => queryParams.addAll(_queryParams('', entryKey.toString(), entryValue))); + // form style explodes an object into one query parameter per entry, keyed by the property name alone; + // a collection value repeats that key once per non-null element (dynamic, so a typed map value still type checks) + {{#isFreeFormObject}}({{{paramName}}} as Map){{/isFreeFormObject}}{{^isFreeFormObject}}{{{paramName}}}{{/isFreeFormObject}}.forEach((entryKey, dynamic entryValue) => queryParams.addAll(_queryParams('multi', entryKey.toString(), entryValue is Iterable ? entryValue.where((e) => e != null).toList() : entryValue))); {{/isDeepObject}} {{#isDeepObject}} queryParams.addAll(_queryParams('{{{collectionFormat}}}', '{{{baseName}}}', {{{paramName}}})); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/DartClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/DartClientCodegenTest.java index d8101905cae9..64ed18868a9e 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/DartClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/DartClientCodegenTest.java @@ -149,14 +149,16 @@ public void testExplodedObjectQueryParameter() throws Exception { // form style with explode - the default - puts every entry on the wire under its own // property name. Handing the whole map to _queryParams stringifies it with - // Map.toString(), which is what used to happen. + // Map.toString(), which is what used to happen. A collection value repeats the key once + // per non-null element ('multi') rather than going out comma joined, and a Set is turned + // into a List first, since _queryParams only expands a List. TestUtils.assertFileContains(apiFile.toPath(), - "(filter as Map).forEach((entryKey, entryValue) => queryParams.addAll(_queryParams('', entryKey.toString(), entryValue)));"); + "(filter as Map).forEach((entryKey, dynamic entryValue) => queryParams.addAll(_queryParams('multi', entryKey.toString(), entryValue is Iterable ? entryValue.where((e) => e != null).toList() : entryValue)));"); TestUtils.assertFileNotContains(apiFile.toPath(), "_queryParams('', 'filter', filter)"); // a declared map behaves the same way, and needs no cast TestUtils.assertFileContains(apiFile.toPath(), - "typedFilter.forEach((entryKey, entryValue) => queryParams.addAll(_queryParams('', entryKey.toString(), entryValue)));"); + "typedFilter.forEach((entryKey, dynamic entryValue) => queryParams.addAll(_queryParams('multi', entryKey.toString(), entryValue is Iterable ? entryValue.where((e) => e != null).toList() : entryValue)));"); // deepObject and form without explode both keep a single parameter TestUtils.assertFileContains(apiFile.toPath(), diff --git a/samples/openapi3/client/petstore/dart2/petstore_client_lib_fake/lib/api/fake_api.dart b/samples/openapi3/client/petstore/dart2/petstore_client_lib_fake/lib/api/fake_api.dart index 35e2a4864080..ad2ed60d835c 100644 --- a/samples/openapi3/client/petstore/dart2/petstore_client_lib_fake/lib/api/fake_api.dart +++ b/samples/openapi3/client/petstore/dart2/petstore_client_lib_fake/lib/api/fake_api.dart @@ -1382,8 +1382,9 @@ class FakeApi { queryParams.addAll(_queryParams('csv', 'url', url)); queryParams.addAll(_queryParams('multi', 'context', context)); if (language != null) { - // form style explodes an object into one query parameter per entry, keyed by the property name alone - language.forEach((entryKey, entryValue) => queryParams.addAll(_queryParams('', entryKey.toString(), entryValue))); + // form style explodes an object into one query parameter per entry, keyed by the property name alone; + // a collection value repeats that key once per non-null element (dynamic, so a typed map value still type checks) + language.forEach((entryKey, dynamic entryValue) => queryParams.addAll(_queryParams('multi', entryKey.toString(), entryValue is Iterable ? entryValue.where((e) => e != null).toList() : entryValue))); } queryParams.addAll(_queryParams('', 'allowEmpty', allowEmpty)); From 6a8e94adc8009d7525d1ca42a3ce096c39225d7a Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Wed, 23 Sep 2026 08:59:24 +0200 Subject: [PATCH 3/4] chore: [dart] trim the exploded query comment The shipped comment is one line now, without the implementation note. 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/dart2/api.mustache | 3 +-- .../openapitools/codegen/dart/DartClientCodegenTest.java | 7 +++---- .../test/resources/3_0/exploded-object-query-param.yaml | 6 +++--- .../dart2/petstore_client_lib_fake/lib/api/fake_api.dart | 3 +-- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/dart2/api.mustache b/modules/openapi-generator/src/main/resources/dart2/api.mustache index 07febaaf3cd1..015bfb9ac1bd 100644 --- a/modules/openapi-generator/src/main/resources/dart2/api.mustache +++ b/modules/openapi-generator/src/main/resources/dart2/api.mustache @@ -70,8 +70,7 @@ class {{{classname}}} { {{#isMap}} {{#isExplode}} {{^isDeepObject}} - // form style explodes an object into one query parameter per entry, keyed by the property name alone; - // a collection value repeats that key once per non-null element (dynamic, so a typed map value still type checks) + // form style, explode: one query parameter per entry, keyed by the property name; a collection repeats the key per non-null element {{#isFreeFormObject}}({{{paramName}}} as Map){{/isFreeFormObject}}{{^isFreeFormObject}}{{{paramName}}}{{/isFreeFormObject}}.forEach((entryKey, dynamic entryValue) => queryParams.addAll(_queryParams('multi', entryKey.toString(), entryValue is Iterable ? entryValue.where((e) => e != null).toList() : entryValue))); {{/isDeepObject}} {{#isDeepObject}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/DartClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/DartClientCodegenTest.java index 64ed18868a9e..301193c8fa63 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/DartClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/DartClientCodegenTest.java @@ -137,7 +137,7 @@ public void testObjectArrayDoesNotUseListFromJson() throws Exception { TestUtils.assertFileContains(modelFile.toPath(), "cast"); } - @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 { List files = generateDartNativeFromSpec( "src/test/resources/3_0/exploded-object-query-param.yaml"); @@ -149,9 +149,8 @@ public void testExplodedObjectQueryParameter() throws Exception { // form style with explode - the default - puts every entry on the wire under its own // property name. Handing the whole map to _queryParams stringifies it with - // Map.toString(), which is what used to happen. A collection value repeats the key once - // per non-null element ('multi') rather than going out comma joined, and a Set is turned - // into a List first, since _queryParams only expands a List. + // Map.toString(), which is what used to happen. A collection value repeats the key per + // non-null element ('multi'). TestUtils.assertFileContains(apiFile.toPath(), "(filter as Map).forEach((entryKey, dynamic entryValue) => queryParams.addAll(_queryParams('multi', entryKey.toString(), entryValue is Iterable ? entryValue.where((e) => e != null).toList() : entryValue)));"); TestUtils.assertFileNotContains(apiFile.toPath(), "_queryParams('', 'filter', filter)"); 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/openapi3/client/petstore/dart2/petstore_client_lib_fake/lib/api/fake_api.dart b/samples/openapi3/client/petstore/dart2/petstore_client_lib_fake/lib/api/fake_api.dart index ad2ed60d835c..ab0713d278f6 100644 --- a/samples/openapi3/client/petstore/dart2/petstore_client_lib_fake/lib/api/fake_api.dart +++ b/samples/openapi3/client/petstore/dart2/petstore_client_lib_fake/lib/api/fake_api.dart @@ -1382,8 +1382,7 @@ class FakeApi { queryParams.addAll(_queryParams('csv', 'url', url)); queryParams.addAll(_queryParams('multi', 'context', context)); if (language != null) { - // form style explodes an object into one query parameter per entry, keyed by the property name alone; - // a collection value repeats that key once per non-null element (dynamic, so a typed map value still type checks) + // form style, explode: one query parameter per entry, keyed by the property name; a collection repeats the key per non-null element language.forEach((entryKey, dynamic entryValue) => queryParams.addAll(_queryParams('multi', entryKey.toString(), entryValue is Iterable ? entryValue.where((e) => e != null).toList() : entryValue))); } queryParams.addAll(_queryParams('', 'allowEmpty', allowEmpty)); From 7fe32f83267bacc642db4228acd3d7ff6a0af421 Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Wed, 23 Sep 2026 09:00:15 +0200 Subject: [PATCH 4/4] test: [dart] 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