From 0890e2a32960bb5cc1cfb807dca3a5e10384ec9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Tue, 20 Oct 2026 00:27:28 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Treat=20unselected=20browser=20f?= =?UTF-8?q?ile=20inputs=20as=20missing=20(#16534)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 2 + fastapi/dependencies/utils.py | 12 +- .../test_file/test_nullable_and_defaults.py | 140 ++++++++++++++---- 3 files changed, 127 insertions(+), 27 deletions(-) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index e397903dd5..d047fa4606 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,8 @@ hide: ## Latest Changes +When no file is selected, browsers submit a file part with an empty filename and empty content. FastAPI now treats this as missing: required file fields return a validation error, and fields with defaults or default factories use them. Unselected inputs are ignored in lists of files. Named zero-byte files are still accepted. + ## 0.149.0 (2026-10-19) Browsers submit empty text inputs as empty strings (`""`). FastAPI now treats empty scalar form values as missing in form models, matching individual `Form` parameters. Required fields return a validation error. Fields with defaults or default factories use them and remain unset in `model_dump(exclude_unset=True)`. diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 0ca505841c..1fb9fd5025 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -747,6 +747,11 @@ def _is_json_field(field: ModelField) -> bool: return any(type(item) is Json for item in field.field_info.metadata) +def _is_empty_file_input(value: Any) -> bool: + # Browsers send an empty filename and content when no file is selected. + return isinstance(value, UploadFile) and value.filename == "" and value.size == 0 + + def _get_multidict_value( field: ModelField, values: Mapping[str, Any], alias: str | None = None ) -> Any: @@ -757,14 +762,17 @@ def _get_multidict_value( and isinstance(values, (ImmutableMultiDict, Headers)) ): value = values.getlist(alias) + if isinstance(values, FormData): + value = [item for item in value if not _is_empty_file_input(item)] else: value = values.get(alias, None) if ( value is None or ( isinstance(values, FormData) - and isinstance(value, str) # For type checks - and value == "" + and ( + (isinstance(value, str) and value == "") or _is_empty_file_input(value) + ) ) or ( field_annotation_is_sequence(field.field_info.annotation) diff --git a/tests/test_request_params/test_file/test_nullable_and_defaults.py b/tests/test_request_params/test_file/test_nullable_and_defaults.py index 443b844f85..277af65e6b 100644 --- a/tests/test_request_params/test_file/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_file/test_nullable_and_defaults.py @@ -1,9 +1,10 @@ from typing import Annotated import pytest -from fastapi import FastAPI, File, UploadFile +from fastapi import FastAPI, File, Form, UploadFile from fastapi.testclient import TestClient from inline_snapshot import snapshot +from pydantic import BaseModel, Field app = FastAPI() @@ -80,14 +81,7 @@ EMPTY_FILE_FORM = ( "content", [ pytest.param(b"", id="omitted"), - pytest.param( - EMPTY_FILE_FORM, - id="no-selection", - marks=pytest.mark.xfail( - reason="A file with an empty filename and content is treated as supplied", - raises=AssertionError, - ), - ), + pytest.param(EMPTY_FILE_FORM, id="no-selection"), ], ) def test_missing_required_nullable_files(path, content): @@ -108,14 +102,7 @@ def test_missing_required_nullable_files(path, content): "content", [ pytest.param(b"", id="omitted"), - pytest.param( - EMPTY_FILE_FORM, - id="no-selection", - marks=pytest.mark.xfail( - reason="A file with an empty filename and content is treated as supplied", - raises=AssertionError, - ), - ), + pytest.param(EMPTY_FILE_FORM, id="no-selection"), ], ) def test_missing_optional_nullable_files(path, content): @@ -158,14 +145,7 @@ def test_named_file_is_supplied_even_when_empty(path, content): "content", [ pytest.param(b"", id="omitted"), - pytest.param( - EMPTY_FILE_FORM, - id="no-selection", - marks=pytest.mark.xfail( - reason="A file with an empty filename and content is treated as supplied", - raises=AssertionError, - ), - ), + pytest.param(EMPTY_FILE_FORM, id="no-selection"), ], ) def test_missing_files_use_bytes_defaults(content): @@ -191,6 +171,116 @@ def test_supplied_files_override_bytes_defaults(content, expected): assert response.json() == {"file": expected, "files": [expected]} +@pytest.mark.parametrize("path", ["/required-bytes", "/required-uploadfile"]) +def test_file_without_a_name_is_supplied_when_it_has_content(path): + response = client.post( + path, + content=( + b"--boundary\r\n" + b'Content-Disposition: form-data; name="file"; filename=""\r\n' + b"Content-Type: application/octet-stream\r\n\r\n" + b"hello\r\n--boundary\r\n" + b'Content-Disposition: form-data; name="files"; filename=""\r\n' + b"Content-Type: application/octet-stream\r\n\r\n" + b"hello\r\n--boundary--\r\n" + ), + headers={"content-type": "multipart/form-data; boundary=boundary"}, + ) + assert response.status_code == 200 + assert response.json() == {"file": 5, "files": [5]} + + +@pytest.mark.parametrize("path", ["/required-bytes", "/required-uploadfile"]) +def test_file_list_ignores_unselected_inputs(path): + response = client.post( + path, + content=( + b"--boundary\r\n" + b'Content-Disposition: form-data; name="file"; filename="empty.txt"\r\n' + b"Content-Type: application/octet-stream\r\n\r\n" + b"\r\n--boundary\r\n" + b'Content-Disposition: form-data; name="files"; filename="first.txt"\r\n' + b"Content-Type: application/octet-stream\r\n\r\n" + b"\r\n--boundary\r\n" + b'Content-Disposition: form-data; name="files"; filename=""\r\n' + b"Content-Type: application/octet-stream\r\n\r\n" + b"\r\n--boundary\r\n" + b'Content-Disposition: form-data; name="files"; filename="last.txt"\r\n' + b"Content-Type: application/octet-stream\r\n\r\n" + b"hello\r\n--boundary--\r\n" + ), + headers={"content-type": "multipart/form-data; boundary=boundary"}, + ) + assert response.status_code == 200 + assert response.json() == {"file": 0, "files": [0, 5]} + + +def test_multipart_text_items_in_bytes_list_are_preserved(): + response = client.post( + "/required-bytes", + files=[ + ("file", ("empty.txt", b"")), + ("files", (None, "")), + ("files", (None, "text")), + ("files", ("selected.txt", b"hello")), + ], + ) + assert response.status_code == 200 + assert response.json() == {"file": 0, "files": [0, 4, 5]} + + +def test_unselected_files_in_form_model_use_defaults_and_remain_unset(): + app = FastAPI() + + class FileModel(BaseModel): + file: UploadFile | None = None + files: Annotated[list[UploadFile], Field(default_factory=list)] + + @app.post("/") + def upload(params: Annotated[FileModel, Form()]): + assert params.file is None + assert params.files == [] + return params.model_dump(exclude_unset=True) + + client = TestClient(app) + response = client.post( + "/", + content=EMPTY_FILE_FORM, + headers={"content-type": "multipart/form-data; boundary=boundary"}, + ) + assert response.status_code == 200 + assert response.json() == {} + + +def test_required_non_nullable_files(): + app = FastAPI() + + @app.post("/") + def upload( + file: Annotated[UploadFile, File()], files: Annotated[list[bytes], File()] + ): + return {"file": file.size, "files": [len(value) for value in files]} + + client = TestClient(app) + response = client.post( + "/", + content=EMPTY_FILE_FORM, + headers={"content-type": "multipart/form-data; boundary=boundary"}, + ) + assert response.status_code == 422 + assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [ + ("missing", ["body", "file"]), + ("missing", ["body", "files"]), + ] + + response = client.post( + "/", + files=[("file", ("empty.txt", b"")), ("files", ("empty.txt", b""))], + ) + assert response.status_code == 200 + assert response.json() == {"file": 0, "files": [0]} + + def test_openapi_schema(): response = client.get("/openapi.json") assert response.status_code == 200, response.text