diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 7be8f774c7..8c2cc2a243 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -1058,6 +1058,15 @@ def _extract_form_items( yield key, param_values, None +async def _read_file_bytes(file: UploadFile) -> bytes: + # Automatic conversion must not consume a file shared with other parameters. + position = file.file.tell() + try: + return await file.read() + finally: + await file.seek(position) + + async def _extract_form_body( body_fields: list[ModelField], received_body: FormData, @@ -1067,7 +1076,7 @@ async def _extract_form_body( if is_bytes_or_nonable_bytes_annotation( field.field_info.annotation ) and isinstance(value, UploadFile): - value = await value.read() + value = await _read_file_bytes(value) elif is_bytes_sequence_annotation( field.field_info.annotation ) and value_is_sequence(value): @@ -1075,7 +1084,7 @@ async def _extract_form_body( results: list[bytes | str] = [] for sub_value in value: if isinstance(sub_value, UploadFile): - results.append(await sub_value.read()) + results.append(await _read_file_bytes(sub_value)) else: results.append(sub_value) value = serialize_sequence_value(field=field, value=results) diff --git a/tests/test_upload_file_read_position.py b/tests/test_upload_file_read_position.py new file mode 100644 index 0000000000..8e8a8ec592 --- /dev/null +++ b/tests/test_upload_file_read_position.py @@ -0,0 +1,147 @@ +from io import BytesIO +from typing import Annotated + +import pytest +from fastapi import Depends, FastAPI, File, UploadFile +from fastapi.dependencies.utils import _read_file_bytes +from fastapi.testclient import TestClient + + +@pytest.mark.parametrize( + "content", + [ + pytest.param(b"hello", id="memory"), + pytest.param(b"x" * (1024 * 1024 + 1), id="disk"), + ], +) +def test_file_bytes_shared_by_dependency_and_endpoint(content: bytes): + app = FastAPI() + + def inspect_file(file: Annotated[bytes, File()]): + return file.decode() + + @app.post("/") + def upload( + inspected: Annotated[str, Depends(inspect_file)], + file: Annotated[bytes, File()], + ): + return {"dependency": inspected, "endpoint": file.decode()} + + client = TestClient(app) + response = client.post("/", files={"file": ("hello.txt", content)}) + assert response.status_code == 200 + assert response.json() == { + "dependency": content.decode(), + "endpoint": content.decode(), + } + + +def test_file_bytes_list_shared_by_dependency_and_endpoint(): + app = FastAPI() + + def inspect_files(files: Annotated[list[bytes], File()]): + return [file.decode() for file in files] + + @app.post("/") + def upload( + inspected: Annotated[list[str], Depends(inspect_files)], + files: Annotated[list[bytes], File()], + ): + return {"dependency": inspected, "endpoint": [file.decode() for file in files]} + + client = TestClient(app) + response = client.post( + "/", + files=[ + ("files", ("one.txt", b"one")), + ("files", ("two.txt", b"two")), + ], + ) + assert response.status_code == 200 + assert response.json() == { + "dependency": ["one", "two"], + "endpoint": ["one", "two"], + } + + +def test_bytes_dependency_preserves_upload_file_for_endpoint(): + app = FastAPI() + + def inspect_file(file: Annotated[bytes, File()]): + return file.decode() + + @app.post("/") + async def upload( + inspected: Annotated[str, Depends(inspect_file)], + file: Annotated[UploadFile, File()], + ): + content = await file.read() + second_read = await file.read() + return { + "dependency": inspected, + "endpoint": content.decode(), + "second_read": second_read.decode(), + } + + client = TestClient(app) + response = client.post("/", files={"file": ("hello.txt", b"hello")}) + assert response.status_code == 200 + assert response.json() == { + "dependency": "hello", + "endpoint": "hello", + "second_read": "", + } + + +def test_automatic_file_read_preserves_nonzero_position(): + app = FastAPI() + + async def read_prefix(file: Annotated[UploadFile, File()]): + return (await file.read(2)).decode() + + def inspect_remainder( + prefix: Annotated[str, Depends(read_prefix)], + file: Annotated[bytes, File()], + ): + return {"prefix": prefix, "remainder": file.decode()} + + @app.post("/") + async def upload( + inspected: Annotated[dict[str, str], Depends(inspect_remainder)], + file: Annotated[UploadFile, File()], + ): + position = file.file.tell() + remainder = await file.read() + return { + "dependency": inspected, + "position": position, + "remainder": remainder.decode(), + } + + client = TestClient(app) + response = client.post("/", files={"file": ("hello.txt", b"hello")}) + assert response.status_code == 200 + assert response.json() == { + "dependency": {"prefix": "he", "remainder": "llo"}, + "position": 2, + "remainder": "llo", + } + + +@pytest.mark.anyio +async def test_failed_automatic_file_read_restores_position( + monkeypatch: pytest.MonkeyPatch, +): + with BytesIO(b"hello") as stream: + file = UploadFile(file=stream) + stream.seek(2) + + async def failing_read(size: int = -1) -> bytes: + stream.read(1) + raise OSError("read failed") + + monkeypatch.setattr(file, "read", failing_read) + with pytest.raises(OSError, match="read failed"): + await _read_file_bytes(file) + + assert stream.tell() == 2