From 1956b676a086d41e07c56257b7c134b6c24f05a1 Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Fri, 28 Aug 2026 22:24:25 +0200 Subject: [PATCH 1/7] fix: [python] 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 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. --- .../src/main/resources/python/api.mustache | 18 +++++++ .../main/resources/python/api_client.mustache | 8 +-- .../python/PythonClientCodegenTest.java | 35 +++++++++++++ .../3_0/exploded-object-query-param.yaml | 51 +++++++++++++++++++ .../openapi_client/api_client.py | 8 +-- .../python/openapi_client/api_client.py | 8 +-- .../legacy_model_dict_client/api_client.py | 8 +-- .../petstore_api/api/fake_api.py | 5 +- .../python-aiohttp/petstore_api/api_client.py | 8 +-- .../petstore_api/api/fake_api.py | 5 +- .../petstore_api/api_client.py | 8 +-- .../python-httpx/petstore_api/api/fake_api.py | 5 +- .../python-httpx/petstore_api/api_client.py | 8 +-- .../petstore_api/api/fake_api.py | 5 +- .../petstore_api/api_client.py | 8 +-- .../python/petstore_api/api/fake_api.py | 5 +- .../python/petstore_api/api_client.py | 8 +-- 17 files changed, 169 insertions(+), 32 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/python/api.mustache b/modules/openapi-generator/src/main/resources/python/api.mustache index 514be3b69a85..cbadc98714d7 100644 --- a/modules/openapi-generator/src/main/resources/python/api.mustache +++ b/modules/openapi-generator/src/main/resources/python/api.mustache @@ -347,7 +347,25 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb _query_params.append(('{{baseName}}', {{paramName}})) {{/isDate}} {{^isDateTime}}{{^isDate}} + {{#isMap}} + {{#isExplode}} + {{#isDeepObject}} + _query_params.append(('{{baseName}}', {{paramName}})) + {{/isDeepObject}} + {{^isDeepObject}} + # form style explodes an object into one parameter per entry, keyed by the + # property name alone + for _key, _value in {{paramName}}.items(): + _query_params.append((_key, _value)) + {{/isDeepObject}} + {{/isExplode}} + {{^isExplode}} + _query_params.append(('{{baseName}}', {{paramName}})) + {{/isExplode}} + {{/isMap}} + {{^isMap}} _query_params.append(('{{baseName}}', {{paramName}}{{#isEnumRef}}.value{{/isEnumRef}})) + {{/isMap}} {{/isDate}}{{/isDateTime}} {{/queryParams}} # process the header parameters diff --git a/modules/openapi-generator/src/main/resources/python/api_client.mustache b/modules/openapi-generator/src/main/resources/python/api_client.mustache index 874d9cd3efef..924cd083455e 100644 --- a/modules/openapi-generator/src/main/resources/python/api_client.mustache +++ b/modules/openapi-generator/src/main/resources/python/api_client.mustache @@ -693,7 +693,7 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb collection_format = collection_formats[k] if collection_format == 'multi': new_params.extend( - (k, quote(str(value).lower() if isinstance(value, bool) else str(value))) + (quote(str(k)), quote(str(value).lower() if isinstance(value, bool) else str(value))) for value in v ) else: @@ -706,12 +706,14 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb else: # csv is the default delimiter = ',' new_params.append( - (k, delimiter.join( + (quote(str(k)), delimiter.join( quote(str(value).lower() if isinstance(value, bool) else str(value)) for value in v)) ) else: - new_params.append((k, quote(str(v)))) + # the name is quoted as well as the value: an exploded object query + # parameter takes its names from the object, so they are runtime data + new_params.append((quote(str(k)), quote(str(v)))) return "&".join(["=".join(map(str, item)) for item in new_params]) diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java index 9787717eb07f..527542f545b5 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java @@ -724,6 +724,41 @@ public void testInitFileImportsExportsWithCustomApiPackage() throws IOException assertFileContains(apiInitFile.toPath(), "from my_pkg.my_api.pet_api import PetApi"); } + @Test(description = "Verify an object query parameter is exploded, whether or not it declares its properties") + public void testExplodedObjectQueryParameter() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("python") + .setInputSpec("src/test/resources/3_0/exploded-object-query-param.yaml") + .setOutputDir(output.getAbsolutePath()); + + DefaultGenerator generator = new DefaultGenerator(); + List files = generator.opts(configurator.toClientOptInput()).generate(); + files.forEach(File::deleteOnExit); + + Path api = Paths.get(output.getAbsolutePath(), "openapi_client", "api", "default_api.py"); + + // form style with explode - the default - puts every entry on the wire under its own + // property name. Handing the whole dict to _query_params instead leaves ApiClient to + // json encode it, which is what used to happen. + TestUtils.assertFileContains(api, + "for _key, _value in filter.items():", + "_query_params.append((_key, _value))"); + TestUtils.assertFileNotContains(api, "_query_params.append(('filter', filter))"); + + // a declared map behaves the same way + TestUtils.assertFileContains(api, "for _key, _value in typed_filter.items():"); + + // deepObject and form without explode both keep a single parameter + TestUtils.assertFileContains(api, + "_query_params.append(('deepFilter', deep_filter))", + "_query_params.append(('flatFilter', flat_filter))"); + TestUtils.assertFileNotContains(api, "for _key, _value in deep_filter.items():"); + TestUtils.assertFileNotContains(api, "for _key, _value in flat_filter.items():"); + } + @Test(description = "Verify default license format uses object notation when poetry1 is false") public void testLicenseFormatInPyprojectToml() throws IOException { File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); 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/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api_client.py b/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api_client.py index 17c439964cee..55b38e0c3b11 100644 --- a/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api_client.py +++ b/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api_client.py @@ -542,7 +542,7 @@ def parameters_to_url_query(self, params, collection_formats): collection_format = collection_formats[k] if collection_format == 'multi': new_params.extend( - (k, quote(str(value).lower() if isinstance(value, bool) else str(value))) + (quote(str(k)), quote(str(value).lower() if isinstance(value, bool) else str(value))) for value in v ) else: @@ -555,12 +555,14 @@ def parameters_to_url_query(self, params, collection_formats): else: # csv is the default delimiter = ',' new_params.append( - (k, delimiter.join( + (quote(str(k)), delimiter.join( quote(str(value).lower() if isinstance(value, bool) else str(value)) for value in v)) ) else: - new_params.append((k, quote(str(v)))) + # the name is quoted as well as the value: an exploded object query + # parameter takes its names from the object, so they are runtime data + new_params.append((quote(str(k)), quote(str(v)))) return "&".join(["=".join(map(str, item)) for item in new_params]) diff --git a/samples/client/echo_api/python/openapi_client/api_client.py b/samples/client/echo_api/python/openapi_client/api_client.py index 17c439964cee..55b38e0c3b11 100644 --- a/samples/client/echo_api/python/openapi_client/api_client.py +++ b/samples/client/echo_api/python/openapi_client/api_client.py @@ -542,7 +542,7 @@ def parameters_to_url_query(self, params, collection_formats): collection_format = collection_formats[k] if collection_format == 'multi': new_params.extend( - (k, quote(str(value).lower() if isinstance(value, bool) else str(value))) + (quote(str(k)), quote(str(value).lower() if isinstance(value, bool) else str(value))) for value in v ) else: @@ -555,12 +555,14 @@ def parameters_to_url_query(self, params, collection_formats): else: # csv is the default delimiter = ',' new_params.append( - (k, delimiter.join( + (quote(str(k)), delimiter.join( quote(str(value).lower() if isinstance(value, bool) else str(value)) for value in v)) ) else: - new_params.append((k, quote(str(v)))) + # the name is quoted as well as the value: an exploded object query + # parameter takes its names from the object, so they are runtime data + new_params.append((quote(str(k)), quote(str(v)))) return "&".join(["=".join(map(str, item)) for item in new_params]) diff --git a/samples/client/others/python-legacy-model-dictionaries/legacy_model_dict_client/api_client.py b/samples/client/others/python-legacy-model-dictionaries/legacy_model_dict_client/api_client.py index baab728d4dd5..fa77f5f82de9 100644 --- a/samples/client/others/python-legacy-model-dictionaries/legacy_model_dict_client/api_client.py +++ b/samples/client/others/python-legacy-model-dictionaries/legacy_model_dict_client/api_client.py @@ -650,7 +650,7 @@ def parameters_to_url_query(self, params, collection_formats): collection_format = collection_formats[k] if collection_format == 'multi': new_params.extend( - (k, quote(str(value).lower() if isinstance(value, bool) else str(value))) + (quote(str(k)), quote(str(value).lower() if isinstance(value, bool) else str(value))) for value in v ) else: @@ -663,12 +663,14 @@ def parameters_to_url_query(self, params, collection_formats): else: # csv is the default delimiter = ',' new_params.append( - (k, delimiter.join( + (quote(str(k)), delimiter.join( quote(str(value).lower() if isinstance(value, bool) else str(value)) for value in v)) ) else: - new_params.append((k, quote(str(v)))) + # the name is quoted as well as the value: an exploded object query + # parameter takes its names from the object, so they are runtime data + new_params.append((quote(str(k)), quote(str(v)))) return "&".join(["=".join(map(str, item)) for item in new_params]) diff --git a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py index 3cc84bcc7af2..51500369f1a0 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py @@ -9534,7 +9534,10 @@ def _test_query_parameter_collection_format_serialize( if language is not None: - _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)) if allow_empty is not None: diff --git a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api_client.py index 49b32fc8edd6..7be69d7ad16b 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api_client.py +++ b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api_client.py @@ -541,7 +541,7 @@ def parameters_to_url_query(self, params, collection_formats): collection_format = collection_formats[k] if collection_format == 'multi': new_params.extend( - (k, quote(str(value).lower() if isinstance(value, bool) else str(value))) + (quote(str(k)), quote(str(value).lower() if isinstance(value, bool) else str(value))) for value in v ) else: @@ -554,12 +554,14 @@ def parameters_to_url_query(self, params, collection_formats): else: # csv is the default delimiter = ',' new_params.append( - (k, delimiter.join( + (quote(str(k)), delimiter.join( quote(str(value).lower() if isinstance(value, bool) else str(value)) for value in v)) ) else: - new_params.append((k, quote(str(v)))) + # the name is quoted as well as the value: an exploded object query + # parameter takes its names from the object, so they are runtime data + new_params.append((quote(str(k)), quote(str(v)))) return "&".join(["=".join(map(str, item)) for item in new_params]) diff --git a/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api/fake_api.py index fda246402ac5..8fc4410aa08c 100644 --- a/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api/fake_api.py @@ -13194,7 +13194,10 @@ def _test_query_parameter_collection_format_serialize( if language is not None: - _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)) if allow_empty is not None: diff --git a/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api_client.py index b7eb3a9170cb..031a2ce0b3df 100644 --- a/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api_client.py +++ b/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api_client.py @@ -544,7 +544,7 @@ def parameters_to_url_query(self, params, collection_formats): collection_format = collection_formats[k] if collection_format == 'multi': new_params.extend( - (k, quote(str(value).lower() if isinstance(value, bool) else str(value))) + (quote(str(k)), quote(str(value).lower() if isinstance(value, bool) else str(value))) for value in v ) else: @@ -557,12 +557,14 @@ def parameters_to_url_query(self, params, collection_formats): else: # csv is the default delimiter = ',' new_params.append( - (k, delimiter.join( + (quote(str(k)), delimiter.join( quote(str(value).lower() if isinstance(value, bool) else str(value)) for value in v)) ) else: - new_params.append((k, quote(str(v)))) + # the name is quoted as well as the value: an exploded object query + # parameter takes its names from the object, so they are runtime data + new_params.append((quote(str(k)), quote(str(v)))) return "&".join(["=".join(map(str, item)) for item in new_params]) diff --git a/samples/openapi3/client/petstore/python-httpx/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-httpx/petstore_api/api/fake_api.py index 9e895f7806ee..8a2bb640df01 100644 --- a/samples/openapi3/client/petstore/python-httpx/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-httpx/petstore_api/api/fake_api.py @@ -9515,7 +9515,10 @@ def _test_query_parameter_collection_format_serialize( if language is not None: - _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)) if allow_empty is not None: diff --git a/samples/openapi3/client/petstore/python-httpx/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-httpx/petstore_api/api_client.py index b7eb3a9170cb..031a2ce0b3df 100644 --- a/samples/openapi3/client/petstore/python-httpx/petstore_api/api_client.py +++ b/samples/openapi3/client/petstore/python-httpx/petstore_api/api_client.py @@ -544,7 +544,7 @@ def parameters_to_url_query(self, params, collection_formats): collection_format = collection_formats[k] if collection_format == 'multi': new_params.extend( - (k, quote(str(value).lower() if isinstance(value, bool) else str(value))) + (quote(str(k)), quote(str(value).lower() if isinstance(value, bool) else str(value))) for value in v ) else: @@ -557,12 +557,14 @@ def parameters_to_url_query(self, params, collection_formats): else: # csv is the default delimiter = ',' new_params.append( - (k, delimiter.join( + (quote(str(k)), delimiter.join( quote(str(value).lower() if isinstance(value, bool) else str(value)) for value in v)) ) else: - new_params.append((k, quote(str(v)))) + # the name is quoted as well as the value: an exploded object query + # parameter takes its names from the object, so they are runtime data + new_params.append((quote(str(k)), quote(str(v)))) return "&".join(["=".join(map(str, item)) for item in new_params]) diff --git a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_api.py index da7188dcf14f..e20d5f3f9bb8 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_api.py @@ -9535,7 +9535,10 @@ def _test_query_parameter_collection_format_serialize( if language is not None: - _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)) if allow_empty is not None: diff --git a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api_client.py index 8dcae18708cf..85638966643f 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api_client.py +++ b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api_client.py @@ -541,7 +541,7 @@ def parameters_to_url_query(self, params, collection_formats): collection_format = collection_formats[k] if collection_format == 'multi': new_params.extend( - (k, quote(str(value).lower() if isinstance(value, bool) else str(value))) + (quote(str(k)), quote(str(value).lower() if isinstance(value, bool) else str(value))) for value in v ) else: @@ -554,12 +554,14 @@ def parameters_to_url_query(self, params, collection_formats): else: # csv is the default delimiter = ',' new_params.append( - (k, delimiter.join( + (quote(str(k)), delimiter.join( quote(str(value).lower() if isinstance(value, bool) else str(value)) for value in v)) ) else: - new_params.append((k, quote(str(v)))) + # the name is quoted as well as the value: an exploded object query + # parameter takes its names from the object, so they are runtime data + new_params.append((quote(str(k)), quote(str(v)))) return "&".join(["=".join(map(str, item)) for item in new_params]) diff --git a/samples/openapi3/client/petstore/python/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python/petstore_api/api/fake_api.py index 2373d1680c58..ad2fb83085bb 100755 --- a/samples/openapi3/client/petstore/python/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python/petstore_api/api/fake_api.py @@ -9515,7 +9515,10 @@ def _test_query_parameter_collection_format_serialize( if language is not None: - _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)) if allow_empty is not None: diff --git a/samples/openapi3/client/petstore/python/petstore_api/api_client.py b/samples/openapi3/client/petstore/python/petstore_api/api_client.py index 899983e443d6..7cacdb18f25d 100755 --- a/samples/openapi3/client/petstore/python/petstore_api/api_client.py +++ b/samples/openapi3/client/petstore/python/petstore_api/api_client.py @@ -541,7 +541,7 @@ def parameters_to_url_query(self, params, collection_formats): collection_format = collection_formats[k] if collection_format == 'multi': new_params.extend( - (k, quote(str(value).lower() if isinstance(value, bool) else str(value))) + (quote(str(k)), quote(str(value).lower() if isinstance(value, bool) else str(value))) for value in v ) else: @@ -554,12 +554,14 @@ def parameters_to_url_query(self, params, collection_formats): else: # csv is the default delimiter = ',' new_params.append( - (k, delimiter.join( + (quote(str(k)), delimiter.join( quote(str(value).lower() if isinstance(value, bool) else str(value)) for value in v)) ) else: - new_params.append((k, quote(str(v)))) + # the name is quoted as well as the value: an exploded object query + # parameter takes its names from the object, so they are runtime data + new_params.append((quote(str(k)), quote(str(v)))) return "&".join(["=".join(map(str, item)) for item in new_params]) From 82c90b6d82c45c440e901d2376cc4b85b828addc Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Fri, 28 Aug 2026 22:48:57 +0200 Subject: [PATCH 2/7] fix: [python] do not apply a collection format to an exploded object entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/main/resources/python/api_client.mustache | 6 +++++- .../codegen/python/PythonClientCodegenTest.java | 8 ++++++++ .../openapi_client/api_client.py | 6 +++++- .../client/echo_api/python/openapi_client/api_client.py | 6 +++++- .../legacy_model_dict_client/api_client.py | 6 +++++- .../petstore/python-aiohttp/petstore_api/api_client.py | 6 +++++- .../petstore/python-httpx-sync/petstore_api/api_client.py | 6 +++++- .../petstore/python-httpx/petstore_api/api_client.py | 6 +++++- .../python-lazyImports/petstore_api/api_client.py | 6 +++++- .../client/petstore/python/petstore_api/api_client.py | 6 +++++- 10 files changed, 53 insertions(+), 9 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/python/api_client.mustache b/modules/openapi-generator/src/main/resources/python/api_client.mustache index 924cd083455e..1485f2877199 100644 --- a/modules/openapi-generator/src/main/resources/python/api_client.mustache +++ b/modules/openapi-generator/src/main/resources/python/api_client.mustache @@ -689,7 +689,11 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb if isinstance(v, dict): v = json.dumps(v) - if k in collection_formats: + # a collection format only applies to a parameter that actually carries a + # 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': new_params.extend( diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java index 527542f545b5..206ab2b3ee7b 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java @@ -757,6 +757,14 @@ public void testExplodedObjectQueryParameter() throws IOException { "_query_params.append(('flatFilter', flat_filter))"); TestUtils.assertFileNotContains(api, "for _key, _value in deep_filter.items():"); TestUtils.assertFileNotContains(api, "for _key, _value in flat_filter.items():"); + + // An exploded object takes its names from the object, so a property name can collide + // with a sibling array parameter's name. The collection format must only be applied + // 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)):"); } @Test(description = "Verify default license format uses object notation when poetry1 is false") diff --git a/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api_client.py b/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api_client.py index 55b38e0c3b11..ba21734be87a 100644 --- a/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api_client.py +++ b/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api_client.py @@ -538,7 +538,11 @@ def parameters_to_url_query(self, params, collection_formats): if isinstance(v, dict): v = json.dumps(v) - if k in collection_formats: + # a collection format only applies to a parameter that actually carries a + # 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': new_params.extend( diff --git a/samples/client/echo_api/python/openapi_client/api_client.py b/samples/client/echo_api/python/openapi_client/api_client.py index 55b38e0c3b11..ba21734be87a 100644 --- a/samples/client/echo_api/python/openapi_client/api_client.py +++ b/samples/client/echo_api/python/openapi_client/api_client.py @@ -538,7 +538,11 @@ def parameters_to_url_query(self, params, collection_formats): if isinstance(v, dict): v = json.dumps(v) - if k in collection_formats: + # a collection format only applies to a parameter that actually carries a + # 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': new_params.extend( diff --git a/samples/client/others/python-legacy-model-dictionaries/legacy_model_dict_client/api_client.py b/samples/client/others/python-legacy-model-dictionaries/legacy_model_dict_client/api_client.py index fa77f5f82de9..5d52175e9702 100644 --- a/samples/client/others/python-legacy-model-dictionaries/legacy_model_dict_client/api_client.py +++ b/samples/client/others/python-legacy-model-dictionaries/legacy_model_dict_client/api_client.py @@ -646,7 +646,11 @@ def parameters_to_url_query(self, params, collection_formats): if isinstance(v, dict): v = json.dumps(v) - if k in collection_formats: + # a collection format only applies to a parameter that actually carries a + # 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': new_params.extend( diff --git a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api_client.py index 7be69d7ad16b..4cb0af34c521 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api_client.py +++ b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api_client.py @@ -537,7 +537,11 @@ def parameters_to_url_query(self, params, collection_formats): if isinstance(v, dict): v = json.dumps(v) - if k in collection_formats: + # a collection format only applies to a parameter that actually carries a + # 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': new_params.extend( diff --git a/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api_client.py index 031a2ce0b3df..9b947dfef3e7 100644 --- a/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api_client.py +++ b/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api_client.py @@ -540,7 +540,11 @@ def parameters_to_url_query(self, params, collection_formats): if isinstance(v, dict): v = json.dumps(v) - if k in collection_formats: + # a collection format only applies to a parameter that actually carries a + # 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': new_params.extend( diff --git a/samples/openapi3/client/petstore/python-httpx/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-httpx/petstore_api/api_client.py index 031a2ce0b3df..9b947dfef3e7 100644 --- a/samples/openapi3/client/petstore/python-httpx/petstore_api/api_client.py +++ b/samples/openapi3/client/petstore/python-httpx/petstore_api/api_client.py @@ -540,7 +540,11 @@ def parameters_to_url_query(self, params, collection_formats): if isinstance(v, dict): v = json.dumps(v) - if k in collection_formats: + # a collection format only applies to a parameter that actually carries a + # 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': new_params.extend( diff --git a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api_client.py index 85638966643f..e8ecc48c1225 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api_client.py +++ b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api_client.py @@ -537,7 +537,11 @@ def parameters_to_url_query(self, params, collection_formats): if isinstance(v, dict): v = json.dumps(v) - if k in collection_formats: + # a collection format only applies to a parameter that actually carries a + # 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': new_params.extend( diff --git a/samples/openapi3/client/petstore/python/petstore_api/api_client.py b/samples/openapi3/client/petstore/python/petstore_api/api_client.py index 7cacdb18f25d..2e4af19c5683 100755 --- a/samples/openapi3/client/petstore/python/petstore_api/api_client.py +++ b/samples/openapi3/client/petstore/python/petstore_api/api_client.py @@ -537,7 +537,11 @@ def parameters_to_url_query(self, params, collection_formats): if isinstance(v, dict): v = json.dumps(v) - if k in collection_formats: + # a collection format only applies to a parameter that actually carries a + # 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': new_params.extend( From c3929d4154517a19887261d21e6028636e10ea96 Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Sun, 30 Aug 2026 19:01:43 +0200 Subject: [PATCH 3/7] 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 Claude-Session: https://claude.ai/code/session_01Go3AyndcGwv5tFTwo9aBfy --- .../petstore/python/tests/test_api_client.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/samples/openapi3/client/petstore/python/tests/test_api_client.py b/samples/openapi3/client/petstore/python/tests/test_api_client.py index cb0ed9080f51..c07181409bae 100644 --- a/samples/openapi3/client/petstore/python/tests/test_api_client.py +++ b/samples/openapi3/client/petstore/python/tests/test_api_client.py @@ -290,6 +290,22 @@ def test_parameters_to_url_query_boolean_value(self): result = self.api_client.parameters_to_url_query([('boolean', True)], {}) self.assertEqual(result, "boolean=true") + def test_parameters_to_url_query_exploded_name_does_not_collide_with_collection_format(self): + # an exploded object query parameter contributes entries under its own property + # names. A scalar entry whose name happens to match a declared array parameter + # must not be joined or repeated as if it were that parameter's list. + params = self.api_client.parameters_to_url_query( + params=[('language', 'nl'), ('context', 'abc')], + collection_formats={'context': 'multi'}) + self.assertEqual(params, "language=nl&context=abc") + + def test_parameters_to_url_query_collection_format_still_applies_to_lists(self): + # the declared array parameter itself still gets its collection format + params = self.api_client.parameters_to_url_query( + params=[('language', 'nl'), ('context', ['a', 'b'])], + collection_formats={'context': 'multi'}) + self.assertEqual(params, "language=nl&context=a&context=b") + def test_parameters_to_url_query_list_value(self): params = self.api_client.parameters_to_url_query(params=[('list', [1, 2, 3])], collection_formats={'list': 'multi'}) From 0a261c680d6a4f6c9502dc36f58b6544ac47e502 Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Mon, 21 Sep 2026 15:27:04 +0200 Subject: [PATCH 4/7] 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) --- .../src/main/resources/python/api.mustache | 33 +++++++++- .../python/PythonClientCodegenTest.java | 40 ++++++++++++ .../python/exploded-model-query-param.yaml | 63 +++++++++++++++++++ .../openapi_client/api/query_api.py | 39 +++++++++++- .../python/openapi_client/api/query_api.py | 39 +++++++++++- .../echo_api/python/tests/test_manual.py | 18 ++++++ .../petstore_api/api/fake_api.py | 7 ++- .../petstore_api/api/fake_api.py | 7 ++- .../python-httpx/petstore_api/api/fake_api.py | 7 ++- .../petstore_api/api/fake_api.py | 7 ++- .../python/petstore_api/api/fake_api.py | 7 ++- 11 files changed, 249 insertions(+), 18 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/3_0/python/exploded-model-query-param.yaml diff --git a/modules/openapi-generator/src/main/resources/python/api.mustache b/modules/openapi-generator/src/main/resources/python/api.mustache index cbadc98714d7..43987ff13955 100644 --- a/modules/openapi-generator/src/main/resources/python/api.mustache +++ b/modules/openapi-generator/src/main/resources/python/api.mustache @@ -354,17 +354,46 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb {{/isDeepObject}} {{^isDeepObject}} # form style explodes an object into one parameter per entry, keyed by the - # property name alone + # property name alone. An entry holding a list repeats that name per item. for _key, _value in {{paramName}}.items(): - _query_params.append((_key, _value)) + if isinstance(_value, (list, tuple)): + _query_params.extend((_key, _item) for _item in _value) + else: + _query_params.append((_key, _value)) {{/isDeepObject}} {{/isExplode}} {{^isExplode}} _query_params.append(('{{baseName}}', {{paramName}})) {{/isExplode}} {{/isMap}} + {{#isModel}} + {{#isExplode}} + {{#isDeepObject}} + _query_params.append(('{{baseName}}', {{paramName}})) + {{/isDeepObject}} + {{^isDeepObject}} + # form style explodes a model like a map, keyed by the names its properties + # carry on the wire. A oneOf or anyOf model holding a primitive has no + # properties to explode, so it stays a single parameter. + _serialized = self.api_client.sanitize_for_serialization({{paramName}}) + if isinstance(_serialized, dict): + for _key, _value in _serialized.items(): + if isinstance(_value, (list, tuple)): + _query_params.extend((_key, _item) for _item in _value) + else: + _query_params.append((_key, _value)) + else: + _query_params.append(('{{baseName}}', {{paramName}})) + {{/isDeepObject}} + {{/isExplode}} + {{^isExplode}} + _query_params.append(('{{baseName}}', {{paramName}})) + {{/isExplode}} + {{/isModel}} {{^isMap}} + {{^isModel}} _query_params.append(('{{baseName}}', {{paramName}}{{#isEnumRef}}.value{{/isEnumRef}})) + {{/isModel}} {{/isMap}} {{/isDate}}{{/isDateTime}} {{/queryParams}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java index 206ab2b3ee7b..6ffd815477e4 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java @@ -767,6 +767,46 @@ public void testExplodedObjectQueryParameter() throws IOException { "if k in collection_formats and isinstance(v, (list, tuple)):"); } + @Test + public void testExplodedModelQueryParameter() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("python") + .setInputSpec("src/test/resources/3_0/python/exploded-model-query-param.yaml") + .setOutputDir(output.getAbsolutePath()); + + DefaultGenerator generator = new DefaultGenerator(); + List files = generator.opts(configurator.toClientOptInput()).generate(); + files.forEach(File::deleteOnExit); + + Path api = Paths.get(output.getAbsolutePath(), "openapi_client", "api", "default_api.py"); + + // an object with declared properties is a model, not a map, and form style with explode + // - the default - must still put every property on the wire under its own name. The + // model is serialized first so the names are the wire names (createdDate:gte, not the + // python attribute), and anything that does not serialize to a dict - a oneOf holding a + // primitive - stays a single parameter. The wire format itself is pinned by the + // echo_api python sample tests (test_query_style_form_explode_true_object*). + TestUtils.assertFileContains(api, + "_serialized = self.api_client.sanitize_for_serialization(ref_filter)", + "_serialized = self.api_client.sanitize_for_serialization(inline_filter)", + "_serialized = self.api_client.sanitize_for_serialization(one_of_filter)", + "for _key, _value in _serialized.items():", + "_query_params.extend((_key, _item) for _item in _value)", + "_query_params.append((_key, _value))", + "_query_params.append(('oneOfFilter', one_of_filter))"); + + // deepObject and form without explode both keep a single parameter + TestUtils.assertFileContains(api, + "_query_params.append(('deepFilter', deep_filter))", + "_query_params.append(('flatFilter', flat_filter))"); + TestUtils.assertFileNotContains(api, + "sanitize_for_serialization(deep_filter)", + "sanitize_for_serialization(flat_filter)"); + } + @Test(description = "Verify default license format uses object notation when poetry1 is false") public void testLicenseFormatInPyprojectToml() throws IOException { File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); diff --git a/modules/openapi-generator/src/test/resources/3_0/python/exploded-model-query-param.yaml b/modules/openapi-generator/src/test/resources/3_0/python/exploded-model-query-param.yaml new file mode 100644 index 000000000000..7341f5ea7706 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/python/exploded-model-query-param.yaml @@ -0,0 +1,63 @@ +openapi: 3.0.3 +info: + title: Exploded model query parameters + description: > + Query parameters whose schema is an object with declared properties, so the python + generator turns them into models rather than dicts. With the form/true defaults a model + must be exploded like a map, keyed by the names its properties carry on the wire. + version: 1.0.0 +servers: + - url: localhost:8080 +paths: + /items: + get: + operationId: listItems + parameters: + # a $ref to a component schema, with style and explode left at their defaults + - in: query + name: refFilter + schema: + $ref: '#/components/schemas/Filter' + # an inline object with declared properties, which becomes a model of its own + - in: query + name: inlineFilter + schema: + type: object + properties: + category: + type: string + # a oneOf model may hold a primitive, which has no properties to explode + - in: query + name: oneOfFilter + schema: + $ref: '#/components/schemas/FilterOrTerm' + # deepObject and form without explode both keep a single parameter + - in: query + name: deepFilter + style: deepObject + explode: true + schema: + $ref: '#/components/schemas/Filter' + - in: query + name: flatFilter + style: form + explode: false + schema: + $ref: '#/components/schemas/Filter' + responses: + '200': + description: a list of items +components: + schemas: + Filter: + type: object + properties: + category: + type: string + # the wire name is not a python identifier, so the model attribute is renamed + createdDate:gte: + type: string + FilterOrTerm: + oneOf: + - $ref: '#/components/schemas/Filter' + - type: string diff --git a/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api/query_api.py b/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api/query_api.py index ac967a796ab2..c7f9f88242f5 100644 --- a/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api/query_api.py +++ b/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api/query_api.py @@ -2204,7 +2204,18 @@ def _test_query_style_form_explode_true_array_string_serialize( # process the query parameters if query_object is not None: - _query_params.append(('query_object', query_object)) + # form style explodes a model like a map, keyed by the names its properties + # carry on the wire. A oneOf or anyOf model holding a primitive has no + # properties to explode, so it stays a single parameter. + _serialized = self.api_client.sanitize_for_serialization(query_object) + if isinstance(_serialized, dict): + for _key, _value in _serialized.items(): + if isinstance(_value, (list, tuple)): + _query_params.extend((_key, _item) for _item in _value) + else: + _query_params.append((_key, _value)) + else: + _query_params.append(('query_object', query_object)) # process the header parameters # process the form parameters @@ -2466,7 +2477,18 @@ def _test_query_style_form_explode_true_object_serialize( # process the query parameters if query_object is not None: - _query_params.append(('query_object', query_object)) + # form style explodes a model like a map, keyed by the names its properties + # carry on the wire. A oneOf or anyOf model holding a primitive has no + # properties to explode, so it stays a single parameter. + _serialized = self.api_client.sanitize_for_serialization(query_object) + if isinstance(_serialized, dict): + for _key, _value in _serialized.items(): + if isinstance(_value, (list, tuple)): + _query_params.extend((_key, _item) for _item in _value) + else: + _query_params.append((_key, _value)) + else: + _query_params.append(('query_object', query_object)) # process the header parameters # process the form parameters @@ -2728,7 +2750,18 @@ def _test_query_style_form_explode_true_object_all_of_serialize( # process the query parameters if query_object is not None: - _query_params.append(('query_object', query_object)) + # form style explodes a model like a map, keyed by the names its properties + # carry on the wire. A oneOf or anyOf model holding a primitive has no + # properties to explode, so it stays a single parameter. + _serialized = self.api_client.sanitize_for_serialization(query_object) + if isinstance(_serialized, dict): + for _key, _value in _serialized.items(): + if isinstance(_value, (list, tuple)): + _query_params.extend((_key, _item) for _item in _value) + else: + _query_params.append((_key, _value)) + else: + _query_params.append(('query_object', query_object)) # process the header parameters # process the form parameters diff --git a/samples/client/echo_api/python/openapi_client/api/query_api.py b/samples/client/echo_api/python/openapi_client/api/query_api.py index ac967a796ab2..c7f9f88242f5 100644 --- a/samples/client/echo_api/python/openapi_client/api/query_api.py +++ b/samples/client/echo_api/python/openapi_client/api/query_api.py @@ -2204,7 +2204,18 @@ def _test_query_style_form_explode_true_array_string_serialize( # process the query parameters if query_object is not None: - _query_params.append(('query_object', query_object)) + # form style explodes a model like a map, keyed by the names its properties + # carry on the wire. A oneOf or anyOf model holding a primitive has no + # properties to explode, so it stays a single parameter. + _serialized = self.api_client.sanitize_for_serialization(query_object) + if isinstance(_serialized, dict): + for _key, _value in _serialized.items(): + if isinstance(_value, (list, tuple)): + _query_params.extend((_key, _item) for _item in _value) + else: + _query_params.append((_key, _value)) + else: + _query_params.append(('query_object', query_object)) # process the header parameters # process the form parameters @@ -2466,7 +2477,18 @@ def _test_query_style_form_explode_true_object_serialize( # process the query parameters if query_object is not None: - _query_params.append(('query_object', query_object)) + # form style explodes a model like a map, keyed by the names its properties + # carry on the wire. A oneOf or anyOf model holding a primitive has no + # properties to explode, so it stays a single parameter. + _serialized = self.api_client.sanitize_for_serialization(query_object) + if isinstance(_serialized, dict): + for _key, _value in _serialized.items(): + if isinstance(_value, (list, tuple)): + _query_params.extend((_key, _item) for _item in _value) + else: + _query_params.append((_key, _value)) + else: + _query_params.append(('query_object', query_object)) # process the header parameters # process the form parameters @@ -2728,7 +2750,18 @@ def _test_query_style_form_explode_true_object_all_of_serialize( # process the query parameters if query_object is not None: - _query_params.append(('query_object', query_object)) + # form style explodes a model like a map, keyed by the names its properties + # carry on the wire. A oneOf or anyOf model holding a primitive has no + # properties to explode, so it stays a single parameter. + _serialized = self.api_client.sanitize_for_serialization(query_object) + if isinstance(_serialized, dict): + for _key, _value in _serialized.items(): + if isinstance(_value, (list, tuple)): + _query_params.extend((_key, _item) for _item in _value) + else: + _query_params.append((_key, _value)) + else: + _query_params.append(('query_object', query_object)) # process the header parameters # process the form parameters diff --git a/samples/client/echo_api/python/tests/test_manual.py b/samples/client/echo_api/python/tests/test_manual.py index b63f5b36dcb1..657710717c10 100644 --- a/samples/client/echo_api/python/tests/test_manual.py +++ b/samples/client/echo_api/python/tests/test_manual.py @@ -92,6 +92,24 @@ def test_query_style_form_explode_false_array_string_test(self): e = EchoServerResponseParser(api_response) self.assertEqual(e.path, "/query/style_form/explode_false/array_string?query_object=Oh%2C%20hello%20world,abc,DEF") + def test_query_style_form_explode_true_object_test(self): + # form style with explode puts every property of a model on the wire under its own + # name, the name it carries on the wire (photoUrls, not photo_urls), and repeats + # that name for every item of a list + api_instance = openapi_client.QueryApi() + pet = openapi_client.Pet(id=12345, name="Hello World", photoUrls=["http://a.com", "http://b.com"], status="available") + api_response = api_instance.test_query_style_form_explode_true_object(pet) + e = EchoServerResponseParser(api_response) + self.assertEqual(e.path, "/query/style_form/explode_true/object?id=12345&name=Hello%20World&photoUrls=http%3A//a.com&photoUrls=http%3A//b.com&status=available") + + def test_query_style_form_explode_true_object_all_of_test(self): + # an allOf model is exploded the same way; the var_date attribute goes on the wire as date + api_instance = openapi_client.QueryApi() + query = openapi_client.DataQuery(id=1, outcomes=["SUCCESS", "FAILURE"], text="Some text", date=datetime.datetime(2020, 1, 2, 3, 4, 5)) + api_response = api_instance.test_query_style_form_explode_true_object_all_of(query) + e = EchoServerResponseParser(api_response) + self.assertEqual(e.path, "/query/style_form/explode_true/object/allOf?id=1&outcomes=SUCCESS&outcomes=FAILURE&text=Some%20text&date=2020-01-02T03%3A04%3A05") + def testDateTimeQueryWithDateTimeFormat(self): api_instance = openapi_client.QueryApi() datetime_format_backup = api_instance.api_client.configuration.datetime_format # backup dateime_format diff --git a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py index 51500369f1a0..22a411697faf 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py @@ -9535,9 +9535,12 @@ def _test_query_parameter_collection_format_serialize( if language is not None: # form style explodes an object into one parameter per entry, keyed by the - # property name alone + # property name alone. An entry holding a list repeats that name per item. for _key, _value in language.items(): - _query_params.append((_key, _value)) + if isinstance(_value, (list, tuple)): + _query_params.extend((_key, _item) for _item in _value) + else: + _query_params.append((_key, _value)) if allow_empty is not None: diff --git a/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api/fake_api.py index 8fc4410aa08c..5cc2208f30c4 100644 --- a/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api/fake_api.py @@ -13195,9 +13195,12 @@ def _test_query_parameter_collection_format_serialize( if language is not None: # form style explodes an object into one parameter per entry, keyed by the - # property name alone + # property name alone. An entry holding a list repeats that name per item. for _key, _value in language.items(): - _query_params.append((_key, _value)) + if isinstance(_value, (list, tuple)): + _query_params.extend((_key, _item) for _item in _value) + else: + _query_params.append((_key, _value)) if allow_empty is not None: diff --git a/samples/openapi3/client/petstore/python-httpx/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-httpx/petstore_api/api/fake_api.py index 8a2bb640df01..ed700b39f959 100644 --- a/samples/openapi3/client/petstore/python-httpx/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-httpx/petstore_api/api/fake_api.py @@ -9516,9 +9516,12 @@ def _test_query_parameter_collection_format_serialize( if language is not None: # form style explodes an object into one parameter per entry, keyed by the - # property name alone + # property name alone. An entry holding a list repeats that name per item. for _key, _value in language.items(): - _query_params.append((_key, _value)) + if isinstance(_value, (list, tuple)): + _query_params.extend((_key, _item) for _item in _value) + else: + _query_params.append((_key, _value)) if allow_empty is not None: diff --git a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_api.py index e20d5f3f9bb8..7d56eb0ced76 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_api.py @@ -9536,9 +9536,12 @@ def _test_query_parameter_collection_format_serialize( if language is not None: # form style explodes an object into one parameter per entry, keyed by the - # property name alone + # property name alone. An entry holding a list repeats that name per item. for _key, _value in language.items(): - _query_params.append((_key, _value)) + if isinstance(_value, (list, tuple)): + _query_params.extend((_key, _item) for _item in _value) + else: + _query_params.append((_key, _value)) if allow_empty is not None: diff --git a/samples/openapi3/client/petstore/python/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python/petstore_api/api/fake_api.py index ad2fb83085bb..b91bbb56ec9d 100755 --- a/samples/openapi3/client/petstore/python/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python/petstore_api/api/fake_api.py @@ -9516,9 +9516,12 @@ def _test_query_parameter_collection_format_serialize( if language is not None: # form style explodes an object into one parameter per entry, keyed by the - # property name alone + # property name alone. An entry holding a list repeats that name per item. for _key, _value in language.items(): - _query_params.append((_key, _value)) + if isinstance(_value, (list, tuple)): + _query_params.extend((_key, _item) for _item in _value) + else: + _query_params.append((_key, _value)) if allow_empty is not None: From dc592a9cd45244a55a17aa6e7a3a3e293c238fe3 Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Mon, 21 Sep 2026 20:35:54 +0200 Subject: [PATCH 5/7] 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 --- .../src/main/resources/python/api.mustache | 16 ++++++--- .../python/PythonClientCodegenTest.java | 22 ++++++++++--- .../python/exploded-model-query-param.yaml | 5 ++- .../openapi_client/api/query_api.py | 33 ++++++++++++++----- .../python/openapi_client/api/query_api.py | 33 ++++++++++++++----- .../echo_api/python/tests/test_manual.py | 10 ++++++ .../petstore_api/api/fake_api.py | 5 ++- .../petstore_api/api/fake_api.py | 5 ++- .../python-httpx/petstore_api/api/fake_api.py | 5 ++- .../petstore_api/api/fake_api.py | 5 ++- .../python/petstore_api/api/fake_api.py | 5 ++- .../petstore/python/tests/test_api_client.py | 10 ++++++ 12 files changed, 122 insertions(+), 32 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/python/api.mustache b/modules/openapi-generator/src/main/resources/python/api.mustache index 43987ff13955..b3eecfef4b9a 100644 --- a/modules/openapi-generator/src/main/resources/python/api.mustache +++ b/modules/openapi-generator/src/main/resources/python/api.mustache @@ -355,9 +355,12 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb {{^isDeepObject}} # form style explodes an object into one parameter per entry, keyed by the # property name alone. An entry holding a list repeats that name per item. + # An entry that is null contributes nothing, as does a null list item. for _key, _value in {{paramName}}.items(): + if _value is None: + continue if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value) + _query_params.extend((_key, _item) for _item in _value if _item is not None) else: _query_params.append((_key, _value)) {{/isDeepObject}} @@ -374,16 +377,21 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb {{^isDeepObject}} # form style explodes a model like a map, keyed by the names its properties # carry on the wire. A oneOf or anyOf model holding a primitive has no - # properties to explode, so it stays a single parameter. + # properties to explode, so it stays a single parameter; one holding a list + # repeats the parameter name per item. A null property or item is left out. _serialized = self.api_client.sanitize_for_serialization({{paramName}}) if isinstance(_serialized, dict): for _key, _value in _serialized.items(): + if _value is None: + continue if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value) + _query_params.extend((_key, _item) for _item in _value if _item is not None) else: _query_params.append((_key, _value)) + elif isinstance(_serialized, (list, tuple)): + _query_params.extend(('{{baseName}}', _item) for _item in _serialized if _item is not None) else: - _query_params.append(('{{baseName}}', {{paramName}})) + _query_params.append(('{{baseName}}', _serialized)) {{/isDeepObject}} {{/isExplode}} {{^isExplode}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java index 6ffd815477e4..e992f9361678 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java @@ -748,6 +748,13 @@ public void testExplodedObjectQueryParameter() throws IOException { "_query_params.append((_key, _value))"); TestUtils.assertFileNotContains(api, "_query_params.append(('filter', filter))"); + // a null entry, and a null item of an entry holding a list, contribute nothing. Without + // the guards they reach parameters_to_url_query, which quotes str(None) and puts the + // literal "None" on the wire. + TestUtils.assertFileContains(api, + "if _value is None:", + "_query_params.extend((_key, _item) for _item in _value if _item is not None)"); + // a declared map behaves the same way TestUtils.assertFileContains(api, "for _key, _value in typed_filter.items():"); @@ -787,16 +794,23 @@ public void testExplodedModelQueryParameter() throws IOException { // - the default - must still put every property on the wire under its own name. The // model is serialized first so the names are the wire names (createdDate:gte, not the // python attribute), and anything that does not serialize to a dict - a oneOf holding a - // primitive - stays a single parameter. The wire format itself is pinned by the - // echo_api python sample tests (test_query_style_form_explode_true_object*). + // primitive - stays a single parameter carrying the serialized value. A oneOf holding a + // list repeats the parameter name per item instead of sending the list's repr. The wire + // format itself is pinned by the echo_api python sample tests + // (test_query_style_form_explode_true_object*). TestUtils.assertFileContains(api, "_serialized = self.api_client.sanitize_for_serialization(ref_filter)", "_serialized = self.api_client.sanitize_for_serialization(inline_filter)", "_serialized = self.api_client.sanitize_for_serialization(one_of_filter)", "for _key, _value in _serialized.items():", - "_query_params.extend((_key, _item) for _item in _value)", + "_query_params.extend((_key, _item) for _item in _value if _item is not None)", "_query_params.append((_key, _value))", - "_query_params.append(('oneOfFilter', one_of_filter))"); + "_query_params.extend(('oneOfFilter', _item) for _item in _serialized if _item is not None)", + "_query_params.append(('oneOfFilter', _serialized))"); + TestUtils.assertFileNotContains(api, "_query_params.append(('oneOfFilter', one_of_filter))"); + + // a null property, and a null item of a property holding a list, contribute nothing + TestUtils.assertFileContains(api, "if _value is None:"); // deepObject and form without explode both keep a single parameter TestUtils.assertFileContains(api, diff --git a/modules/openapi-generator/src/test/resources/3_0/python/exploded-model-query-param.yaml b/modules/openapi-generator/src/test/resources/3_0/python/exploded-model-query-param.yaml index 7341f5ea7706..e7a68518b4a0 100644 --- a/modules/openapi-generator/src/test/resources/3_0/python/exploded-model-query-param.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/python/exploded-model-query-param.yaml @@ -26,7 +26,7 @@ paths: properties: category: type: string - # a oneOf model may hold a primitive, which has no properties to explode + # a oneOf model may hold a primitive or a list, neither of which has properties to explode - in: query name: oneOfFilter schema: @@ -61,3 +61,6 @@ components: oneOf: - $ref: '#/components/schemas/Filter' - type: string + - type: array + items: + type: string diff --git a/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api/query_api.py b/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api/query_api.py index c7f9f88242f5..473cc0425acc 100644 --- a/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api/query_api.py +++ b/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api/query_api.py @@ -2206,16 +2206,21 @@ def _test_query_style_form_explode_true_array_string_serialize( # form style explodes a model like a map, keyed by the names its properties # carry on the wire. A oneOf or anyOf model holding a primitive has no - # properties to explode, so it stays a single parameter. + # properties to explode, so it stays a single parameter; one holding a list + # repeats the parameter name per item. A null property or item is left out. _serialized = self.api_client.sanitize_for_serialization(query_object) if isinstance(_serialized, dict): for _key, _value in _serialized.items(): + if _value is None: + continue if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value) + _query_params.extend((_key, _item) for _item in _value if _item is not None) else: _query_params.append((_key, _value)) + elif isinstance(_serialized, (list, tuple)): + _query_params.extend(('query_object', _item) for _item in _serialized if _item is not None) else: - _query_params.append(('query_object', query_object)) + _query_params.append(('query_object', _serialized)) # process the header parameters # process the form parameters @@ -2479,16 +2484,21 @@ def _test_query_style_form_explode_true_object_serialize( # form style explodes a model like a map, keyed by the names its properties # carry on the wire. A oneOf or anyOf model holding a primitive has no - # properties to explode, so it stays a single parameter. + # properties to explode, so it stays a single parameter; one holding a list + # repeats the parameter name per item. A null property or item is left out. _serialized = self.api_client.sanitize_for_serialization(query_object) if isinstance(_serialized, dict): for _key, _value in _serialized.items(): + if _value is None: + continue if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value) + _query_params.extend((_key, _item) for _item in _value if _item is not None) else: _query_params.append((_key, _value)) + elif isinstance(_serialized, (list, tuple)): + _query_params.extend(('query_object', _item) for _item in _serialized if _item is not None) else: - _query_params.append(('query_object', query_object)) + _query_params.append(('query_object', _serialized)) # process the header parameters # process the form parameters @@ -2752,16 +2762,21 @@ def _test_query_style_form_explode_true_object_all_of_serialize( # form style explodes a model like a map, keyed by the names its properties # carry on the wire. A oneOf or anyOf model holding a primitive has no - # properties to explode, so it stays a single parameter. + # properties to explode, so it stays a single parameter; one holding a list + # repeats the parameter name per item. A null property or item is left out. _serialized = self.api_client.sanitize_for_serialization(query_object) if isinstance(_serialized, dict): for _key, _value in _serialized.items(): + if _value is None: + continue if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value) + _query_params.extend((_key, _item) for _item in _value if _item is not None) else: _query_params.append((_key, _value)) + elif isinstance(_serialized, (list, tuple)): + _query_params.extend(('query_object', _item) for _item in _serialized if _item is not None) else: - _query_params.append(('query_object', query_object)) + _query_params.append(('query_object', _serialized)) # process the header parameters # process the form parameters diff --git a/samples/client/echo_api/python/openapi_client/api/query_api.py b/samples/client/echo_api/python/openapi_client/api/query_api.py index c7f9f88242f5..473cc0425acc 100644 --- a/samples/client/echo_api/python/openapi_client/api/query_api.py +++ b/samples/client/echo_api/python/openapi_client/api/query_api.py @@ -2206,16 +2206,21 @@ def _test_query_style_form_explode_true_array_string_serialize( # form style explodes a model like a map, keyed by the names its properties # carry on the wire. A oneOf or anyOf model holding a primitive has no - # properties to explode, so it stays a single parameter. + # properties to explode, so it stays a single parameter; one holding a list + # repeats the parameter name per item. A null property or item is left out. _serialized = self.api_client.sanitize_for_serialization(query_object) if isinstance(_serialized, dict): for _key, _value in _serialized.items(): + if _value is None: + continue if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value) + _query_params.extend((_key, _item) for _item in _value if _item is not None) else: _query_params.append((_key, _value)) + elif isinstance(_serialized, (list, tuple)): + _query_params.extend(('query_object', _item) for _item in _serialized if _item is not None) else: - _query_params.append(('query_object', query_object)) + _query_params.append(('query_object', _serialized)) # process the header parameters # process the form parameters @@ -2479,16 +2484,21 @@ def _test_query_style_form_explode_true_object_serialize( # form style explodes a model like a map, keyed by the names its properties # carry on the wire. A oneOf or anyOf model holding a primitive has no - # properties to explode, so it stays a single parameter. + # properties to explode, so it stays a single parameter; one holding a list + # repeats the parameter name per item. A null property or item is left out. _serialized = self.api_client.sanitize_for_serialization(query_object) if isinstance(_serialized, dict): for _key, _value in _serialized.items(): + if _value is None: + continue if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value) + _query_params.extend((_key, _item) for _item in _value if _item is not None) else: _query_params.append((_key, _value)) + elif isinstance(_serialized, (list, tuple)): + _query_params.extend(('query_object', _item) for _item in _serialized if _item is not None) else: - _query_params.append(('query_object', query_object)) + _query_params.append(('query_object', _serialized)) # process the header parameters # process the form parameters @@ -2752,16 +2762,21 @@ def _test_query_style_form_explode_true_object_all_of_serialize( # form style explodes a model like a map, keyed by the names its properties # carry on the wire. A oneOf or anyOf model holding a primitive has no - # properties to explode, so it stays a single parameter. + # properties to explode, so it stays a single parameter; one holding a list + # repeats the parameter name per item. A null property or item is left out. _serialized = self.api_client.sanitize_for_serialization(query_object) if isinstance(_serialized, dict): for _key, _value in _serialized.items(): + if _value is None: + continue if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value) + _query_params.extend((_key, _item) for _item in _value if _item is not None) else: _query_params.append((_key, _value)) + elif isinstance(_serialized, (list, tuple)): + _query_params.extend(('query_object', _item) for _item in _serialized if _item is not None) else: - _query_params.append(('query_object', query_object)) + _query_params.append(('query_object', _serialized)) # process the header parameters # process the form parameters diff --git a/samples/client/echo_api/python/tests/test_manual.py b/samples/client/echo_api/python/tests/test_manual.py index 657710717c10..9a48611ae104 100644 --- a/samples/client/echo_api/python/tests/test_manual.py +++ b/samples/client/echo_api/python/tests/test_manual.py @@ -102,6 +102,16 @@ def test_query_style_form_explode_true_object_test(self): e = EchoServerResponseParser(api_response) self.assertEqual(e.path, "/query/style_form/explode_true/object?id=12345&name=Hello%20World&photoUrls=http%3A//a.com&photoUrls=http%3A//b.com&status=available") + def test_query_style_form_explode_true_object_leaves_out_unset_properties(self): + # a property that was never set contributes nothing; only the two required + # properties go on the wire. A property explicitly set to null is skipped the + # same way, so neither can reach the wire as the literal "None" + api_instance = openapi_client.QueryApi() + pet = openapi_client.Pet(name="Hello World", photoUrls=["http://a.com"]) + api_response = api_instance.test_query_style_form_explode_true_object(pet) + e = EchoServerResponseParser(api_response) + self.assertEqual(e.path, "/query/style_form/explode_true/object?name=Hello%20World&photoUrls=http%3A//a.com") + def test_query_style_form_explode_true_object_all_of_test(self): # an allOf model is exploded the same way; the var_date attribute goes on the wire as date api_instance = openapi_client.QueryApi() diff --git a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py index 22a411697faf..6d4ae576a056 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py @@ -9536,9 +9536,12 @@ def _test_query_parameter_collection_format_serialize( # form style explodes an object into one parameter per entry, keyed by the # property name alone. An entry holding a list repeats that name per item. + # An entry that is null contributes nothing, as does a null list item. for _key, _value in language.items(): + if _value is None: + continue if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value) + _query_params.extend((_key, _item) for _item in _value if _item is not None) else: _query_params.append((_key, _value)) diff --git a/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api/fake_api.py index 5cc2208f30c4..a38dfd5fc7f8 100644 --- a/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api/fake_api.py @@ -13196,9 +13196,12 @@ def _test_query_parameter_collection_format_serialize( # form style explodes an object into one parameter per entry, keyed by the # property name alone. An entry holding a list repeats that name per item. + # An entry that is null contributes nothing, as does a null list item. for _key, _value in language.items(): + if _value is None: + continue if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value) + _query_params.extend((_key, _item) for _item in _value if _item is not None) else: _query_params.append((_key, _value)) diff --git a/samples/openapi3/client/petstore/python-httpx/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-httpx/petstore_api/api/fake_api.py index ed700b39f959..ad1245912be1 100644 --- a/samples/openapi3/client/petstore/python-httpx/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-httpx/petstore_api/api/fake_api.py @@ -9517,9 +9517,12 @@ def _test_query_parameter_collection_format_serialize( # form style explodes an object into one parameter per entry, keyed by the # property name alone. An entry holding a list repeats that name per item. + # An entry that is null contributes nothing, as does a null list item. for _key, _value in language.items(): + if _value is None: + continue if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value) + _query_params.extend((_key, _item) for _item in _value if _item is not None) else: _query_params.append((_key, _value)) diff --git a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_api.py index 7d56eb0ced76..3e330dadf0ce 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_api.py @@ -9537,9 +9537,12 @@ def _test_query_parameter_collection_format_serialize( # form style explodes an object into one parameter per entry, keyed by the # property name alone. An entry holding a list repeats that name per item. + # An entry that is null contributes nothing, as does a null list item. for _key, _value in language.items(): + if _value is None: + continue if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value) + _query_params.extend((_key, _item) for _item in _value if _item is not None) else: _query_params.append((_key, _value)) diff --git a/samples/openapi3/client/petstore/python/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python/petstore_api/api/fake_api.py index b91bbb56ec9d..aae3e3b396ef 100755 --- a/samples/openapi3/client/petstore/python/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python/petstore_api/api/fake_api.py @@ -9517,9 +9517,12 @@ def _test_query_parameter_collection_format_serialize( # form style explodes an object into one parameter per entry, keyed by the # property name alone. An entry holding a list repeats that name per item. + # An entry that is null contributes nothing, as does a null list item. for _key, _value in language.items(): + if _value is None: + continue if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value) + _query_params.extend((_key, _item) for _item in _value if _item is not None) else: _query_params.append((_key, _value)) diff --git a/samples/openapi3/client/petstore/python/tests/test_api_client.py b/samples/openapi3/client/petstore/python/tests/test_api_client.py index c07181409bae..5f10962851b2 100644 --- a/samples/openapi3/client/petstore/python/tests/test_api_client.py +++ b/samples/openapi3/client/petstore/python/tests/test_api_client.py @@ -306,6 +306,16 @@ def test_parameters_to_url_query_collection_format_still_applies_to_lists(self): collection_formats={'context': 'multi'}) self.assertEqual(params, "language=nl&context=a&context=b") + def test_parameters_to_url_query_quotes_reserved_characters_in_names(self): + # an exploded object contributes entries under its own property names, which are + # whatever the spec declares and need not be url safe. The name is quoted just + # like the value, so a property called "createdDate:gte" does not read as a + # separator on the wire. + params = self.api_client.parameters_to_url_query( + params=[('createdDate:gte', '2023-01-01'), ('a&b', 'c')], + collection_formats={}) + self.assertEqual(params, "createdDate%3Agte=2023-01-01&a%26b=c") + def test_parameters_to_url_query_list_value(self): params = self.api_client.parameters_to_url_query(params=[('list', [1, 2, 3])], collection_formats={'list': 'multi'}) From 8ed9c17573b02eb305cc93eb14ed8b0a194f0855 Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Wed, 23 Sep 2026 09:07:21 +0200 Subject: [PATCH 6/7] 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) --- .../src/main/resources/python/api.mustache | 29 +--------- .../main/resources/python/api_client.mustache | 15 +++-- .../python/PythonClientCodegenTest.java | 57 +++++-------------- .../3_0/exploded-object-query-param.yaml | 4 +- .../openapi_client/api/query_api.py | 54 +----------------- .../openapi_client/api_client.py | 15 +++-- .../python/openapi_client/api/query_api.py | 54 +----------------- .../python/openapi_client/api_client.py | 15 +++-- .../echo_api/python/tests/test_manual.py | 4 +- .../legacy_model_dict_client/api_client.py | 15 +++-- .../petstore_api/api/fake_api.py | 11 +--- .../python-aiohttp/petstore_api/api_client.py | 15 +++-- .../petstore_api/api/fake_api.py | 11 +--- .../petstore_api/api_client.py | 15 +++-- .../python-httpx/petstore_api/api/fake_api.py | 11 +--- .../python-httpx/petstore_api/api_client.py | 15 +++-- .../petstore_api/api/fake_api.py | 11 +--- .../petstore_api/api_client.py | 15 +++-- .../python/petstore_api/api/fake_api.py | 11 +--- .../python/petstore_api/api_client.py | 15 +++-- .../petstore/python/tests/test_api_client.py | 15 +++++ 21 files changed, 125 insertions(+), 282 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/python/api.mustache b/modules/openapi-generator/src/main/resources/python/api.mustache index b3eecfef4b9a..3cee5c88c1b8 100644 --- a/modules/openapi-generator/src/main/resources/python/api.mustache +++ b/modules/openapi-generator/src/main/resources/python/api.mustache @@ -353,16 +353,7 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb _query_params.append(('{{baseName}}', {{paramName}})) {{/isDeepObject}} {{^isDeepObject}} - # form style explodes an object into one parameter per entry, keyed by the - # property name alone. An entry holding a list repeats that name per item. - # An entry that is null contributes nothing, as does a null list item. - for _key, _value in {{paramName}}.items(): - if _value is None: - continue - if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value if _item is not None) - else: - _query_params.append((_key, _value)) + _query_params.extend(self.api_client.explode_query_object('{{baseName}}', {{paramName}})) {{/isDeepObject}} {{/isExplode}} {{^isExplode}} @@ -375,23 +366,7 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb _query_params.append(('{{baseName}}', {{paramName}})) {{/isDeepObject}} {{^isDeepObject}} - # form style explodes a model like a map, keyed by the names its properties - # carry on the wire. A oneOf or anyOf model holding a primitive has no - # properties to explode, so it stays a single parameter; one holding a list - # repeats the parameter name per item. A null property or item is left out. - _serialized = self.api_client.sanitize_for_serialization({{paramName}}) - if isinstance(_serialized, dict): - for _key, _value in _serialized.items(): - if _value is None: - continue - if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value if _item is not None) - else: - _query_params.append((_key, _value)) - elif isinstance(_serialized, (list, tuple)): - _query_params.extend(('{{baseName}}', _item) for _item in _serialized if _item is not None) - else: - _query_params.append(('{{baseName}}', _serialized)) + _query_params.extend(self.api_client.explode_query_object('{{baseName}}', {{paramName}})) {{/isDeepObject}} {{/isExplode}} {{^isExplode}} diff --git a/modules/openapi-generator/src/main/resources/python/api_client.mustache b/modules/openapi-generator/src/main/resources/python/api_client.mustache index 1485f2877199..973a8de03b2b 100644 --- a/modules/openapi-generator/src/main/resources/python/api_client.mustache +++ b/modules/openapi-generator/src/main/resources/python/api_client.mustache @@ -671,6 +671,13 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb new_params.append((k, v)) return new_params + def explode_query_object(self, name, obj): + """form style, explode: one query parameter per entry, keyed by the property name; a list repeats the name, None is left out""" + obj = self.sanitize_for_serialization(obj) + if not isinstance(obj, dict): + obj = {name: obj} + 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] + def parameters_to_url_query(self, params, collection_formats): """Get parameters as list of tuples, formatting collections. @@ -689,10 +696,7 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb if isinstance(v, dict): v = json.dumps(v) - # a collection format only applies to a parameter that actually carries a - # 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. + # a collection format applies only to a list; an exploded entry may share a declared array parameter's name if k in collection_formats and isinstance(v, (list, tuple)): collection_format = collection_formats[k] if collection_format == 'multi': @@ -715,8 +719,7 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb for value in v)) ) else: - # the name is quoted as well as the value: an exploded object query - # parameter takes its names from the object, so they are runtime data + # names are quoted too: an exploded object's names are runtime data new_params.append((quote(str(k)), quote(str(v)))) return "&".join(["=".join(map(str, item)) for item in new_params]) diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java index e992f9361678..d8c89b4455ab 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonClientCodegenTest.java @@ -724,7 +724,7 @@ public void testInitFileImportsExportsWithCustomApiPackage() throws IOException assertFileContains(apiInitFile.toPath(), "from my_pkg.my_api.pet_api import PetApi"); } - @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 IOException { File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); output.deleteOnExit(); @@ -740,37 +740,23 @@ public void testExplodedObjectQueryParameter() throws IOException { Path api = Paths.get(output.getAbsolutePath(), "openapi_client", "api", "default_api.py"); - // form style with explode - the default - puts every entry on the wire under its own - // property name. Handing the whole dict to _query_params instead leaves ApiClient to - // json encode it, which is what used to happen. TestUtils.assertFileContains(api, - "for _key, _value in filter.items():", - "_query_params.append((_key, _value))"); + "_query_params.extend(self.api_client.explode_query_object('filter', filter))", + "_query_params.extend(self.api_client.explode_query_object('typedFilter', typed_filter))"); TestUtils.assertFileNotContains(api, "_query_params.append(('filter', filter))"); - // a null entry, and a null item of an entry holding a list, contribute nothing. Without - // the guards they reach parameters_to_url_query, which quotes str(None) and puts the - // literal "None" on the wire. - TestUtils.assertFileContains(api, - "if _value is None:", - "_query_params.extend((_key, _item) for _item in _value if _item is not None)"); - - // a declared map behaves the same way - TestUtils.assertFileContains(api, "for _key, _value in typed_filter.items():"); - // deepObject and form without explode both keep a single parameter TestUtils.assertFileContains(api, "_query_params.append(('deepFilter', deep_filter))", "_query_params.append(('flatFilter', flat_filter))"); - TestUtils.assertFileNotContains(api, "for _key, _value in deep_filter.items():"); - TestUtils.assertFileNotContains(api, "for _key, _value in flat_filter.items():"); + TestUtils.assertFileNotContains(api, + "explode_query_object('deepFilter', deep_filter)", + "explode_query_object('flatFilter', flat_filter)"); - // An exploded object takes its names from the object, so a property name can collide - // with a sibling array parameter's name. The collection format must only be applied - // 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. + // a collection format applies only to a list, so an exploded "context": "en" next to a context: multi array stays context=en Path apiClient = Paths.get(output.getAbsolutePath(), "openapi_client", "api_client.py"); TestUtils.assertFileContains(apiClient, + "def explode_query_object(self, name, obj):", "if k in collection_formats and isinstance(v, (list, tuple)):"); } @@ -790,35 +776,20 @@ public void testExplodedModelQueryParameter() throws IOException { Path api = Paths.get(output.getAbsolutePath(), "openapi_client", "api", "default_api.py"); - // an object with declared properties is a model, not a map, and form style with explode - // - the default - must still put every property on the wire under its own name. The - // model is serialized first so the names are the wire names (createdDate:gte, not the - // python attribute), and anything that does not serialize to a dict - a oneOf holding a - // primitive - stays a single parameter carrying the serialized value. A oneOf holding a - // list repeats the parameter name per item instead of sending the list's repr. The wire - // format itself is pinned by the echo_api python sample tests - // (test_query_style_form_explode_true_object*). + // a model is serialized first (wire names), then exploded; a oneOf holding a primitive stays one parameter, one holding a list repeats the name TestUtils.assertFileContains(api, - "_serialized = self.api_client.sanitize_for_serialization(ref_filter)", - "_serialized = self.api_client.sanitize_for_serialization(inline_filter)", - "_serialized = self.api_client.sanitize_for_serialization(one_of_filter)", - "for _key, _value in _serialized.items():", - "_query_params.extend((_key, _item) for _item in _value if _item is not None)", - "_query_params.append((_key, _value))", - "_query_params.extend(('oneOfFilter', _item) for _item in _serialized if _item is not None)", - "_query_params.append(('oneOfFilter', _serialized))"); + "_query_params.extend(self.api_client.explode_query_object('refFilter', ref_filter))", + "_query_params.extend(self.api_client.explode_query_object('inlineFilter', inline_filter))", + "_query_params.extend(self.api_client.explode_query_object('oneOfFilter', one_of_filter))"); TestUtils.assertFileNotContains(api, "_query_params.append(('oneOfFilter', one_of_filter))"); - // a null property, and a null item of a property holding a list, contribute nothing - TestUtils.assertFileContains(api, "if _value is None:"); - // deepObject and form without explode both keep a single parameter TestUtils.assertFileContains(api, "_query_params.append(('deepFilter', deep_filter))", "_query_params.append(('flatFilter', flat_filter))"); TestUtils.assertFileNotContains(api, - "sanitize_for_serialization(deep_filter)", - "sanitize_for_serialization(flat_filter)"); + "explode_query_object('deepFilter', deep_filter)", + "explode_query_object('flatFilter', flat_filter)"); } @Test(description = "Verify default license format uses object notation when poetry1 is false") 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..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, 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/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api/query_api.py b/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api/query_api.py index 473cc0425acc..05837d93cbee 100644 --- a/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api/query_api.py +++ b/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api/query_api.py @@ -2204,23 +2204,7 @@ def _test_query_style_form_explode_true_array_string_serialize( # process the query parameters if query_object is not None: - # form style explodes a model like a map, keyed by the names its properties - # carry on the wire. A oneOf or anyOf model holding a primitive has no - # properties to explode, so it stays a single parameter; one holding a list - # repeats the parameter name per item. A null property or item is left out. - _serialized = self.api_client.sanitize_for_serialization(query_object) - if isinstance(_serialized, dict): - for _key, _value in _serialized.items(): - if _value is None: - continue - if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value if _item is not None) - else: - _query_params.append((_key, _value)) - elif isinstance(_serialized, (list, tuple)): - _query_params.extend(('query_object', _item) for _item in _serialized if _item is not None) - else: - _query_params.append(('query_object', _serialized)) + _query_params.extend(self.api_client.explode_query_object('query_object', query_object)) # process the header parameters # process the form parameters @@ -2482,23 +2466,7 @@ def _test_query_style_form_explode_true_object_serialize( # process the query parameters if query_object is not None: - # form style explodes a model like a map, keyed by the names its properties - # carry on the wire. A oneOf or anyOf model holding a primitive has no - # properties to explode, so it stays a single parameter; one holding a list - # repeats the parameter name per item. A null property or item is left out. - _serialized = self.api_client.sanitize_for_serialization(query_object) - if isinstance(_serialized, dict): - for _key, _value in _serialized.items(): - if _value is None: - continue - if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value if _item is not None) - else: - _query_params.append((_key, _value)) - elif isinstance(_serialized, (list, tuple)): - _query_params.extend(('query_object', _item) for _item in _serialized if _item is not None) - else: - _query_params.append(('query_object', _serialized)) + _query_params.extend(self.api_client.explode_query_object('query_object', query_object)) # process the header parameters # process the form parameters @@ -2760,23 +2728,7 @@ def _test_query_style_form_explode_true_object_all_of_serialize( # process the query parameters if query_object is not None: - # form style explodes a model like a map, keyed by the names its properties - # carry on the wire. A oneOf or anyOf model holding a primitive has no - # properties to explode, so it stays a single parameter; one holding a list - # repeats the parameter name per item. A null property or item is left out. - _serialized = self.api_client.sanitize_for_serialization(query_object) - if isinstance(_serialized, dict): - for _key, _value in _serialized.items(): - if _value is None: - continue - if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value if _item is not None) - else: - _query_params.append((_key, _value)) - elif isinstance(_serialized, (list, tuple)): - _query_params.extend(('query_object', _item) for _item in _serialized if _item is not None) - else: - _query_params.append(('query_object', _serialized)) + _query_params.extend(self.api_client.explode_query_object('query_object', query_object)) # process the header parameters # process the form parameters diff --git a/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api_client.py b/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api_client.py index ba21734be87a..77721a58cb85 100644 --- a/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api_client.py +++ b/samples/client/echo_api/python-disallowAdditionalPropertiesIfNotPresent/openapi_client/api_client.py @@ -520,6 +520,13 @@ def parameters_to_tuples(self, params, collection_formats): new_params.append((k, v)) return new_params + def explode_query_object(self, name, obj): + """form style, explode: one query parameter per entry, keyed by the property name; a list repeats the name, None is left out""" + obj = self.sanitize_for_serialization(obj) + if not isinstance(obj, dict): + obj = {name: obj} + 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] + def parameters_to_url_query(self, params, collection_formats): """Get parameters as list of tuples, formatting collections. @@ -538,10 +545,7 @@ def parameters_to_url_query(self, params, collection_formats): if isinstance(v, dict): v = json.dumps(v) - # a collection format only applies to a parameter that actually carries a - # 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. + # a collection format applies only to a list; an exploded entry may share a declared array parameter's name if k in collection_formats and isinstance(v, (list, tuple)): collection_format = collection_formats[k] if collection_format == 'multi': @@ -564,8 +568,7 @@ def parameters_to_url_query(self, params, collection_formats): for value in v)) ) else: - # the name is quoted as well as the value: an exploded object query - # parameter takes its names from the object, so they are runtime data + # names are quoted too: an exploded object's names are runtime data new_params.append((quote(str(k)), quote(str(v)))) return "&".join(["=".join(map(str, item)) for item in new_params]) diff --git a/samples/client/echo_api/python/openapi_client/api/query_api.py b/samples/client/echo_api/python/openapi_client/api/query_api.py index 473cc0425acc..05837d93cbee 100644 --- a/samples/client/echo_api/python/openapi_client/api/query_api.py +++ b/samples/client/echo_api/python/openapi_client/api/query_api.py @@ -2204,23 +2204,7 @@ def _test_query_style_form_explode_true_array_string_serialize( # process the query parameters if query_object is not None: - # form style explodes a model like a map, keyed by the names its properties - # carry on the wire. A oneOf or anyOf model holding a primitive has no - # properties to explode, so it stays a single parameter; one holding a list - # repeats the parameter name per item. A null property or item is left out. - _serialized = self.api_client.sanitize_for_serialization(query_object) - if isinstance(_serialized, dict): - for _key, _value in _serialized.items(): - if _value is None: - continue - if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value if _item is not None) - else: - _query_params.append((_key, _value)) - elif isinstance(_serialized, (list, tuple)): - _query_params.extend(('query_object', _item) for _item in _serialized if _item is not None) - else: - _query_params.append(('query_object', _serialized)) + _query_params.extend(self.api_client.explode_query_object('query_object', query_object)) # process the header parameters # process the form parameters @@ -2482,23 +2466,7 @@ def _test_query_style_form_explode_true_object_serialize( # process the query parameters if query_object is not None: - # form style explodes a model like a map, keyed by the names its properties - # carry on the wire. A oneOf or anyOf model holding a primitive has no - # properties to explode, so it stays a single parameter; one holding a list - # repeats the parameter name per item. A null property or item is left out. - _serialized = self.api_client.sanitize_for_serialization(query_object) - if isinstance(_serialized, dict): - for _key, _value in _serialized.items(): - if _value is None: - continue - if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value if _item is not None) - else: - _query_params.append((_key, _value)) - elif isinstance(_serialized, (list, tuple)): - _query_params.extend(('query_object', _item) for _item in _serialized if _item is not None) - else: - _query_params.append(('query_object', _serialized)) + _query_params.extend(self.api_client.explode_query_object('query_object', query_object)) # process the header parameters # process the form parameters @@ -2760,23 +2728,7 @@ def _test_query_style_form_explode_true_object_all_of_serialize( # process the query parameters if query_object is not None: - # form style explodes a model like a map, keyed by the names its properties - # carry on the wire. A oneOf or anyOf model holding a primitive has no - # properties to explode, so it stays a single parameter; one holding a list - # repeats the parameter name per item. A null property or item is left out. - _serialized = self.api_client.sanitize_for_serialization(query_object) - if isinstance(_serialized, dict): - for _key, _value in _serialized.items(): - if _value is None: - continue - if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value if _item is not None) - else: - _query_params.append((_key, _value)) - elif isinstance(_serialized, (list, tuple)): - _query_params.extend(('query_object', _item) for _item in _serialized if _item is not None) - else: - _query_params.append(('query_object', _serialized)) + _query_params.extend(self.api_client.explode_query_object('query_object', query_object)) # process the header parameters # process the form parameters diff --git a/samples/client/echo_api/python/openapi_client/api_client.py b/samples/client/echo_api/python/openapi_client/api_client.py index ba21734be87a..77721a58cb85 100644 --- a/samples/client/echo_api/python/openapi_client/api_client.py +++ b/samples/client/echo_api/python/openapi_client/api_client.py @@ -520,6 +520,13 @@ def parameters_to_tuples(self, params, collection_formats): new_params.append((k, v)) return new_params + def explode_query_object(self, name, obj): + """form style, explode: one query parameter per entry, keyed by the property name; a list repeats the name, None is left out""" + obj = self.sanitize_for_serialization(obj) + if not isinstance(obj, dict): + obj = {name: obj} + 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] + def parameters_to_url_query(self, params, collection_formats): """Get parameters as list of tuples, formatting collections. @@ -538,10 +545,7 @@ def parameters_to_url_query(self, params, collection_formats): if isinstance(v, dict): v = json.dumps(v) - # a collection format only applies to a parameter that actually carries a - # 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. + # a collection format applies only to a list; an exploded entry may share a declared array parameter's name if k in collection_formats and isinstance(v, (list, tuple)): collection_format = collection_formats[k] if collection_format == 'multi': @@ -564,8 +568,7 @@ def parameters_to_url_query(self, params, collection_formats): for value in v)) ) else: - # the name is quoted as well as the value: an exploded object query - # parameter takes its names from the object, so they are runtime data + # names are quoted too: an exploded object's names are runtime data new_params.append((quote(str(k)), quote(str(v)))) return "&".join(["=".join(map(str, item)) for item in new_params]) diff --git a/samples/client/echo_api/python/tests/test_manual.py b/samples/client/echo_api/python/tests/test_manual.py index 9a48611ae104..b6d2a30e7c67 100644 --- a/samples/client/echo_api/python/tests/test_manual.py +++ b/samples/client/echo_api/python/tests/test_manual.py @@ -103,9 +103,7 @@ def test_query_style_form_explode_true_object_test(self): self.assertEqual(e.path, "/query/style_form/explode_true/object?id=12345&name=Hello%20World&photoUrls=http%3A//a.com&photoUrls=http%3A//b.com&status=available") def test_query_style_form_explode_true_object_leaves_out_unset_properties(self): - # a property that was never set contributes nothing; only the two required - # properties go on the wire. A property explicitly set to null is skipped the - # same way, so neither can reach the wire as the literal "None" + # a property that was never set contributes nothing api_instance = openapi_client.QueryApi() pet = openapi_client.Pet(name="Hello World", photoUrls=["http://a.com"]) api_response = api_instance.test_query_style_form_explode_true_object(pet) diff --git a/samples/client/others/python-legacy-model-dictionaries/legacy_model_dict_client/api_client.py b/samples/client/others/python-legacy-model-dictionaries/legacy_model_dict_client/api_client.py index 5d52175e9702..dffac725656d 100644 --- a/samples/client/others/python-legacy-model-dictionaries/legacy_model_dict_client/api_client.py +++ b/samples/client/others/python-legacy-model-dictionaries/legacy_model_dict_client/api_client.py @@ -628,6 +628,13 @@ def parameters_to_tuples(self, params, collection_formats): new_params.append((k, v)) return new_params + def explode_query_object(self, name, obj): + """form style, explode: one query parameter per entry, keyed by the property name; a list repeats the name, None is left out""" + obj = self.sanitize_for_serialization(obj) + if not isinstance(obj, dict): + obj = {name: obj} + 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] + def parameters_to_url_query(self, params, collection_formats): """Get parameters as list of tuples, formatting collections. @@ -646,10 +653,7 @@ def parameters_to_url_query(self, params, collection_formats): if isinstance(v, dict): v = json.dumps(v) - # a collection format only applies to a parameter that actually carries a - # 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. + # a collection format applies only to a list; an exploded entry may share a declared array parameter's name if k in collection_formats and isinstance(v, (list, tuple)): collection_format = collection_formats[k] if collection_format == 'multi': @@ -672,8 +676,7 @@ def parameters_to_url_query(self, params, collection_formats): for value in v)) ) else: - # the name is quoted as well as the value: an exploded object query - # parameter takes its names from the object, so they are runtime data + # names are quoted too: an exploded object's names are runtime data new_params.append((quote(str(k)), quote(str(v)))) return "&".join(["=".join(map(str, item)) for item in new_params]) diff --git a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py index 6d4ae576a056..f5034ea9753a 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py @@ -9534,16 +9534,7 @@ def _test_query_parameter_collection_format_serialize( if language is not None: - # form style explodes an object into one parameter per entry, keyed by the - # property name alone. An entry holding a list repeats that name per item. - # An entry that is null contributes nothing, as does a null list item. - for _key, _value in language.items(): - if _value is None: - continue - if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value if _item is not None) - else: - _query_params.append((_key, _value)) + _query_params.extend(self.api_client.explode_query_object('language', language)) if allow_empty is not None: diff --git a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api_client.py index 4cb0af34c521..50741490c29c 100644 --- a/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api_client.py +++ b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api_client.py @@ -519,6 +519,13 @@ def parameters_to_tuples(self, params, collection_formats): new_params.append((k, v)) return new_params + def explode_query_object(self, name, obj): + """form style, explode: one query parameter per entry, keyed by the property name; a list repeats the name, None is left out""" + obj = self.sanitize_for_serialization(obj) + if not isinstance(obj, dict): + obj = {name: obj} + 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] + def parameters_to_url_query(self, params, collection_formats): """Get parameters as list of tuples, formatting collections. @@ -537,10 +544,7 @@ def parameters_to_url_query(self, params, collection_formats): if isinstance(v, dict): v = json.dumps(v) - # a collection format only applies to a parameter that actually carries a - # 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. + # a collection format applies only to a list; an exploded entry may share a declared array parameter's name if k in collection_formats and isinstance(v, (list, tuple)): collection_format = collection_formats[k] if collection_format == 'multi': @@ -563,8 +567,7 @@ def parameters_to_url_query(self, params, collection_formats): for value in v)) ) else: - # the name is quoted as well as the value: an exploded object query - # parameter takes its names from the object, so they are runtime data + # names are quoted too: an exploded object's names are runtime data new_params.append((quote(str(k)), quote(str(v)))) return "&".join(["=".join(map(str, item)) for item in new_params]) diff --git a/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api/fake_api.py index a38dfd5fc7f8..c22c0ab04bda 100644 --- a/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api/fake_api.py @@ -13194,16 +13194,7 @@ def _test_query_parameter_collection_format_serialize( if language is not None: - # form style explodes an object into one parameter per entry, keyed by the - # property name alone. An entry holding a list repeats that name per item. - # An entry that is null contributes nothing, as does a null list item. - for _key, _value in language.items(): - if _value is None: - continue - if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value if _item is not None) - else: - _query_params.append((_key, _value)) + _query_params.extend(self.api_client.explode_query_object('language', language)) if allow_empty is not None: diff --git a/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api_client.py index 9b947dfef3e7..44b7a286135f 100644 --- a/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api_client.py +++ b/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api_client.py @@ -522,6 +522,13 @@ def parameters_to_tuples(self, params, collection_formats): new_params.append((k, v)) return new_params + def explode_query_object(self, name, obj): + """form style, explode: one query parameter per entry, keyed by the property name; a list repeats the name, None is left out""" + obj = self.sanitize_for_serialization(obj) + if not isinstance(obj, dict): + obj = {name: obj} + 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] + def parameters_to_url_query(self, params, collection_formats): """Get parameters as list of tuples, formatting collections. @@ -540,10 +547,7 @@ def parameters_to_url_query(self, params, collection_formats): if isinstance(v, dict): v = json.dumps(v) - # a collection format only applies to a parameter that actually carries a - # 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. + # a collection format applies only to a list; an exploded entry may share a declared array parameter's name if k in collection_formats and isinstance(v, (list, tuple)): collection_format = collection_formats[k] if collection_format == 'multi': @@ -566,8 +570,7 @@ def parameters_to_url_query(self, params, collection_formats): for value in v)) ) else: - # the name is quoted as well as the value: an exploded object query - # parameter takes its names from the object, so they are runtime data + # names are quoted too: an exploded object's names are runtime data new_params.append((quote(str(k)), quote(str(v)))) return "&".join(["=".join(map(str, item)) for item in new_params]) diff --git a/samples/openapi3/client/petstore/python-httpx/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-httpx/petstore_api/api/fake_api.py index ad1245912be1..6adfebde84fb 100644 --- a/samples/openapi3/client/petstore/python-httpx/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-httpx/petstore_api/api/fake_api.py @@ -9515,16 +9515,7 @@ def _test_query_parameter_collection_format_serialize( if language is not None: - # form style explodes an object into one parameter per entry, keyed by the - # property name alone. An entry holding a list repeats that name per item. - # An entry that is null contributes nothing, as does a null list item. - for _key, _value in language.items(): - if _value is None: - continue - if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value if _item is not None) - else: - _query_params.append((_key, _value)) + _query_params.extend(self.api_client.explode_query_object('language', language)) if allow_empty is not None: diff --git a/samples/openapi3/client/petstore/python-httpx/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-httpx/petstore_api/api_client.py index 9b947dfef3e7..44b7a286135f 100644 --- a/samples/openapi3/client/petstore/python-httpx/petstore_api/api_client.py +++ b/samples/openapi3/client/petstore/python-httpx/petstore_api/api_client.py @@ -522,6 +522,13 @@ def parameters_to_tuples(self, params, collection_formats): new_params.append((k, v)) return new_params + def explode_query_object(self, name, obj): + """form style, explode: one query parameter per entry, keyed by the property name; a list repeats the name, None is left out""" + obj = self.sanitize_for_serialization(obj) + if not isinstance(obj, dict): + obj = {name: obj} + 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] + def parameters_to_url_query(self, params, collection_formats): """Get parameters as list of tuples, formatting collections. @@ -540,10 +547,7 @@ def parameters_to_url_query(self, params, collection_formats): if isinstance(v, dict): v = json.dumps(v) - # a collection format only applies to a parameter that actually carries a - # 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. + # a collection format applies only to a list; an exploded entry may share a declared array parameter's name if k in collection_formats and isinstance(v, (list, tuple)): collection_format = collection_formats[k] if collection_format == 'multi': @@ -566,8 +570,7 @@ def parameters_to_url_query(self, params, collection_formats): for value in v)) ) else: - # the name is quoted as well as the value: an exploded object query - # parameter takes its names from the object, so they are runtime data + # names are quoted too: an exploded object's names are runtime data new_params.append((quote(str(k)), quote(str(v)))) return "&".join(["=".join(map(str, item)) for item in new_params]) diff --git a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_api.py index 3e330dadf0ce..feba662440e8 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api/fake_api.py @@ -9535,16 +9535,7 @@ def _test_query_parameter_collection_format_serialize( if language is not None: - # form style explodes an object into one parameter per entry, keyed by the - # property name alone. An entry holding a list repeats that name per item. - # An entry that is null contributes nothing, as does a null list item. - for _key, _value in language.items(): - if _value is None: - continue - if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value if _item is not None) - else: - _query_params.append((_key, _value)) + _query_params.extend(self.api_client.explode_query_object('language', language)) if allow_empty is not None: diff --git a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api_client.py index e8ecc48c1225..eeb694ac158b 100644 --- a/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api_client.py +++ b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api_client.py @@ -519,6 +519,13 @@ def parameters_to_tuples(self, params, collection_formats): new_params.append((k, v)) return new_params + def explode_query_object(self, name, obj): + """form style, explode: one query parameter per entry, keyed by the property name; a list repeats the name, None is left out""" + obj = self.sanitize_for_serialization(obj) + if not isinstance(obj, dict): + obj = {name: obj} + 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] + def parameters_to_url_query(self, params, collection_formats): """Get parameters as list of tuples, formatting collections. @@ -537,10 +544,7 @@ def parameters_to_url_query(self, params, collection_formats): if isinstance(v, dict): v = json.dumps(v) - # a collection format only applies to a parameter that actually carries a - # 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. + # a collection format applies only to a list; an exploded entry may share a declared array parameter's name if k in collection_formats and isinstance(v, (list, tuple)): collection_format = collection_formats[k] if collection_format == 'multi': @@ -563,8 +567,7 @@ def parameters_to_url_query(self, params, collection_formats): for value in v)) ) else: - # the name is quoted as well as the value: an exploded object query - # parameter takes its names from the object, so they are runtime data + # names are quoted too: an exploded object's names are runtime data new_params.append((quote(str(k)), quote(str(v)))) return "&".join(["=".join(map(str, item)) for item in new_params]) diff --git a/samples/openapi3/client/petstore/python/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python/petstore_api/api/fake_api.py index aae3e3b396ef..0605972caae5 100755 --- a/samples/openapi3/client/petstore/python/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python/petstore_api/api/fake_api.py @@ -9515,16 +9515,7 @@ def _test_query_parameter_collection_format_serialize( if language is not None: - # form style explodes an object into one parameter per entry, keyed by the - # property name alone. An entry holding a list repeats that name per item. - # An entry that is null contributes nothing, as does a null list item. - for _key, _value in language.items(): - if _value is None: - continue - if isinstance(_value, (list, tuple)): - _query_params.extend((_key, _item) for _item in _value if _item is not None) - else: - _query_params.append((_key, _value)) + _query_params.extend(self.api_client.explode_query_object('language', language)) if allow_empty is not None: diff --git a/samples/openapi3/client/petstore/python/petstore_api/api_client.py b/samples/openapi3/client/petstore/python/petstore_api/api_client.py index 2e4af19c5683..c98eb486e651 100755 --- a/samples/openapi3/client/petstore/python/petstore_api/api_client.py +++ b/samples/openapi3/client/petstore/python/petstore_api/api_client.py @@ -519,6 +519,13 @@ def parameters_to_tuples(self, params, collection_formats): new_params.append((k, v)) return new_params + def explode_query_object(self, name, obj): + """form style, explode: one query parameter per entry, keyed by the property name; a list repeats the name, None is left out""" + obj = self.sanitize_for_serialization(obj) + if not isinstance(obj, dict): + obj = {name: obj} + 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] + def parameters_to_url_query(self, params, collection_formats): """Get parameters as list of tuples, formatting collections. @@ -537,10 +544,7 @@ def parameters_to_url_query(self, params, collection_formats): if isinstance(v, dict): v = json.dumps(v) - # a collection format only applies to a parameter that actually carries a - # 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. + # a collection format applies only to a list; an exploded entry may share a declared array parameter's name if k in collection_formats and isinstance(v, (list, tuple)): collection_format = collection_formats[k] if collection_format == 'multi': @@ -563,8 +567,7 @@ def parameters_to_url_query(self, params, collection_formats): for value in v)) ) else: - # the name is quoted as well as the value: an exploded object query - # parameter takes its names from the object, so they are runtime data + # names are quoted too: an exploded object's names are runtime data new_params.append((quote(str(k)), quote(str(v)))) return "&".join(["=".join(map(str, item)) for item in new_params]) diff --git a/samples/openapi3/client/petstore/python/tests/test_api_client.py b/samples/openapi3/client/petstore/python/tests/test_api_client.py index 5f10962851b2..cea1e548dd31 100644 --- a/samples/openapi3/client/petstore/python/tests/test_api_client.py +++ b/samples/openapi3/client/petstore/python/tests/test_api_client.py @@ -316,6 +316,21 @@ def test_parameters_to_url_query_quotes_reserved_characters_in_names(self): collection_formats={}) self.assertEqual(params, "createdDate%3Agte=2023-01-01&a%26b=c") + def test_explode_query_object(self): + # one pair per entry; a list repeats the name, None is left out, values are serialized + params = self.api_client.explode_query_object('filter', {'a': 'b', 'k': None, 'l': ['x', None, 2], 'd': parse('2020-01-02').date()}) + self.assertEqual(params, [('a', 'b'), ('l', 'x'), ('l', 2), ('d', '2020-01-02')]) + + def test_explode_query_object_model(self): + # a model explodes under its wire names, and unset properties contribute nothing + params = self.api_client.explode_query_object('pet', petstore_api.Pet(name='doggie', photoUrls=['a', 'b'])) + self.assertEqual(params, [('name', 'doggie'), ('photoUrls', 'a'), ('photoUrls', 'b')]) + + def test_explode_query_object_not_a_dict(self): + # a value that does not serialize to a dict stays under the parameter name + self.assertEqual(self.api_client.explode_query_object('q', 'x'), [('q', 'x')]) + self.assertEqual(self.api_client.explode_query_object('q', ['x', None, 'y']), [('q', 'x'), ('q', 'y')]) + def test_parameters_to_url_query_list_value(self): params = self.api_client.parameters_to_url_query(params=[('list', [1, 2, 3])], collection_formats={'list': 'multi'}) From dc3b575f5171c96de513c20cd152e6e5c6515b5d Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Wed, 23 Sep 2026 11:52:51 +0200 Subject: [PATCH 7/7] 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) --- .../petstore_api/api/fake_api.py | 2 +- .../petstore_api/api_client.py | 17 +++++++++++++---- .../python-httpx2/petstore_api/api/fake_api.py | 2 +- .../python-httpx2/petstore_api/api_client.py | 17 +++++++++++++---- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/samples/openapi3/client/petstore/python-httpx2-sync/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-httpx2-sync/petstore_api/api/fake_api.py index fda246402ac5..c22c0ab04bda 100644 --- a/samples/openapi3/client/petstore/python-httpx2-sync/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-httpx2-sync/petstore_api/api/fake_api.py @@ -13194,7 +13194,7 @@ def _test_query_parameter_collection_format_serialize( if language is not None: - _query_params.append(('language', language)) + _query_params.extend(self.api_client.explode_query_object('language', language)) if allow_empty is not None: diff --git a/samples/openapi3/client/petstore/python-httpx2-sync/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-httpx2-sync/petstore_api/api_client.py index b7eb3a9170cb..44b7a286135f 100644 --- a/samples/openapi3/client/petstore/python-httpx2-sync/petstore_api/api_client.py +++ b/samples/openapi3/client/petstore/python-httpx2-sync/petstore_api/api_client.py @@ -522,6 +522,13 @@ def parameters_to_tuples(self, params, collection_formats): new_params.append((k, v)) return new_params + def explode_query_object(self, name, obj): + """form style, explode: one query parameter per entry, keyed by the property name; a list repeats the name, None is left out""" + obj = self.sanitize_for_serialization(obj) + if not isinstance(obj, dict): + obj = {name: obj} + 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] + def parameters_to_url_query(self, params, collection_formats): """Get parameters as list of tuples, formatting collections. @@ -540,11 +547,12 @@ def parameters_to_url_query(self, params, collection_formats): if isinstance(v, dict): v = json.dumps(v) - if k in collection_formats: + # a collection format applies only to a list; an exploded entry may share a declared array parameter's name + if k in collection_formats and isinstance(v, (list, tuple)): collection_format = collection_formats[k] if collection_format == 'multi': new_params.extend( - (k, quote(str(value).lower() if isinstance(value, bool) else str(value))) + (quote(str(k)), quote(str(value).lower() if isinstance(value, bool) else str(value))) for value in v ) else: @@ -557,12 +565,13 @@ def parameters_to_url_query(self, params, collection_formats): else: # csv is the default delimiter = ',' new_params.append( - (k, delimiter.join( + (quote(str(k)), delimiter.join( quote(str(value).lower() if isinstance(value, bool) else str(value)) for value in v)) ) else: - new_params.append((k, quote(str(v)))) + # names are quoted too: an exploded object's names are runtime data + new_params.append((quote(str(k)), quote(str(v)))) return "&".join(["=".join(map(str, item)) for item in new_params]) diff --git a/samples/openapi3/client/petstore/python-httpx2/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-httpx2/petstore_api/api/fake_api.py index 9e895f7806ee..6adfebde84fb 100644 --- a/samples/openapi3/client/petstore/python-httpx2/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-httpx2/petstore_api/api/fake_api.py @@ -9515,7 +9515,7 @@ def _test_query_parameter_collection_format_serialize( if language is not None: - _query_params.append(('language', language)) + _query_params.extend(self.api_client.explode_query_object('language', language)) if allow_empty is not None: diff --git a/samples/openapi3/client/petstore/python-httpx2/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-httpx2/petstore_api/api_client.py index b7eb3a9170cb..44b7a286135f 100644 --- a/samples/openapi3/client/petstore/python-httpx2/petstore_api/api_client.py +++ b/samples/openapi3/client/petstore/python-httpx2/petstore_api/api_client.py @@ -522,6 +522,13 @@ def parameters_to_tuples(self, params, collection_formats): new_params.append((k, v)) return new_params + def explode_query_object(self, name, obj): + """form style, explode: one query parameter per entry, keyed by the property name; a list repeats the name, None is left out""" + obj = self.sanitize_for_serialization(obj) + if not isinstance(obj, dict): + obj = {name: obj} + 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] + def parameters_to_url_query(self, params, collection_formats): """Get parameters as list of tuples, formatting collections. @@ -540,11 +547,12 @@ def parameters_to_url_query(self, params, collection_formats): if isinstance(v, dict): v = json.dumps(v) - if k in collection_formats: + # a collection format applies only to a list; an exploded entry may share a declared array parameter's name + if k in collection_formats and isinstance(v, (list, tuple)): collection_format = collection_formats[k] if collection_format == 'multi': new_params.extend( - (k, quote(str(value).lower() if isinstance(value, bool) else str(value))) + (quote(str(k)), quote(str(value).lower() if isinstance(value, bool) else str(value))) for value in v ) else: @@ -557,12 +565,13 @@ def parameters_to_url_query(self, params, collection_formats): else: # csv is the default delimiter = ',' new_params.append( - (k, delimiter.join( + (quote(str(k)), delimiter.join( quote(str(value).lower() if isinstance(value, bool) else str(value)) for value in v)) ) else: - new_params.append((k, quote(str(v)))) + # names are quoted too: an exploded object's names are runtime data + new_params.append((quote(str(k)), quote(str(v)))) return "&".join(["=".join(map(str, item)) for item in new_params])