Talk is cheap, show me the code:
# main.py
# Python3.10+
import hashlib
import json
from contextlib import asynccontextmanager
from enum import IntEnum
from typing import Annotated
from urllib.parse import quote
from anyio import Path
from fastapi import (
Body,
Depends,
FastAPI,
File,
Form,
HTTPException,
Request,
UploadFile,
)
from fastapi.openapi.docs import (
get_redoc_html,
get_swagger_ui_html,
get_swagger_ui_oauth2_redirect_html,
)
from fastapi.params import Form as FormType
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Json, model_validator
BASE_DIR = Path(__file__).parent
MEDIA_URL = "/media"
MEDIA_ROOT = BASE_DIR / "media"
UPLOAD_ROOT = MEDIA_ROOT / "uploads"
FILE_LIMIT: tuple[int, Literal["K", "M", "G"]] = (5, "M")
class Singleton:
def __init__(self, cls):
self._cls = cls
self._cached = {}
def __call__(self, *args, **kwargs):
if (cls := self._cached.get(self._cls)) is None:
cls = self._cached[self._cls] = self._cls(*args, **kwargs)
return cls
class Unit(IntEnum):
K = 1024
M = K * 1024
G = M * 1024
@Singleton
class SingleFileLimit:
size = FILE_LIMIT[0]
unit = Unit[FILE_LIMIT[1]]
@property
def value(self) -> int:
return self.size * self.unit.value
@property
def desc(self) -> str:
return f"{self.size}{self.unit.name}"
def check_file_size(content: bytes) -> None:
if len(content) > SingleFileLimit().value:
msg = f"File size must be <= {SingleFileLimit().desc}"
raise HTTPException(status_code=400, detail=msg)
async def ensure_dir_exists(path: Path) -> None:
if not await path.exists():
await path.mkdir(parents=True)
@asynccontextmanager
async def lifespan(app: FastAPI):
await ensure_dir_exists(UPLOAD_ROOT)
app.mount("/media", StaticFiles(directory=MEDIA_ROOT.name), name="media")
yield
app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None)
@app.get("/docs", include_in_schema=False)
async def custom_swagger_ui_html():
return get_swagger_ui_html(
openapi_url=app.openapi_url,
title=app.title + " - Swagger UI",
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
swagger_js_url="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js",
swagger_css_url="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css",
)
@app.get(
app.swagger_ui_oauth2_redirect_url or "/docs/oauth2-redirect",
include_in_schema=False,
)
async def swagger_ui_redirect():
return get_swagger_ui_oauth2_redirect_html()
@app.get("/redoc", include_in_schema=False)
async def redoc_html():
return get_redoc_html(
openapi_url=app.openapi_url,
title=app.title + " - ReDoc",
redoc_js_url="https://unpkg.com/redoc@next/bundles/redoc.standalone.js",
)
def get_host(req: Request) -> str:
return getattr(req, "headers", {}).get("host") or "http://127.0.0.1:8000"
@app.post("/put-cert")
async def put_cert(request: Request, file: Annotated[bytes, File()]) -> str:
check_file_size(file)
filename = hashlib.md5(file).hexdigest() + ".png"
if not await (fpath := UPLOAD_ROOT / filename).exists():
await ensure_dir_exists(fpath.parent)
await fpath.write_bytes(file)
host = get_host(request)
path = fpath.relative_to(MEDIA_ROOT).as_posix()
return host + MEDIA_URL + "/" + path
@app.post("/put-object")
async def put_object(request: Request, file: Annotated[UploadFile, File()]) -> str:
content = await file.read()
check_file_size(content)
filename = file.filename
if await (fpath := UPLOAD_ROOT / filename).exists():
if (await fpath.read_bytes()) != content:
for i in range(1, 100000):
new_name = fpath.stem + f"({i})" + fpath.suffix
if await (fpath := fpath.with_name(new_name)).exists():
if (await fpath.read_bytes()) == content:
break
else:
await fpath.write_bytes(content)
break
else:
await ensure_dir_exists(fpath.parent)
await fpath.write_bytes(content)
fpath = fpath.with_name(quote(fpath.name))
host = get_host(request)
path = fpath.relative_to(MEDIA_ROOT).as_posix()
return host + MEDIA_URL + "/" + path
class QueryData(BaseModel):
field1: str
field2: int
@app.post("/put-with-query")
async def put_with_query(
request: Request,
q: Annotated[QueryData, Depends()],
file: Annotated[UploadFile, File()],
) -> dict[str, int | str | None]:
assert isinstance(q.field1, str)
assert isinstance(q.field2, int)
return {
"field1": q.field1,
"field2": q.field2,
"filename": file.filename,
"size": len(await file.read()),
}
class JsonForm(Json, FormType):
...
@app.post("/put-with-form")
async def put_with_form(
file: Annotated[bytes, File()],
fileb: Annotated[UploadFile, File()],
token: Annotated[str, Form()],
size: Annotated[int, Form()],
q: Annotated[QueryData, JsonForm()],
) -> dict[str, str | int | None]:
assert isinstance(token, str)
assert isinstance(size, int)
assert isinstance(q.field1, str)
assert isinstance(q.field2, int)
return {
"file_size": len(file),
"token": token,
"size": size,
"field1": q.field1,
"field2": q.field2,
"fileb_content_type": fileb.content_type,
}
class MyModel(BaseModel):
field1: str
field2: int
@model_validator(mode="before")
@classmethod
def auto_loads_json_string(cls, data):
if isinstance(data, str) and data.startswith("{"):
data = json.loads(data)
return data
@app.post("/put-with-body")
async def put_with_body(
file: Annotated[bytes, File()],
fileb: Annotated[UploadFile, File()],
data: Annotated[MyModel, Body()],
) -> dict[str, int | str | None]:
assert isinstance(data.field1, str)
assert isinstance(data.field2, int)
return {
"field1": data.field1,
"field2": data.field2,
"file_size": len(file),
"filename": fileb.filename,
"fileb_content_type": fileb.content_type,
}
Start server:
python -m venv venv
source venv/*/python
pip install --upgrade pip fastapi uvicorn
uvicorn main:app --reload
Test API
import json
from io import BytesIO
from pathlib import Path
local_file = Path('my.png')
content = local_file.read_bytes()
host = 'http://127.0.0.1:8000/'
url = host + 'put-with-body'
r = requests.post(url, files={'file': BytesIO(content), 'fileb': BytesIO(content)}, data={'data' :json.dumps({'field1': 'a', 'field2': 1})})
print(r.json())
# {'field1': 'a', 'field2': 1, 'file_size': 385580, 'filename': 'fileb', 'fileb_content_type': None}
url = host + 'put-with-form'
r = requests.post(url, files={'file': BytesIO(content), 'fileb': BytesIO(content)}, data={'token': 'xx', 'size': 1, 'q': json.dumps({'field1': 'a', 'field2': 0})})
print(r.json())
# {'file_size': 385580, 'token': 'xx', 'size': 1, 'field1': 'a', 'field2': 0, 'fileb_content_type': None}
url = host + 'put-with-query'
r = requests.post(url, files={'file': BytesIO(content)}, params={'field1': 'a', 'field2': 0})
print(r.json())
# {'field1': 'a', 'field2': 0, 'filename': 'file', 'size': 385580}
Docs page
http://127.0.0.1:8000/docs
Dependencies versions
(venv) [~/trying/something]$ pip list|grep fastapi
fastapi 0.104.0
(venv) [~/trying/something]$ pip list|grep uvicorn
uvicorn 0.23.2
(venv) [~/trying/something]$ pip list|grep requests
requests 2.31.0