mirror of
https://github.com/fastapi/fastapi.git
synced 2026-10-29 21:30:48 -04:00
🐛 Treat unselected browser file inputs as missing (#16534)
This commit is contained in:
1 parent
4001c90dad
commit
0890e2a329
3 files changed
+127
-27
No files matched your search
@@ -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)`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user