diff --git a/src/cloudflare/resources/kv/namespaces/values.py b/src/cloudflare/resources/kv/namespaces/values.py index 05e3e187510..c65aa933923 100644 --- a/src/cloudflare/resources/kv/namespaces/values.py +++ b/src/cloudflare/resources/kv/namespaces/values.py @@ -113,6 +113,10 @@ def update( raise ValueError(f"Expected a non-empty value for `namespace_id` but received {namespace_id!r}") if not key_name: raise ValueError(f"Expected a non-empty value for `key_name` but received {key_name!r}") + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} return self._put( f"/accounts/{account_id}/storage/kv/namespaces/{namespace_id}/values/{key_name}", body=maybe_transform( @@ -324,6 +328,10 @@ async def update( raise ValueError(f"Expected a non-empty value for `namespace_id` but received {namespace_id!r}") if not key_name: raise ValueError(f"Expected a non-empty value for `key_name` but received {key_name!r}") + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} return await self._put( f"/accounts/{account_id}/storage/kv/namespaces/{namespace_id}/values/{key_name}", body=await async_maybe_transform( diff --git a/tests/api_resources/kv/namespaces/test_values.py b/tests/api_resources/kv/namespaces/test_values.py index fbf0e7a0dd5..40de31a73df 100644 --- a/tests/api_resources/kv/namespaces/test_values.py +++ b/tests/api_resources/kv/namespaces/test_values.py @@ -115,6 +115,40 @@ def test_path_params_update(self, client: Cloudflare) -> None: value="Some Value", ) + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_update_uses_multipart_form_data(self, client: Cloudflare, respx_mock: MockRouter) -> None: + """Test that values.update sends data as multipart/form-data, not JSON. + + This is a regression test for https://github.com/cloudflare/cloudflare-python/issues/2519 + """ + respx_mock.put( + "/accounts/023e105f4ecef8ad9ca31a8372d0c353/storage/kv/namespaces/0f2ac74b498b48028cb68387c421e279/values/My-Key" + ).mock(return_value=httpx.Response(200, json={"success": True, "result": {}})) + + client.kv.namespaces.values.update( + key_name="My-Key", + account_id="023e105f4ecef8ad9ca31a8372d0c353", + namespace_id="0f2ac74b498b48028cb68387c421e279", + metadata='{"someMetadataKey": "someMetadataValue"}', + value="Some Value", + ) + + # Verify the request was made + assert respx_mock.calls.call_count == 1 + request = respx_mock.calls[0].request + + # Verify Content-Type is multipart/form-data (with boundary) + content_type = request.headers.get("content-type", "") + assert content_type.startswith("multipart/form-data"), ( + f"Expected Content-Type to start with 'multipart/form-data', got '{content_type}'" + ) + + # Verify the body contains separate form fields for value and metadata + body = request.content.decode("utf-8") + assert "Some Value" in body, "Value should be in the multipart body" + assert "someMetadataKey" in body, "Metadata should be in the multipart body" + @parametrize def test_method_delete(self, client: Cloudflare) -> None: value = client.kv.namespaces.values.delete( @@ -351,6 +385,40 @@ async def test_path_params_update(self, async_client: AsyncCloudflare) -> None: value="Some Value", ) + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_update_uses_multipart_form_data(self, async_client: AsyncCloudflare, respx_mock: MockRouter) -> None: + """Test that values.update sends data as multipart/form-data, not JSON. + + This is a regression test for https://github.com/cloudflare/cloudflare-python/issues/2519 + """ + respx_mock.put( + "/accounts/023e105f4ecef8ad9ca31a8372d0c353/storage/kv/namespaces/0f2ac74b498b48028cb68387c421e279/values/My-Key" + ).mock(return_value=httpx.Response(200, json={"success": True, "result": {}})) + + await async_client.kv.namespaces.values.update( + key_name="My-Key", + account_id="023e105f4ecef8ad9ca31a8372d0c353", + namespace_id="0f2ac74b498b48028cb68387c421e279", + metadata='{"someMetadataKey": "someMetadataValue"}', + value="Some Value", + ) + + # Verify the request was made + assert respx_mock.calls.call_count == 1 + request = respx_mock.calls[0].request + + # Verify Content-Type is multipart/form-data (with boundary) + content_type = request.headers.get("content-type", "") + assert content_type.startswith("multipart/form-data"), ( + f"Expected Content-Type to start with 'multipart/form-data', got '{content_type}'" + ) + + # Verify the body contains separate form fields for value and metadata + body = request.content.decode("utf-8") + assert "Some Value" in body, "Value should be in the multipart body" + assert "someMetadataKey" in body, "Metadata should be in the multipart body" + @parametrize async def test_method_delete(self, async_client: AsyncCloudflare) -> None: value = await async_client.kv.namespaces.values.delete(