Skip to content

Commit 7330fbf

Browse files
committed
request - streaming multipart/form-data requests using file like objects
1 parent 353d19f commit 7330fbf

6 files changed

Lines changed: 145 additions & 17 deletions

File tree

aiopenapi3/v30/glue.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -283,14 +283,38 @@ def _prepare_body(self, data, rbq):
283283
https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#media-type-object
284284
"""
285285
media: aiopenapi3.v30.media.MediaType = self.operation.requestBody.content[ct]
286-
if not media.schema_ or not isinstance(data, media.schema_.get_type()):
287-
"""expect the data to be a model"""
286+
if media.schema_ and isinstance(data, media.schema_.get_type()):
287+
"""data is a model"""
288+
params = parameters_from_multipart(data, media, rbq)
289+
msg = encode_multipart_parameters(params)
290+
self.req.content = msg.as_string()
291+
self.req.headers["Content-Type"] = f'{msg.get_content_type()}; boundary="{msg.get_boundary()}"'
292+
elif isinstance(data, list):
293+
_files = list()
294+
_data = dict()
295+
for name, value in data:
296+
if isinstance(value, tuple):
297+
alias = fh = content_type = None
298+
headers = {}
299+
if len(value) == 4:
300+
(alias, fh, content_type, headers) = value
301+
elif len(value) == 3:
302+
(alias, fh, content_type) = value
303+
elif len(value) == 2:
304+
(alias, fh) = value
305+
elif len(value) == 1:
306+
(alias, fh) = value
307+
308+
if (e := media.encoding.get(name)) is not None:
309+
headers.update({name: rbq[name] for name in e.headers.keys() if name in rbq})
310+
_value = (alias, fh, content_type, headers)
311+
_files.append((name, _value))
312+
else:
313+
_data[name] = value
314+
self.req.files = _files
315+
self.req.data = _data
316+
else:
288317
raise TypeError((type(data), media.schema_.get_type()))
289-
290-
params = parameters_from_multipart(data, media, rbq)
291-
msg = encode_multipart_parameters(params)
292-
self.req.content = msg.as_string()
293-
self.req.headers["Content-Type"] = f'{msg.get_content_type()}; boundary="{msg.get_boundary()}"'
294318
elif (ct := "application/x-www-form-urlencoded") in self.operation.requestBody.content:
295319
self.req.headers["Content-Type"] = ct
296320
media: aiopenapi3.v30.media.MediaType = self.operation.requestBody.content[ct]

docs/source/advanced.rst

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,12 @@ e.g.:
111111
mutualTLS
112112
^^^^^^^^^
113113
MutualTLS authentication requires
114+
114115
* certificate file
115116
* key file
116-
to authenticate to the remote server.
117+
* (optional) password to keyfile
118+
119+
to authenticate to the remote server, c.f. :ref:`httpx.Client.cert <https://www.python-httpx.org/api/#client>`_.
117120

118121
.. code:: python
119122

@@ -158,7 +161,7 @@ and
158161
Manual Requests
159162
===============
160163

161-
Creating a request manually allows accessing the httpx.Response as part of the :meth:`aiopenapi3.request.Request.request` return value.
164+
Creating a request manually allows accessing the httpx.Response as part of the :meth:`aiopenapi3.request.RequestBase.request` return value.
162165

163166
.. code:: python
164167
@@ -178,9 +181,65 @@ This can be used to provide certain header values (ETag), which are not paramete
178181
req.req.headers["If-Match"] = etag
179182
r = await req(parameters=parameters, data=kwargs)
180183
184+
Request Streaming
185+
-----------------
186+
File uploads via "multipart/form-data" as mentioned in the httpx documentation
187+
(Multipart file `uploads <https://www.python-httpx.org/quickstart/#sending-multipart-file-uploads>`_ &
188+
`encoding <https://www.python-httpx.org/advanced/#multipart-file-encoding>`_)
189+
do not require the content of the request to be in memory but work with file-like-objects instead.
190+
191+
httpx request streaming using file-like objects is limited to "multipart/form-data".
192+
It can not be used with "application/json" or "application/octet-stream".
193+
Additionally it does not support choice of encoding (such as base16, base64url or quoted-printable) as possible with OpenAPI v3.1 contentEncoding, which should not be a limitation.
194+
195+
Use via `Manual Requests`_ using the :meth:`~aiopenapi3.request.RequestBase.request` API.
196+
197+
.. code:: python
198+
199+
data = [
200+
("name",('form-data:name', file-like-object, content_type, headers))
201+
]
202+
203+
A Request like
204+
205+
.. code::
206+
207+
Content-Type: multipart/form-data; boundary=2a8ae6ad-f4ad-4d9a-a92c-6d217011fe0f
208+
Content-Length: …
209+
210+
--2a8ae6ad-f4ad-4d9a-a92c-6d217011fe0f
211+
Content-Disposition: form-data; name="datafile"; filename="r.gif"
212+
Content-Type: image/gif
213+
214+
215+
216+
would have to be created such as
217+
218+
.. code:: python
219+
220+
data = [
221+
("datafile",('r.gif', Path('r.gif').open('rb'), "image/gif", {})
222+
]
223+
224+
req.request(data=data)
225+
226+
Mixing file-like-objects and other form data fields is possible.
227+
228+
.. code:: python
229+
230+
data = [
231+
("datafile",('r.gif', Path('r.gif').open('rb'), "image/gif", {}),
232+
("path", "media/images/r.gif"),
233+
]
234+
235+
req.request(data=data)
236+
237+
238+
See :aioai3:ref:`tests.stream_test.test_request`.
239+
181240
182241
Response Streaming
183-
==================
242+
------------------
184243
185244
Responses exceeding the defined maximum content-length raise :class:`aiopenapi3.errors.ContentLengthExceededError` to prevent memory exhaustion.
186245
Though it is possible to increase the defined maximum content-length, it is preferable to use streaming for large responses, limiting the amount of memory required.
@@ -219,13 +278,13 @@ The main difference in the async use of the streaming is await & async for.
219278
220279
221280
Non-JSON Content
222-
----------------
281+
^^^^^^^^^^^^^^^^
223282
In case the content is not a model (application/octet-stream), the data can be read iteratively and written/processed.
224283
225284
See :aioai3:ref:`tests.stream_test.test_stream_data`.
226285
227286
JSON/Arrays of Models
228-
---------------------
287+
^^^^^^^^^^^^^^^^^^^^^
229288
In case the large response is an array of models, iterative JSON parsing libraries can be used to process the data.
230289
231290
See :aioai3:ref:`tests.stream_test.test_stream_array`.

docs/source/index.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ While aiopenapi3 supports some of the more exotic features of the Swagger/OpenAP
4141
* :ref:`api:Parameter Encoding`
4242
* :ref:`advanced:Forms`
4343
* :ref:`advanced:mutualTLS` authentication
44-
* :ref:`advanced:Response Streaming`
44+
* :ref:`Request <advanced:Request Streaming>` and :ref:`Response <advanced:Response Streaming>` streaming to reduce memory usage
4545

4646
some aspects of the specifications are implemented loose
4747

pdm.lock

Lines changed: 11 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ tests = [
114114
"nonecorn",
115115
"bootstrap-flask",
116116
"ijson",
117+
"python-multipart>=0.0.6",
117118
]
118119

119120
[tool.pdm]

tests/stream_test.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,20 @@
22
import random
33
import sys
44
import string
5-
from typing import List
5+
6+
if sys.version_info >= (3, 9):
7+
from typing import List, Annotated
8+
else:
9+
from typing import List
10+
from typing_extensions import Annotated
11+
612
from pathlib import Path
713

814
import uvloop
915
from hypercorn.asyncio import serve
1016
from hypercorn.config import Config
1117
import pydantic
12-
from fastapi import FastAPI, Request, Response, Query
18+
from fastapi import FastAPI, Request, Response, Query, UploadFile, Body
1319
from fastapi.responses import PlainTextResponse
1420

1521
import pytest
@@ -46,7 +52,6 @@ async def server(event_loop, config):
4652
@pytest_asyncio.fixture(scope="session")
4753
async def client(event_loop, server):
4854
api = await aiopenapi3.OpenAPI.load_async(f"http://{server.bind[0]}/openapi.json")
49-
5055
return api
5156

5257

@@ -66,6 +71,22 @@ def files(request: Request, response: Response, number: int = Query(), size: int
6671
return [File(data=f.read(size)) for _ in range(number)]
6772

6873

74+
@app.post("/request-streaming", operation_id="request_streaming", response_model=int)
75+
def request_streaming(
76+
request: Request, response: Response, files: List[UploadFile], path: Annotated[str, Body()]
77+
) -> int:
78+
r = 0
79+
for file in files:
80+
if file.filename == "a.png":
81+
assert file.headers["x-extra"] == "yes"
82+
83+
file.file.seek(0, 2)
84+
offset = file.file.tell()
85+
r += offset
86+
87+
return r + len(path)
88+
89+
6990
@pytest.mark.asyncio
7091
async def test_stream_data(event_loop, server, client):
7192
cl = client._max_response_content_length
@@ -81,6 +102,19 @@ async def test_stream_data(event_loop, server, client):
81102
assert l == cl
82103

83104

105+
@pytest.mark.asyncio
106+
async def test_request(event_loop, server, client):
107+
import io
108+
109+
data = [
110+
("files", ("a.png", io.BytesIO(b"data:a"), "image/png", {"x-extra": "yes"})),
111+
("files", ("b.png", io.BytesIO(b"data:b"))),
112+
("path", "media/images"),
113+
]
114+
size = await client._.request_streaming(data=data)
115+
assert size == 24
116+
117+
84118
@pytest.mark.asyncio
85119
async def test_stream_array(event_loop, server, client):
86120
import ijson

0 commit comments

Comments
 (0)