diff --git a/sdk/core/azure-core/CHANGELOG.md b/sdk/core/azure-core/CHANGELOG.md index f77fe1020e82..5f7669b06113 100644 --- a/sdk/core/azure-core/CHANGELOG.md +++ b/sdk/core/azure-core/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 1.42.0 (Unreleased) + +### Bugs Fixed + +- Fixed `HttpRequest` encoding multipart payloads that carry form data but no file (e.g. when the file part is optional and omitted) as `application/x-www-form-urlencoded`. Such requests can now be flagged with the internal `is_multipart_payload` keyword; the form fields are then encoded as file-less multipart parts so the request is sent as `multipart/form-data`. #39163 + ## 1.41.0 (2026-05-07) ### Features Added diff --git a/sdk/core/azure-core/azure/core/_version.py b/sdk/core/azure-core/azure/core/_version.py index b61909f350dd..7e497df87224 100644 --- a/sdk/core/azure-core/azure/core/_version.py +++ b/sdk/core/azure-core/azure/core/_version.py @@ -9,4 +9,4 @@ # regenerated. # -------------------------------------------------------------------------- -VERSION = "1.41.0" +VERSION = "1.42.0" diff --git a/sdk/core/azure-core/azure/core/rest/_rest_py3.py b/sdk/core/azure-core/azure/core/rest/_rest_py3.py index 97c3658f5497..b42c496adb0d 100644 --- a/sdk/core/azure-core/azure/core/rest/_rest_py3.py +++ b/sdk/core/azure-core/azure/core/rest/_rest_py3.py @@ -116,6 +116,7 @@ def __init__( data=data, files=files, json=json, + is_multipart_payload=kwargs.pop("is_multipart_payload", False), ) self.headers: MutableMapping[str, str] = case_insensitive_dict(default_headers) self.headers.update(headers or {}) @@ -131,6 +132,7 @@ def _set_body( data: Optional[Dict[str, Any]] = None, files: Optional[FilesType] = None, json: Any = None, + is_multipart_payload: bool = False, ) -> MutableMapping[str, str]: """Sets the body of the request, and returns the default headers. @@ -139,6 +141,10 @@ def _set_body( :param dict data: Form data you want in your request body. :param dict files: Files you want to in your request body. :param any json: A JSON serializable object. + :param bool is_multipart_payload: Whether the payload is meant to be sent as + multipart/form-data. When True, the request is treated as multipart even if + no file is present (e.g. the file part is optional and omitted), so it is not + mislabeled as ``application/x-www-form-urlencoded``. :return: The default headers for the request :rtype: MutableMapping[str, str] """ @@ -155,7 +161,15 @@ def _set_body( if files: default_headers, self._files = set_multipart_body(files) if data: - default_headers, self._data = set_urlencoded_body(data, has_files=bool(files)) + if is_multipart_payload and not files: + # A multipart payload whose only file part is optional (and omitted) must + # still be sent as multipart/form-data, not application/x-www-form-urlencoded. + # Transports build the multipart body from `files`, so encode the form + # fields as file-less parts (name, (None, value)) instead of url-encoding them. + default_headers, self._files = set_multipart_body(data) + self._data = None + else: + default_headers, self._data = set_urlencoded_body(data, has_files=bool(files)) return default_headers @property diff --git a/sdk/core/azure-core/tests/test_rest_http_request.py b/sdk/core/azure-core/tests/test_rest_http_request.py index 7ad245d30bb2..400490f8a1b3 100644 --- a/sdk/core/azure-core/tests/test_rest_http_request.py +++ b/sdk/core/azure-core/tests/test_rest_http_request.py @@ -88,6 +88,35 @@ def test_url_encoded_data(): } # httpx makes this just b'test=123'. set_formdata_body is still keeping it as a dict +def test_multipart_data_without_files(): + # A multipart payload whose file part is optional (and omitted) still must be sent + # as multipart, not application/x-www-form-urlencoded. See issue #39163. + # The transports build a multipart body from `files`, so the form fields are encoded + # as file-less parts and `data` is cleared. + request = HttpRequest( + "POST", + "http://example.org", + data={"test": "123"}, + is_multipart_payload=True, + ) + + assert "Content-Type" not in request.headers + assert request.files == {"test": (None, "123")} + assert request.data is None + + +def test_url_encoded_data_when_not_multipart(): + # Without the multipart flag, data-only requests remain urlencoded (default behavior). + request = HttpRequest( + "POST", + "http://example.org", + data={"test": "123"}, + is_multipart_payload=False, + ) + + assert request.headers["Content-Type"] == "application/x-www-form-urlencoded" + + def test_json_encoded_data(): request = HttpRequest("POST", "http://example.org", json={"test": 123})