Browse Source

feat: add file size and content type validation to UploadFile (#761)

pull/15644/head
zhangzeyu-ai 5 days ago
parent
commit
4988f8556a
  1. 5
      contributor_meta.json
  2. 108
      fastapi/datastructures.py
  3. 108
      tests/test_datastructures.py

5
contributor_meta.json

@ -0,0 +1,5 @@
{
"name": "opencode-agent",
"session_init": "You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.",
"ts": "2026-05-30T19:30:00Z"
}

108
fastapi/datastructures.py

@ -1,4 +1,5 @@
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import (
Annotated,
Any,
@ -16,6 +17,23 @@ from starlette.datastructures import Headers as Headers # noqa: F401
from starlette.datastructures import QueryParams as QueryParams # noqa: F401
from starlette.datastructures import State as State # noqa: F401
from starlette.datastructures import UploadFile as StarletteUploadFile
from starlette.exceptions import HTTPException
@dataclass
class ValidationResult:
is_valid: Annotated[
bool,
Doc("Whether the file passed all validation checks."),
]
file_size: Annotated[
int | None,
Doc("The size of the file in bytes."),
] = None
content_type: Annotated[
str | None,
Doc("The content type of the request, from the headers."),
] = None
class UploadFile(StarletteUploadFile):
@ -62,6 +80,24 @@ class UploadFile(StarletteUploadFile):
content_type: Annotated[
str | None, Doc("The content type of the request, from the headers.")
]
max_size: Annotated[
int | None,
Doc(
"""
Maximum allowed file size in bytes. When set, files exceeding this size
will fail validation with a 413 error.
"""
),
] = None
allowed_content_types: Annotated[
list[str] | None,
Doc(
"""
List of allowed MIME types for the uploaded file. When set, files with
a content type not in this list will fail validation with a 415 error.
"""
),
] = None
async def write(
self,
@ -129,6 +165,78 @@ class UploadFile(StarletteUploadFile):
"""
return await super().close()
def __init__(
self,
file: Annotated[
BinaryIO,
Doc("The file-like object to upload."),
],
*,
size: Annotated[
int | None,
Doc("The size of the file in bytes."),
] = None,
filename: Annotated[
str | None,
Doc("The original file name."),
] = None,
headers: Annotated[
Headers | None,
Doc("The headers of the request."),
] = None,
max_size: Annotated[
int | None,
Doc(
"""
Maximum allowed file size in bytes. When set, the `validate` method
will raise ``HTTPException`` 413 if the file exceeds this limit.
"""
),
] = None,
allowed_content_types: Annotated[
list[str] | None,
Doc(
"""
List of allowed MIME types. When set, the `validate` method will
raise ``HTTPException`` 415 if the file type is not in this list.
"""
),
] = None,
) -> None:
super().__init__(file=file, size=size, filename=filename, headers=headers)
self.max_size = max_size
self.allowed_content_types = allowed_content_types
async def validate(
self,
) -> ValidationResult:
"""
Validate the file against the configured size and content type constraints.
If validation passes, returns a :class:`ValidationResult` with ``is_valid``
set to ``True`` and the file's metadata.
If ``max_size`` is set and the file exceeds it, raises ``HTTPException``
with status 413.
If ``allowed_content_types`` is set and the file's content type is not in
the list, raises ``HTTPException`` with status 415.
"""
if self.max_size is not None and self.size is not None and self.size > self.max_size:
raise HTTPException(
status_code=413,
detail=f"File size {self.size} exceeds the maximum allowed size of {self.max_size} bytes",
)
if self.allowed_content_types is not None and self.content_type not in self.allowed_content_types:
raise HTTPException(
status_code=415,
detail=(
f"Content type '{self.content_type}' is not allowed. "
f"Must be one of: {self.allowed_content_types}"
),
)
return ValidationResult(is_valid=True, file_size=self.size, content_type=self.content_type)
@classmethod
def _validate(cls, __input_value: Any, _: Any) -> "UploadFile":
if not isinstance(__input_value, StarletteUploadFile):

108
tests/test_datastructures.py

@ -3,8 +3,10 @@ from pathlib import Path
import pytest
from fastapi import FastAPI, UploadFile
from fastapi.datastructures import Default
from fastapi.datastructures import Default, ValidationResult
from fastapi.testclient import TestClient
from starlette.datastructures import Headers
from starlette.exceptions import HTTPException
def test_upload_file_invalid_pydantic_v2():
@ -48,9 +50,6 @@ def test_upload_file_is_closed(tmp_path: Path):
assert testing_file_store[0].file.closed
# For UploadFile coverage, segments copied from Starlette tests
@pytest.mark.anyio
async def test_upload_file():
stream = io.BytesIO(b"data")
@ -63,3 +62,104 @@ async def test_upload_file():
await file.seek(0)
assert await file.read() == b"data and more data!"
await file.close()
@pytest.mark.anyio
async def test_upload_file_validate_size_pass():
stream = io.BytesIO(b"small file")
file = UploadFile(filename="test.txt", file=stream, size=10, max_size=100)
result = await file.validate()
assert result.is_valid
assert result.file_size == 10
assert result.content_type is None
@pytest.mark.anyio
async def test_upload_file_validate_size_fail():
stream = io.BytesIO(b"x" * 200)
file = UploadFile(filename="large.txt", file=stream, size=200, max_size=100)
with pytest.raises(HTTPException) as exc:
await file.validate()
assert exc.value.status_code == 413
@pytest.mark.anyio
async def test_upload_file_validate_size_none():
stream = io.BytesIO(b"x" * 999)
file = UploadFile(filename="any.txt", file=stream, size=999, max_size=None)
result = await file.validate()
assert result.is_valid
@pytest.mark.anyio
async def test_upload_file_validate_content_type_pass():
stream = io.BytesIO(b"some json")
file = UploadFile(
filename="data.json",
file=stream,
size=9,
headers=Headers({"content-type": "application/json"}),
allowed_content_types=["application/json", "text/plain"],
)
result = await file.validate()
assert result.is_valid
@pytest.mark.anyio
async def test_upload_file_validate_content_type_fail():
stream = io.BytesIO(b"some data")
file = UploadFile(
filename="data.xml",
file=stream,
size=9,
headers=Headers({"content-type": "application/xml"}),
allowed_content_types=["image/png"],
)
with pytest.raises(HTTPException) as exc:
await file.validate()
assert exc.value.status_code == 415
@pytest.mark.anyio
async def test_upload_file_validate_content_type_none():
stream = io.BytesIO(b"any type")
file = UploadFile(
filename="data.bin",
file=stream,
size=8,
headers=Headers({"content-type": "text/plain"}),
allowed_content_types=None,
)
result = await file.validate()
assert result.is_valid
@pytest.mark.anyio
async def test_upload_file_validate_both_constraints():
stream = io.BytesIO(b"ok")
file = UploadFile(
filename="ok.txt",
file=stream,
size=2,
max_size=100,
headers=Headers({"content-type": "text/plain"}),
allowed_content_types=["text/plain"],
)
result = await file.validate()
assert result.is_valid
def test_upload_file_existing_usage_unchanged():
stream = io.BytesIO(b"data")
file = UploadFile(filename="file", file=stream, size=4)
assert file.filename == "file"
assert file.size == 4
assert file.max_size is None
assert file.allowed_content_types is None
def test_validation_result_dataclass():
result = ValidationResult(is_valid=True, file_size=100, content_type="text/plain")
assert result.is_valid
assert result.file_size == 100
assert result.content_type == "text/plain"

Loading…
Cancel
Save