diff --git a/modules/openapi-generator/src/main/resources/python/api.mustache b/modules/openapi-generator/src/main/resources/python/api.mustache index 514be3b69a85..3cee5c88c1b8 100644 --- a/modules/openapi-generator/src/main/resources/python/api.mustache +++ b/modules/openapi-generator/src/main/resources/python/api.mustache @@ -347,7 +347,37 @@ 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}} + _query_params.extend(self.api_client.explode_query_object('{{baseName}}', {{paramName}})) + {{/isDeepObject}} + {{/isExplode}} + {{^isExplode}} + _query_params.append(('{{baseName}}', {{paramName}})) + {{/isExplode}} + {{/isMap}} + {{#isModel}} + {{#isExplode}} + {{#isDeepObject}} + _query_params.append(('{{baseName}}', {{paramName}})) + {{/isDeepObject}} + {{^isDeepObject}} + _query_params.extend(self.api_client.explode_query_object('{{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}} # 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..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,11 +696,12 @@ https://github.com/OpenAPITools/openapi-generator/blob/c84b949df1a9ec04ba75989cb 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: @@ -706,12 +714,13 @@ 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)))) + # 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 5f6272a7a11b..5b7f8c33b8c6 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 @@ -803,6 +803,74 @@ public void testInitFileImportsExportsWithCustomApiPackage() throws IOException assertFileContains(apiInitFile.toPath(), "from my_pkg.my_api.pet_api import PetApi"); } + @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(); + + 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"); + + TestUtils.assertFileContains(api, + "_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))"); + + // 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, + "explode_query_object('deepFilter', deep_filter)", + "explode_query_object('flatFilter', flat_filter)"); + + // 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)):"); + } + + @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"); + + // 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, + "_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))"); + + // 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, + "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") 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..e7a68518b4a0 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/python/exploded-model-query-param.yaml @@ -0,0 +1,66 @@ +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 or a list, neither of which has 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 + - 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 ac967a796ab2..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,7 +2204,7 @@ 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)) + _query_params.extend(self.api_client.explode_query_object('query_object', query_object)) # process the header parameters # process the form parameters @@ -2466,7 +2466,7 @@ 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)) + _query_params.extend(self.api_client.explode_query_object('query_object', query_object)) # process the header parameters # process the form parameters @@ -2728,7 +2728,7 @@ 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)) + _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 17c439964cee..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,11 +545,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: @@ -555,12 +563,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/client/echo_api/python/openapi_client/api/query_api.py b/samples/client/echo_api/python/openapi_client/api/query_api.py index ac967a796ab2..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,7 +2204,7 @@ 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)) + _query_params.extend(self.api_client.explode_query_object('query_object', query_object)) # process the header parameters # process the form parameters @@ -2466,7 +2466,7 @@ 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)) + _query_params.extend(self.api_client.explode_query_object('query_object', query_object)) # process the header parameters # process the form parameters @@ -2728,7 +2728,7 @@ 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)) + _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 17c439964cee..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,11 +545,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: @@ -555,12 +563,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/client/echo_api/python/tests/test_manual.py b/samples/client/echo_api/python/tests/test_manual.py index b63f5b36dcb1..b6d2a30e7c67 100644 --- a/samples/client/echo_api/python/tests/test_manual.py +++ b/samples/client/echo_api/python/tests/test_manual.py @@ -92,6 +92,32 @@ 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_leaves_out_unset_properties(self): + # 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) + 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() + 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/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..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,11 +653,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: @@ -663,12 +671,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-aiohttp/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api/fake_api.py index 3cc84bcc7af2..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,7 +9534,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-aiohttp/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-aiohttp/petstore_api/api_client.py index 49b32fc8edd6..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,11 +544,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: @@ -554,12 +562,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-httpx-sync/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api/fake_api.py index fda246402ac5..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,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-httpx-sync/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-httpx-sync/petstore_api/api_client.py index b7eb3a9170cb..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,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-httpx/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-httpx/petstore_api/api/fake_api.py index 9e895f7806ee..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,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-httpx/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-httpx/petstore_api/api_client.py index b7eb3a9170cb..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,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-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]) 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..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,7 +9535,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-lazyImports/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-lazyImports/petstore_api/api_client.py index 8dcae18708cf..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,11 +544,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: @@ -554,12 +562,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/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python/petstore_api/api/fake_api.py index 2373d1680c58..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,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/petstore_api/api_client.py b/samples/openapi3/client/petstore/python/petstore_api/api_client.py index 899983e443d6..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,11 +544,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: @@ -554,12 +562,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/tests/test_api_client.py b/samples/openapi3/client/petstore/python/tests/test_api_client.py index cb0ed9080f51..cea1e548dd31 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,47 @@ 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_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_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'})