FastAPI同时上传文件和JSON参数

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
posted @ 2023-10-24 21:13  waketzheng  阅读(1103)  评论(0编辑  收藏  举报