Skip to content

Commit 8300f48

Browse files
wiebrenclaude
andauthored
[python] fix: explode object query parameters (#24802)
* fix: [python] explode object query parameters 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. * fix: [python] do not apply a collection format to an exploded object 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 #24802. * test: [python] exercise the exploded-name collision guard at runtime 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 * fix: [python] explode object query parameters with declared properties An object with declared properties becomes a model rather than a dict, so it was flagged isModel, missed the isMap branch and was still json encoded as a single parameter. With form style and explode a model is now serialized with sanitize_for_serialization first, so its entries carry the names the properties have on the wire and unset properties are left out, and then exploded like a map. A oneOf or anyOf model holding a primitive does not serialize to a dict and stays a single parameter; deepObject and explode: false are unchanged. An exploded entry holding a list now repeats its name per item (photoUrls=a&photoUrls=b), as java, go and typescript-axios do, instead of going out as the str() of the list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: [python] skip null entries and explode a oneOf holding a list A null map value went on the wire as k=None and a null list item as l=None, because parameters_to_url_query quotes str(None). Both exploded branches now skip a null value and a null item, as kotlin, dart and go do. The model branch also appends the serialized value rather than the model. For a oneOf or anyOf holding a primitive that changes nothing on the wire, since param_serialize runs sanitize_for_serialization over every query parameter before parameters_to_url_query, but one holding a list now repeats the parameter name per item instead of sending the list's repr. Adds a runtime test for the quoting of exploded property names, which only the generated text covered until now, and one for a model whose optional properties are left unset. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor: [python] explode query objects in one ApiClient helper The map and model branches repeated the same loop in every generated operation. ApiClient.explode_query_object now serializes the value and returns one pair per entry, so each branch is a single call, and the helper gets direct unit tests. Comments shrink to one line, the shared fixture's description names the three combinations it covers, and the test titles say what they check. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * chore: [python] regenerate the python-httpx2 samples They landed on master after this branch was cut, so they did not carry the exploded query object changes yet. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a61e05c commit 8300f48

25 files changed

Lines changed: 387 additions & 57 deletions

File tree

‎modules/openapi-generator/src/main/resources/python/api.mustache‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,37 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb
347347
_query_params.append(('{{baseName}}', {{paramName}}))
348348
{{/isDate}}
349349
{{^isDateTime}}{{^isDate}}
350+
{{#isMap}}
351+
{{#isExplode}}
352+
{{#isDeepObject}}
353+
_query_params.append(('{{baseName}}', {{paramName}}))
354+
{{/isDeepObject}}
355+
{{^isDeepObject}}
356+
_query_params.extend(self.api_client.explode_query_object('{{baseName}}', {{paramName}}))
357+
{{/isDeepObject}}
358+
{{/isExplode}}
359+
{{^isExplode}}
360+
_query_params.append(('{{baseName}}', {{paramName}}))
361+
{{/isExplode}}
362+
{{/isMap}}
363+
{{#isModel}}
364+
{{#isExplode}}
365+
{{#isDeepObject}}
366+
_query_params.append(('{{baseName}}', {{paramName}}))
367+
{{/isDeepObject}}
368+
{{^isDeepObject}}
369+
_query_params.extend(self.api_client.explode_query_object('{{baseName}}', {{paramName}}))
370+
{{/isDeepObject}}
371+
{{/isExplode}}
372+
{{^isExplode}}
373+
_query_params.append(('{{baseName}}', {{paramName}}))
374+
{{/isExplode}}
375+
{{/isModel}}
376+
{{^isMap}}
377+
{{^isModel}}
350378
_query_params.append(('{{baseName}}', {{paramName}}{{#isEnumRef}}.value{{/isEnumRef}}))
379+
{{/isModel}}
380+
{{/isMap}}
351381
{{/isDate}}{{/isDateTime}}
352382
{{/queryParams}}
353383
# process the header parameters

‎modules/openapi-generator/src/main/resources/python/api_client.mustache‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,13 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb
671671
new_params.append((k, v))
672672
return new_params
673673

674+
def explode_query_object(self, name, obj):
675+
"""form style, explode: one query parameter per entry, keyed by the property name; a list repeats the name, None is left out"""
676+
obj = self.sanitize_for_serialization(obj)
677+
if not isinstance(obj, dict):
678+
obj = {name: obj}
679+
return [(k, item) for k, v in obj.items() for item in (v if isinstance(v, (list, tuple)) else [v]) if item is not None]
680+
674681
def parameters_to_url_query(self, params, collection_formats):
675682
"""Get parameters as list of tuples, formatting collections.
676683

@@ -689,11 +696,12 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb
689696
if isinstance(v, dict):
690697
v = json.dumps(v)
691698

692-
if k in collection_formats:
699+
# a collection format applies only to a list; an exploded entry may share a declared array parameter's name
700+
if k in collection_formats and isinstance(v, (list, tuple)):
693701
collection_format = collection_formats[k]
694702
if collection_format == 'multi':
695703
new_params.extend(
696-
(k, quote(str(value).lower() if isinstance(value, bool) else str(value)))
704+
(quote(str(k)), quote(str(value).lower() if isinstance(value, bool) else str(value)))
697705
for value in v
698706
)
699707
else:
@@ -706,12 +714,13 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb
706714
else: # csv is the default
707715
delimiter = ','
708716
new_params.append(
709-
(k, delimiter.join(
717+
(quote(str(k)), delimiter.join(
710718
quote(str(value).lower() if isinstance(value, bool) else str(value))
711719
for value in v))
712720
)
713721
else:
714-
new_params.append((k, quote(str(v))))
722+
# names are quoted too: an exploded object's names are runtime data
723+
new_params.append((quote(str(k)), quote(str(v))))
715724

716725
return "&".join(["=".join(map(str, item)) for item in new_params])
717726

‎modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,74 @@ public void testInitFileImportsExportsWithCustomApiPackage() throws IOException
803803
assertFileContains(apiInitFile.toPath(), "from my_pkg.my_api.pet_api import PetApi");
804804
}
805805

806+
@Test(description = "Verify a form style, exploded map query parameter goes on the wire one entry per parameter")
807+
public void testExplodedObjectQueryParameter() throws IOException {
808+
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
809+
output.deleteOnExit();
810+
811+
final CodegenConfigurator configurator = new CodegenConfigurator()
812+
.setGeneratorName("python")
813+
.setInputSpec("src/test/resources/3_0/exploded-object-query-param.yaml")
814+
.setOutputDir(output.getAbsolutePath());
815+
816+
DefaultGenerator generator = new DefaultGenerator();
817+
List<File> files = generator.opts(configurator.toClientOptInput()).generate();
818+
files.forEach(File::deleteOnExit);
819+
820+
Path api = Paths.get(output.getAbsolutePath(), "openapi_client", "api", "default_api.py");
821+
822+
TestUtils.assertFileContains(api,
823+
"_query_params.extend(self.api_client.explode_query_object('filter', filter))",
824+
"_query_params.extend(self.api_client.explode_query_object('typedFilter', typed_filter))");
825+
TestUtils.assertFileNotContains(api, "_query_params.append(('filter', filter))");
826+
827+
// deepObject and form without explode both keep a single parameter
828+
TestUtils.assertFileContains(api,
829+
"_query_params.append(('deepFilter', deep_filter))",
830+
"_query_params.append(('flatFilter', flat_filter))");
831+
TestUtils.assertFileNotContains(api,
832+
"explode_query_object('deepFilter', deep_filter)",
833+
"explode_query_object('flatFilter', flat_filter)");
834+
835+
// a collection format applies only to a list, so an exploded "context": "en" next to a context: multi array stays context=en
836+
Path apiClient = Paths.get(output.getAbsolutePath(), "openapi_client", "api_client.py");
837+
TestUtils.assertFileContains(apiClient,
838+
"def explode_query_object(self, name, obj):",
839+
"if k in collection_formats and isinstance(v, (list, tuple)):");
840+
}
841+
842+
@Test
843+
public void testExplodedModelQueryParameter() throws IOException {
844+
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
845+
output.deleteOnExit();
846+
847+
final CodegenConfigurator configurator = new CodegenConfigurator()
848+
.setGeneratorName("python")
849+
.setInputSpec("src/test/resources/3_0/python/exploded-model-query-param.yaml")
850+
.setOutputDir(output.getAbsolutePath());
851+
852+
DefaultGenerator generator = new DefaultGenerator();
853+
List<File> files = generator.opts(configurator.toClientOptInput()).generate();
854+
files.forEach(File::deleteOnExit);
855+
856+
Path api = Paths.get(output.getAbsolutePath(), "openapi_client", "api", "default_api.py");
857+
858+
// a model is serialized first (wire names), then exploded; a oneOf holding a primitive stays one parameter, one holding a list repeats the name
859+
TestUtils.assertFileContains(api,
860+
"_query_params.extend(self.api_client.explode_query_object('refFilter', ref_filter))",
861+
"_query_params.extend(self.api_client.explode_query_object('inlineFilter', inline_filter))",
862+
"_query_params.extend(self.api_client.explode_query_object('oneOfFilter', one_of_filter))");
863+
TestUtils.assertFileNotContains(api, "_query_params.append(('oneOfFilter', one_of_filter))");
864+
865+
// deepObject and form without explode both keep a single parameter
866+
TestUtils.assertFileContains(api,
867+
"_query_params.append(('deepFilter', deep_filter))",
868+
"_query_params.append(('flatFilter', flat_filter))");
869+
TestUtils.assertFileNotContains(api,
870+
"explode_query_object('deepFilter', deep_filter)",
871+
"explode_query_object('flatFilter', flat_filter)");
872+
}
873+
806874
@Test(description = "Verify default license format uses object notation when poetry1 is false")
807875
public void testLicenseFormatInPyprojectToml() throws IOException {
808876
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
openapi: 3.0.3
2+
info:
3+
title: Exploded model query parameters
4+
description: >
5+
Query parameters whose schema is an object with declared properties, so the python
6+
generator turns them into models rather than dicts. With the form/true defaults a model
7+
must be exploded like a map, keyed by the names its properties carry on the wire.
8+
version: 1.0.0
9+
servers:
10+
- url: localhost:8080
11+
paths:
12+
/items:
13+
get:
14+
operationId: listItems
15+
parameters:
16+
# a $ref to a component schema, with style and explode left at their defaults
17+
- in: query
18+
name: refFilter
19+
schema:
20+
$ref: '#/components/schemas/Filter'
21+
# an inline object with declared properties, which becomes a model of its own
22+
- in: query
23+
name: inlineFilter
24+
schema:
25+
type: object
26+
properties:
27+
category:
28+
type: string
29+
# a oneOf model may hold a primitive or a list, neither of which has properties to explode
30+
- in: query
31+
name: oneOfFilter
32+
schema:
33+
$ref: '#/components/schemas/FilterOrTerm'
34+
# deepObject and form without explode both keep a single parameter
35+
- in: query
36+
name: deepFilter
37+
style: deepObject
38+
explode: true
39+
schema:
40+
$ref: '#/components/schemas/Filter'
41+
- in: query
42+
name: flatFilter
43+
style: form
44+
explode: false
45+
schema:
46+
$ref: '#/components/schemas/Filter'
47+
responses:
48+
'200':
49+
description: a list of items
50+
components:
51+
schemas:
52+
Filter:
53+
type: object
54+
properties:
55+
category:
56+
type: string
57+
# the wire name is not a python identifier, so the model attribute is renamed
58+
createdDate:gte:
59+
type: string
60+
FilterOrTerm:
61+
oneOf:
62+
- $ref: '#/components/schemas/Filter'
63+
- type: string
64+
- type: array
65+
items:
66+
type: string

‎samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api/query_api.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2204,7 +2204,7 @@ def _test_query_style_form_explode_true_array_string_serialize(
22042204
# process the query parameters
22052205
if query_object is not None:
22062206

2207-
_query_params.append(('query_object', query_object))
2207+
_query_params.extend(self.api_client.explode_query_object('query_object', query_object))
22082208

22092209
# process the header parameters
22102210
# process the form parameters
@@ -2466,7 +2466,7 @@ def _test_query_style_form_explode_true_object_serialize(
24662466
# process the query parameters
24672467
if query_object is not None:
24682468

2469-
_query_params.append(('query_object', query_object))
2469+
_query_params.extend(self.api_client.explode_query_object('query_object', query_object))
24702470

24712471
# process the header parameters
24722472
# process the form parameters
@@ -2728,7 +2728,7 @@ def _test_query_style_form_explode_true_object_all_of_serialize(
27282728
# process the query parameters
27292729
if query_object is not None:
27302730

2731-
_query_params.append(('query_object', query_object))
2731+
_query_params.extend(self.api_client.explode_query_object('query_object', query_object))
27322732

27332733
# process the header parameters
27342734
# process the form parameters

‎samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api_client.py‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,13 @@ def parameters_to_tuples(self, params, collection_formats):
520520
new_params.append((k, v))
521521
return new_params
522522

523+
def explode_query_object(self, name, obj):
524+
"""form style, explode: one query parameter per entry, keyed by the property name; a list repeats the name, None is left out"""
525+
obj = self.sanitize_for_serialization(obj)
526+
if not isinstance(obj, dict):
527+
obj = {name: obj}
528+
return [(k, item) for k, v in obj.items() for item in (v if isinstance(v, (list, tuple)) else [v]) if item is not None]
529+
523530
def parameters_to_url_query(self, params, collection_formats):
524531
"""Get parameters as list of tuples, formatting collections.
525532
@@ -538,11 +545,12 @@ def parameters_to_url_query(self, params, collection_formats):
538545
if isinstance(v, dict):
539546
v = json.dumps(v)
540547

541-
if k in collection_formats:
548+
# a collection format applies only to a list; an exploded entry may share a declared array parameter's name
549+
if k in collection_formats and isinstance(v, (list, tuple)):
542550
collection_format = collection_formats[k]
543551
if collection_format == 'multi':
544552
new_params.extend(
545-
(k, quote(str(value).lower() if isinstance(value, bool) else str(value)))
553+
(quote(str(k)), quote(str(value).lower() if isinstance(value, bool) else str(value)))
546554
for value in v
547555
)
548556
else:
@@ -555,12 +563,13 @@ def parameters_to_url_query(self, params, collection_formats):
555563
else: # csv is the default
556564
delimiter = ','
557565
new_params.append(
558-
(k, delimiter.join(
566+
(quote(str(k)), delimiter.join(
559567
quote(str(value).lower() if isinstance(value, bool) else str(value))
560568
for value in v))
561569
)
562570
else:
563-
new_params.append((k, quote(str(v))))
571+
# names are quoted too: an exploded object's names are runtime data
572+
new_params.append((quote(str(k)), quote(str(v))))
564573

565574
return "&".join(["=".join(map(str, item)) for item in new_params])
566575

‎samples/client/echo_api/python/openapi_client/api/query_api.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2204,7 +2204,7 @@ def _test_query_style_form_explode_true_array_string_serialize(
22042204
# process the query parameters
22052205
if query_object is not None:
22062206

2207-
_query_params.append(('query_object', query_object))
2207+
_query_params.extend(self.api_client.explode_query_object('query_object', query_object))
22082208

22092209
# process the header parameters
22102210
# process the form parameters
@@ -2466,7 +2466,7 @@ def _test_query_style_form_explode_true_object_serialize(
24662466
# process the query parameters
24672467
if query_object is not None:
24682468

2469-
_query_params.append(('query_object', query_object))
2469+
_query_params.extend(self.api_client.explode_query_object('query_object', query_object))
24702470

24712471
# process the header parameters
24722472
# process the form parameters
@@ -2728,7 +2728,7 @@ def _test_query_style_form_explode_true_object_all_of_serialize(
27282728
# process the query parameters
27292729
if query_object is not None:
27302730

2731-
_query_params.append(('query_object', query_object))
2731+
_query_params.extend(self.api_client.explode_query_object('query_object', query_object))
27322732

27332733
# process the header parameters
27342734
# process the form parameters

‎samples/client/echo_api/python/openapi_client/api_client.py‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,13 @@ def parameters_to_tuples(self, params, collection_formats):
520520
new_params.append((k, v))
521521
return new_params
522522

523+
def explode_query_object(self, name, obj):
524+
"""form style, explode: one query parameter per entry, keyed by the property name; a list repeats the name, None is left out"""
525+
obj = self.sanitize_for_serialization(obj)
526+
if not isinstance(obj, dict):
527+
obj = {name: obj}
528+
return [(k, item) for k, v in obj.items() for item in (v if isinstance(v, (list, tuple)) else [v]) if item is not None]
529+
523530
def parameters_to_url_query(self, params, collection_formats):
524531
"""Get parameters as list of tuples, formatting collections.
525532
@@ -538,11 +545,12 @@ def parameters_to_url_query(self, params, collection_formats):
538545
if isinstance(v, dict):
539546
v = json.dumps(v)
540547

541-
if k in collection_formats:
548+
# a collection format applies only to a list; an exploded entry may share a declared array parameter's name
549+
if k in collection_formats and isinstance(v, (list, tuple)):
542550
collection_format = collection_formats[k]
543551
if collection_format == 'multi':
544552
new_params.extend(
545-
(k, quote(str(value).lower() if isinstance(value, bool) else str(value)))
553+
(quote(str(k)), quote(str(value).lower() if isinstance(value, bool) else str(value)))
546554
for value in v
547555
)
548556
else:
@@ -555,12 +563,13 @@ def parameters_to_url_query(self, params, collection_formats):
555563
else: # csv is the default
556564
delimiter = ','
557565
new_params.append(
558-
(k, delimiter.join(
566+
(quote(str(k)), delimiter.join(
559567
quote(str(value).lower() if isinstance(value, bool) else str(value))
560568
for value in v))
561569
)
562570
else:
563-
new_params.append((k, quote(str(v))))
571+
# names are quoted too: an exploded object's names are runtime data
572+
new_params.append((quote(str(k)), quote(str(v))))
564573

565574
return "&".join(["=".join(map(str, item)) for item in new_params])
566575

0 commit comments

Comments
 (0)