Skip to content

[python] fix: explode object query parameters - #24802

Open
wiebren wants to merge 3 commits into
OpenAPITools:masterfrom
wiebren:fix/exploded-object-query-parameters-python
Open

[python] fix: explode object query parameters#24802
wiebren wants to merge 3 commits into
OpenAPITools:masterfrom
wiebren:fix/exploded-object-query-parameters-python

Conversation

@wiebren

@wiebren wiebren commented Aug 28, 2026

Copy link
Copy Markdown

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 python client JSON encoded the whole object into a
single parameter instead, while csharp, java and php got it right.

Given:

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

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

generator on the wire
expected category=books&createdDate%3Agte=2023-01-01
python filter=%7B%22category%22:%20%22books%22,%20...%7D
csharp, java, php correct

Split out of #24797 at the maintainer's request, one PR per language. The go half
stays in #24797, the typescript-fetch half is #24803. The three are independent — no
shared main/ code — and can be reviewed and merged in any order. They each add the same
test fixture at the same path with identical content, so whichever lands first, the others
rebase cleanly.

Three causes

1. python/api.mustache — an object parameter was appended whole and json encoded by
ApiClient.parameters_to_url_query, which sees no style or explode. Exploded maps are now
appended entry by entry. parameters_to_url_query's dict branch is left alone: it is shared
by every call site and is still the right fallback for a parameter that is not exploded.

2. python/api_client.mustacheparameters_to_url_query quoted values and never
quoted names. That was harmless while every name came from baseName in the document, but
an exploded object takes its names from the object, so with (1) they became runtime data for
the first time and a name carrying & or = would break the URL. Names are now quoted too,
which is also what go already did via url.Values.Encode and typescript-fetch via
encodeURIComponent. Spec-declared names are unaffected — they are almost always already
URL-safe.

Verified on the wire, not just asserted

The client was generated from the new fixture and pointed at a server that echoes its own raw
query string back:

parameter python
filter (object, defaults) category=books&createdDate%3Agte=2023-01-01
typedFilter (map, defaults) same
deepFilter (style: deepObject) one json parameter
flatFilter (explode: false) one json parameter

The first two rows are the fixed behaviour; the last two are byte-for-byte what the generator
did before.

3. python/api_client.mustache, collection formatsparameters_to_url_query looked
the parameter name up in collection_formats without checking that the value was a
collection. That was safe while every name came from baseName, but with (1) the names are
runtime data, so a property name can collide with a sibling array parameter's name and the
scalar gets iterated character by character. In the petstore fixture,
testQueryParameterCollectionFormat declares context: multi next to the exploded map
language, so language={"context": "en"} went on the wire as context=e&context=n, and a
csv sibling turned https://x.test into h,t,t,p,s,%3A,/,/,x,.,t,e,s,t. The collection
format now applies only when the value really is a list or a tuple. Every genuine array
parameter is unaffected — multi, csv, ssv and pipes all serialize byte for byte as
before. Thanks to cubic for catching this.

Known gaps, called out deliberately

Declared object models. The exploded branch is gated on isMap, so a $refed object
model as a query parameter is isModel and still goes on the wire whole. This is
pre-existing — every object was json encoded before this change — and it is the same gap
typescript-fetch has in #24803, where iterating the model would also put the interface
property names on the wire rather than the wire names. Left for a follow-up.

deepObject. python emits deepFilter={"category": "books"} where the spec asks for
deepFilter[category]=books. That is a pre-existing defect with its own blast radius rather
than something this change introduces — the deepFilter row above is byte-for-byte what the
template did before — so it is left for a follow-up to keep this reviewable. Say the word and
I will fold it in.

Tests

modules/openapi-generator/src/test/resources/3_0/exploded-object-query-param.yaml covers
the four combinations that decide the wire format:

  • PythonClientCodegenTest#testExplodedObjectQueryParameter

It fails without the fix (verified by stashing only the main/ changes), and also asserts
the collection format guard from (3). 87 tests pass across PythonClientCodegenTest and
PythonPydanticV1ClientCodegenTest.

PR checklist

  • Read the contribution guidelines.
  • Built the project and updated samples (./bin/generate-samples.sh for
    bin/configs/python*.yaml; ./bin/utils/export_docs_generators.sh produced no diff).
    13 sample files changed: 8 api_client.py and 5 fake_api.py. The fake_api.py ones
    come from the petstore fixture's language parameter, a declared map with the default
    style; the api_client.py ones are the name quoting in (2) and the collection format
    guard in (3). The three python-pydantic-v1 samples are untouched, since that
    generator has its own api_client.mustache.
  • Technical committee: @cbornet @tomplus @arun-nalla

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 python client
JSON-encoded the whole object into a single parameter instead.

python/api.mustache appended the object whole, and it was JSON encoded by
ApiClient.parameters_to_url_query, which sees no style or explode. Exploded maps
are now appended entry by entry. parameters_to_url_query's dict branch is left
alone: it is shared by every call site and is still the right fallback for a
parameter that is not exploded.

python/api_client.mustache quoted values and never quoted names. That was
harmless while every name came from baseName in the document, but an exploded
object takes its names from the object, so the names became runtime data for the
first time and a name carrying & or = would break the URL. Names are now quoted
too.
@wiebren

wiebren commented Aug 28, 2026

Copy link
Copy Markdown
Author

Split per language as requested, out of #24797. This is the python half; the siblings are:

The three touch disjoint sets of files under main/ and have no ordering dependency, so they
can be reviewed and merged independently. Each adds
modules/openapi-generator/src/test/resources/3_0/exploded-object-query-param.yaml at the
same path with identical content, so whichever lands first, the others rebase cleanly.

Each branch was tested on its own after the split, not just as part of the original combined
branch.

@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 17 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/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py">

<violation number="1" location="samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py:9539">
P2: When `language` contains a property named `context` (or another collection-format parameter), the serializer treats that exploded property as the unrelated array parameter and corrupts its value. Serialize exploded-object entries separately from `_collection_formats` lookup so arbitrary map keys remain scalar query values.</violation>
</file>

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

<violation number="1" location="modules/openapi-generator/src/main/resources/python/api.mustache:366">
P1: When a query parameter is an object with declared properties rather than a map, this `isMap` gate leaves it on the single-parameter path, so the generator still JSON-encodes the whole object. Apply exploded form serialization to object models as well, or add the corresponding property iteration for their generated representation.</violation>
</file>

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

Re-trigger cubic

_query_params.append(('{{baseName}}', {{paramName}}))
{{/isExplode}}
{{/isMap}}
{{^isMap}}

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.

P1: When a query parameter is an object with declared properties rather than a map, this isMap gate leaves it on the single-parameter path, so the generator still JSON-encodes the whole object. Apply exploded form serialization to object models as well, or add the corresponding property iteration for their generated representation.

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/python/api.mustache, line 366:

<comment>When a query parameter is an object with declared properties rather than a map, this `isMap` gate leaves it on the single-parameter path, so the generator still JSON-encodes the whole object. Apply exploded form serialization to object models as well, or add the corresponding property iteration for their generated representation.</comment>

<file context>
@@ -347,7 +347,25 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb
+            _query_params.append(('{{baseName}}', {{paramName}}))
+            {{/isExplode}}
+            {{/isMap}}
+            {{^isMap}}
             _query_params.append(('{{baseName}}', {{paramName}}{{#isEnumRef}}.value{{/isEnumRef}}))
+            {{/isMap}}
</file context>

_query_params.append(('language', language))
# form style explodes an object into one parameter per entry, keyed by the
# property name alone
for _key, _value in language.items():

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: When language contains a property named context (or another collection-format parameter), the serializer treats that exploded property as the unrelated array parameter and corrupts its value. Serialize exploded-object entries separately from _collection_formats lookup so arbitrary map keys remain scalar query values.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py, line 9539:

<comment>When `language` contains a property named `context` (or another collection-format parameter), the serializer treats that exploded property as the unrelated array parameter and corrupts its value. Serialize exploded-object entries separately from `_collection_formats` lookup so arbitrary map keys remain scalar query values.</comment>

<file context>
@@ -9534,7 +9534,10 @@ def _test_query_parameter_collection_format_serialize(
-            _query_params.append(('language', language))
+            # form style explodes an object into one parameter per entry, keyed by the
+            # property name alone
+            for _key, _value in language.items():
+                _query_params.append((_key, _value))
             
</file context>

…entry

An exploded object query parameter takes its names from the object, so a
property name can collide with the name of a sibling array parameter that
declares a collection format. parameters_to_url_query looked the name up in
collection_formats without checking that the value was a collection, so the
scalar was iterated character by character.

In the petstore fixture, testQueryParameterCollectionFormat declares
context: multi alongside the exploded map language. Calling it with
language={"context": "en"} produced context=e&context=n, and a csv sibling
turned https://x.test into h,t,t,p,s,%3A,/,/,x,.,t,e,s,t.

The collection format now applies only when the value really is a list or a
tuple. Every genuine array parameter is unaffected — multi, csv, ssv and pipes
all serialize byte for byte as before.

Reported by cubic on OpenAPITools#24802.
@wiebren

wiebren commented Aug 28, 2026

Copy link
Copy Markdown
Author

Thanks cubic — one of these was a real regression and is now fixed in 82c90b6.

P2, collection format collision — valid, fixed. This was introduced by this PR and I had
missed it. parameters_to_url_query looked the name up in collection_formats without
checking the value was a collection. Harmless while every name came from baseName, but the
exploded names are runtime data, so a property name colliding with a sibling array parameter
sent the scalar down the list path and it was iterated character by character. Reproduced
against the petstore fixture, which declares context: multi next to the exploded map
language:

input before after
language={"context": "en", "region": "eu"} context=e&context=n&region=eu context=en&region=eu
language={"url": "https://x.test"} url=h,t,t,p,s,%3A,/,/,x,.,t,e,s,t url=https%3A//x.test

The fix gates the branch on the value actually being a collection:

if k in collection_formats and isinstance(v, (list, tuple)):

Checked all four collection formats against real array parameters — multi, csv, ssv,
pipes — and each serializes byte for byte as before, so the guard only removes the
mis-dispatch. PythonClientCodegenTest#testExplodedObjectQueryParameter now asserts it.

Worth noting your suggested framing — serialize exploded entries separately from the
_collection_formats lookup — isn't reachable from api.mustache: everything lands in the
same _query_params list that parameters_to_url_query consumes, so the guard has to live
in the serializer. Same outcome, different seam.

P1, declared object models — valid, but pre-existing and deliberately out of scope. The
isMap gate does leave a $refed object model on the single-parameter path. That is not a
regression: every object was json encoded before this change, so models are no worse off,
maps are better off. Folding models in is not a template branch either — it needs the
generated model's wire names, not its python attribute names, so it has to route through the
serializer the way typescript-fetch has to route through {{dataType}}ToJSON (same gap,
see #24803). I have added it to the known-gaps section rather than half-fix it here. Happy to
take it as a follow-up.

@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.

3 issues found across 10 files (changes from recent commits).

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/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java">

<violation number="1" location="modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java:766">
P3: The collision scenario this comment describes (an exploded object property named `context` alongside a `context: multi` array parameter) is not present in the fixture, so the assertion only checks that the guard line exists in the shared api_client.mustache output. That line is generated for every Python client regardless of the fixture, so the test would pass even if the collision behavior regressed. Add a colliding parameter/property pair to exploded-object-query-param.yaml and assert the generated wire behavior (e.g., that the exploded entry is not split by the sibling array's collection format) to actually exercise the fix.</violation>
</file>

<file name="samples/client/others/python-legacy-model-dictionaries/legacy_model_dict_client/api_client.py">

<violation number="1" location="samples/client/others/python-legacy-model-dictionaries/legacy_model_dict_client/api_client.py:653">
P2: The guard and name-quoting fixes were applied only to the `python` template; the `python-pydantic-v1` template variant still generates `if k in collection_formats:` without the `isinstance(v, (list, tuple))` guard and without quoting parameter names. Pydantic-v1 generated clients therefore still have the collection-format collision bug (and unquoted names) for exploded object query params. The test only exercises the `python` generator, so the pydantic-v1 gap is not covered. Mirror the change in python-pydantic-v1/api_client.mustache and regenerate the pydantic-v1 samples.</violation>
</file>

<file name="modules/openapi-generator/src/main/resources/python/api_client.mustache">

<violation number="1" location="modules/openapi-generator/src/main/resources/python/api_client.mustache:696">
P3: The isinstance check only prevents the collision for scalar values. If an exploded object property value is itself a list/tuple and its name collides with a sibling array parameter, the collection format is still applied, so the property is still joined or repeated as if it were the sibling's list. For example, `filter: {context: ['en', 'fr']}` alongside a `context` array parameter with `multi` format still emits `context=en&context=fr` from the object entry, which is exactly the behavior the comment says must not happen. Distinguishing declared array parameter names from runtime object property names (e.g., only applying collection formats to keys that are declared query parameters) would close the gap; the value-type check alone cannot.</violation>
</file>

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

Re-trigger cubic

# collection. An exploded object query parameter takes its names from the
# object, so a property name that happens to match a sibling array parameter
# must not be joined or repeated as if it were that parameter's list.
if k in collection_formats and isinstance(v, (list, tuple)):

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: The guard and name-quoting fixes were applied only to the python template; the python-pydantic-v1 template variant still generates if k in collection_formats: without the isinstance(v, (list, tuple)) guard and without quoting parameter names. Pydantic-v1 generated clients therefore still have the collection-format collision bug (and unquoted names) for exploded object query params. The test only exercises the python generator, so the pydantic-v1 gap is not covered. Mirror the change in python-pydantic-v1/api_client.mustache and regenerate the pydantic-v1 samples.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/client/others/python-legacy-model-dictionaries/legacy_model_dict_client/api_client.py, line 653:

<comment>The guard and name-quoting fixes were applied only to the `python` template; the `python-pydantic-v1` template variant still generates `if k in collection_formats:` without the `isinstance(v, (list, tuple))` guard and without quoting parameter names. Pydantic-v1 generated clients therefore still have the collection-format collision bug (and unquoted names) for exploded object query params. The test only exercises the `python` generator, so the pydantic-v1 gap is not covered. Mirror the change in python-pydantic-v1/api_client.mustache and regenerate the pydantic-v1 samples.</comment>

<file context>
@@ -646,7 +646,11 @@ def parameters_to_url_query(self, params, collection_formats):
+            # collection. An exploded object query parameter takes its names from the
+            # object, so a property name that happens to match a sibling array parameter
+            # must not be joined or repeated as if it were that parameter's list.
+            if k in collection_formats and isinstance(v, (list, tuple)):
                 collection_format = collection_formats[k]
                 if collection_format == 'multi':
</file context>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Not needed there — python-pydantic-v1 never got the behavior these fixes protect. This PR
touches only the python templates; the pydantic-v1 api.mustache has no explode branch,
so an object query parameter still goes out on the single-parameter path and no runtime
property name ever reaches its parameters_to_url_query. The unguarded if k in collection_formats: there only ever sees the declared parameter names it always saw, and
the quoting fix protects runtime data that template never produces. Extending the explode
behavior to pydantic-v1 would be its own change with its own samples churn; happy to file
it separately if maintainers want the template variants to converge.

// to a value that actually is a collection, or "context": "en" alongside a
// context: multi array parameter would go on the wire as context=e&context=n.
Path apiClient = Paths.get(output.getAbsolutePath(), "openapi_client", "api_client.py");
TestUtils.assertFileContains(apiClient,

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 collision scenario this comment describes (an exploded object property named context alongside a context: multi array parameter) is not present in the fixture, so the assertion only checks that the guard line exists in the shared api_client.mustache output. That line is generated for every Python client regardless of the fixture, so the test would pass even if the collision behavior regressed. Add a colliding parameter/property pair to exploded-object-query-param.yaml and assert the generated wire behavior (e.g., that the exploded entry is not split by the sibling array's collection format) to actually exercise the fix.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java, line 766:

<comment>The collision scenario this comment describes (an exploded object property named `context` alongside a `context: multi` array parameter) is not present in the fixture, so the assertion only checks that the guard line exists in the shared api_client.mustache output. That line is generated for every Python client regardless of the fixture, so the test would pass even if the collision behavior regressed. Add a colliding parameter/property pair to exploded-object-query-param.yaml and assert the generated wire behavior (e.g., that the exploded entry is not split by the sibling array's collection format) to actually exercise the fix.</comment>

<file context>
@@ -757,6 +757,14 @@ public void testExplodedObjectQueryParameter() throws IOException {
+        // to a value that actually is a collection, or "context": "en" alongside a
+        // context: multi array parameter would go on the wire as context=e&context=n.
+        Path apiClient = Paths.get(output.getAbsolutePath(), "openapi_client", "api_client.py");
+        TestUtils.assertFileContains(apiClient,
+            "if k in collection_formats and isinstance(v, (list, tuple)):");
     }
</file context>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fair — the fixture asserted the guard's text, not its behavior. Added two runtime tests in
c3929d4, in the hand-maintained petstore sample tests (tests/test_api_client.py, which CI
executes): a scalar entry named like a declared multi array parameter goes out as itself
(language=nl&context=abc), and the declared array parameter itself still gets its
collection format (context=a&context=b). Both run against the generated
parameters_to_url_query, so the collision path is now exercised, not just present.

# collection. An exploded object query parameter takes its names from the
# object, so a property name that happens to match a sibling array parameter
# must not be joined or repeated as if it were that parameter's list.
if k in collection_formats and isinstance(v, (list, tuple)):

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 isinstance check only prevents the collision for scalar values. If an exploded object property value is itself a list/tuple and its name collides with a sibling array parameter, the collection format is still applied, so the property is still joined or repeated as if it were the sibling's list. For example, filter: {context: ['en', 'fr']} alongside a context array parameter with multi format still emits context=en&context=fr from the object entry, which is exactly the behavior the comment says must not happen. Distinguishing declared array parameter names from runtime object property names (e.g., only applying collection formats to keys that are declared query parameters) would close the gap; the value-type check alone cannot.

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/python/api_client.mustache, line 696:

<comment>The isinstance check only prevents the collision for scalar values. If an exploded object property value is itself a list/tuple and its name collides with a sibling array parameter, the collection format is still applied, so the property is still joined or repeated as if it were the sibling's list. For example, `filter: {context: ['en', 'fr']}` alongside a `context` array parameter with `multi` format still emits `context=en&context=fr` from the object entry, which is exactly the behavior the comment says must not happen. Distinguishing declared array parameter names from runtime object property names (e.g., only applying collection formats to keys that are declared query parameters) would close the gap; the value-type check alone cannot.</comment>

<file context>
@@ -689,7 +689,11 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb
+            # collection. An exploded object query parameter takes its names from the
+            # object, so a property name that happens to match a sibling array parameter
+            # must not be joined or repeated as if it were that parameter's list.
+            if k in collection_formats and isinstance(v, (list, tuple)):
                 collection_format = collection_formats[k]
                 if collection_format == 'multi':
</file context>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Accurate as a mechanism, and left as is deliberately. A list-valued property of an
exploded object has no defined serialization in OpenAPI at all (form/explode is specified
for objects with scalar-ish members; nested collections are undefined), so when such a name
also collides with a declared array parameter there is no "right answer" to restore — the
guard covers the case the spec does define, a scalar property shadowing an array parameter.
Distinguishing the two sources for nested lists would mean tagging exploded entries through
the whole _query_params pipeline, which is a larger restructure than this fix warrants.
The new runtime tests in c3929d4 pin the two defined behaviors either side of the guard.

Two runtime tests on parameters_to_url_query in the hand-maintained
petstore sample tests: a scalar entry named like a declared array
parameter goes out as itself, and the declared array parameter still gets
its collection format.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Go3AyndcGwv5tFTwo9aBfy
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant