Repository: aio-libs/aiohttp

Issue:
Using MultipartWriter.append_json breaks in 3.9.4 with AssertionError "assert CONTENT_DISPOSITION in payload.headers"
### Describe the bug

After upgrading to 3.9.4 the following example code that used to work in 3.9.3 starts failing. I think the issue was introduced in https://github.com/aio-libs/aiohttp/commit/7d0be3fee540a3d4161ac7dc76422f1f5ea60104

### To Reproduce

```python
import aiohttp
import asyncio


async def main():
    async with aiohttp.ClientSession() as session:
        mpwriter = aiohttp.MultipartWriter('form-data')
        mpwriter.append_json({'a': 1}) 
        response = await session.post('https://httpbingo.org/post', data=mpwriter)
        print(await response.text())


asyncio.run(main())
```

### Expected behavior

This should print a text response, but instead it fails with an AssertionError


### Logs/tracebacks

```python-traceback
Traceback (most recent call last):
  File "/Users/nico/test.py", line 13, in <module>
    asyncio.run(main())
  File "/opt/homebrew/Cellar/python@3.9/3.9.18_1/Frameworks/Python.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "/opt/homebrew/Cellar/python@3.9/3.9.18_1/Frameworks/Python.framework/Versions/3.9/lib/python3.9/asyncio/base_events.py", line 647, in run_until_complete
    return future.result()
  File "/Users/nico/test.py", line 8, in main
    mpwriter.append_json({'a': 1})
  File "/Users/nico/virtualenvs/gaevenv3.9/lib/python3.9/site-packages/aiohttp/multipart.py", line 887, in append_json
    return self.append_payload(JsonPayload(obj, headers=headers))
  File "/Users/nico/virtualenvs/gaevenv3.9/lib/python3.9/site-packages/aiohttp/multipart.py", line 851, in append_payload
    assert CONTENT_DISPOSITION in payload.headers
AssertionError
```
```


### Python Version

```console
Python 3.9.18
```


### aiohttp Version

```console
Name: aiohttp
Version: 3.9.4
Summary: Async http client/server framework (asyncio)
Home-page: https://github.com/aio-libs/aiohttp
Author:
Author-email:
License: Apache 2
Location: /Users/nico/virtualenvs/gaevenv3.9/lib/python3.9/site-packages
Requires: aiosignal, async-timeout, attrs, frozenlist, multidict, yarl
Required-by: gcloud-aio-auth
```


### multidict Version

```console
Name: multidict
Version: 5.2.0
Summary: multidict implementation
Home-page: https://github.com/aio-libs/multidict
Author: Andrew Svetlov
Author-email: andrew.svetlov@gmail.com
License: Apache 2
Location: /Users/nico/virtualenvs/gaevenv3.9/lib/python3.9/site-packages
Requires:
Required-by: aiohttp, sanic, yarl
```


### yarl Version

```console
Name: yarl
Version: 1.9.2
Summary: Yet another URL library
Home-page: https://github.com/aio-libs/yarl/
Author: Andrew Svetlov
Author-email: andrew.svetlov@gmail.com
License: Apache-2.0
Location: /Users/nico/virtualenvs/gaevenv3.9/lib/python3.9/site-packages
Requires: idna, multidict
Required-by: aiohttp
```


### OS

macOS 14.1.1

### Related component

Client

### Additional context

_No response_

### Code of Conduct

- [X] I agree to follow the aio-libs Code of Conduct


Discussion:
Got this issue here: https://github.com/aiogram/aiogram/actions/runs/8664683869/job/23761669236?pr=1459

Example of usage with previous version:
https://github.com/aiogram/aiogram/blob/b49939aaff2b1e37520a207b01ea124412641d01/aiogram/webhook/aiohttp_server.py#L155-L163
Through these convenience methods, we should probably add Content-Disposition automatically. But, I guess the one question is how to name the parts? Shouldn't there be an expected name by the receiving service, which the user should be using?
Note that these are just asserts, so you can likely use `python -O` as a workaround at the moment.
> Note that these are just asserts, so you can likely use `python -O` as a workaround at the moment.

But this update breaks all existing tests 

What's wrong with this usage?
https://github.com/aiogram/aiogram/blob/b49939aaff2b1e37520a207b01ea124412641d01/aiogram/webhook/aiohttp_server.py#L155-L163

What should I edit to make 3.9.4 works?
It seems like there's a dead end here with new assertions

> What's wrong with this usage?

Wait, that code _does_ set content-disposition. Why are we getting the error there too?
I was just looking at the code in the start of this issue, which doesn't set content-disposition.

Think you can create a test quickly in a PR here?
> > What's wrong with this usage?
> 
> Wait, that code _does_ set content-disposition. Why are we getting the error there too? I was just looking at the code in the start of this issue, which doesn't set content-disposition.
> 
> Think you can create a test quickly in a PR here?

Sure, wait a minute
Ah, wait, you're getting the payload after the append and then setting it, but the check is happening during the append. Maybe we can move the check later...
https://github.com/aio-libs/aiohttp/pull/8332

Resolving patch:
diff --git a/aiohttp/multipart.py b/aiohttp/multipart.py
--- a/aiohttp/multipart.py
+++ b/aiohttp/multipart.py
@@ -879,6 +879,10 @@ def append_payload(self, payload: Payload) -> Payload:
                 not {CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TRANSFER_ENCODING}
                 & payload.headers.keys()
             )
+            # Set default Content-Disposition in case user doesn't create one
+            if CONTENT_DISPOSITION not in payload.headers:
+                name = f"section-{len(self._parts)}"
+                payload.set_content_disposition("form-data", name=name)
         else:
             # compression
             encoding = payload.headers.get(CONTENT_ENCODING, "").lower()


Test patch:
diff --git a/tests/test_multipart.py b/tests/test_multipart.py
--- a/tests/test_multipart.py
+++ b/tests/test_multipart.py
@@ -1165,12 +1165,24 @@ def test_append_multipart(self, writer: Any) -> None:
         part = writer._parts[0][0]
         assert part.headers[CONTENT_TYPE] == "test/passed"
 
-    async def test_set_content_disposition_after_append(self):
+    def test_set_content_disposition_after_append(self):
         writer = aiohttp.MultipartWriter("form-data")
-        payload = writer.append("some-data")
-        payload.set_content_disposition("form-data", name="method")
-        assert CONTENT_DISPOSITION in payload.headers
-        assert "name=" in payload.headers[CONTENT_DISPOSITION]
+        part = writer.append("some-data")
+        part.set_content_disposition("form-data", name="method")
+        assert 'name="method"' in part.headers[CONTENT_DISPOSITION]
+
+    def test_automatic_content_disposition(self):
+        writer = aiohttp.MultipartWriter("form-data")
+        writer.append_json(())
+        part = payload.StringPayload("foo")
+        part.set_content_disposition("form-data", name="second")
+        writer.append_payload(part)
+        writer.append("foo")
+
+        disps = tuple(p[0].headers[CONTENT_DISPOSITION] for p in writer._parts)
+        assert 'name="section-0"' in disps[0]
+        assert 'name="second"' in disps[1]
+        assert 'name="section-2"' in disps[2]
 
     def test_with(self) -> None:
         with aiohttp.MultipartWriter(boundary=":") as writer: