Ir para o conteúdo

Uploads — disco local + S3 / MinIO

Desde v0.24.0 o UploadUtils aceita backend de storage pluggável: mantenha o mesmo código de upload e troque entre disco local e MinIO/S3 via flag de configuração.

Validação fica no UploadUtils

Tamanho, extensão, MIME, magic bytes e content_validator continuam sendo validados no UploadUtils antes de qualquer byte ir pro storage — backends só recebem dados já validados.

Backends disponíveis

Backend Quando usar Extra necessário
LocalUploadStorage Dev / single-replica / FS local [upload]
MinIOUploadStorage Multi-réplica, S3/MinIO/R2/B2/Spaces [upload] + [minio]

Os dois implementam o protocolo UploadStorage (write_stream, delete, exists, presigned_url).

Padrão: disco local (back-compat)

Sem mudar nada — UploadUtils(upload_dir) continua gravando local:

from pathlib import Path

from fastapi import APIRouter, UploadFile
from tempest_fastapi_sdk import UploadUtils

router = APIRouter()
utils = UploadUtils(Path("./uploads"), max_size_bytes=10 * 1024 * 1024)


@router.post("/files")
async def upload(file: UploadFile) -> dict[str, str]:
    """Salva o arquivo em disco e devolve o caminho absoluto."""
    path = await utils.save(file)
    return {"path": str(path)}

Trocando pra MinIO/S3

Reaproveite o AsyncMinIOClient da app:

from fastapi import APIRouter, UploadFile
from tempest_fastapi_sdk import (
    AsyncMinIOClient,
    MinIOUploadStorage,
    UploadUtils,
)

from src.core.settings import settings


client = AsyncMinIOClient(
    endpoint=settings.MINIO_ENDPOINT,
    access_key=settings.MINIO_ACCESS_KEY,
    secret_key=settings.MINIO_SECRET_KEY,
    default_bucket=settings.MINIO_DEFAULT_BUCKET,
)
remote = MinIOUploadStorage(client)
utils = UploadUtils(
    "./tmp",  # ainda obrigatório, mas não usado quando passa storage=
    max_size_bytes=10 * 1024 * 1024,
)

router = APIRouter()


@router.post("/files")
async def upload(file: UploadFile) -> dict[str, str]:
    """Valida e envia direto pro MinIO."""
    # `file.filename` é `str | None` — caia pra um nome estável quando o
    # cliente não envia o header de Content-Disposition.
    safe_name = file.filename or "upload.bin"
    key = await utils.save(file, storage=remote, filename=safe_name)
    return {"key": key.as_posix()}

Alternando via flag de settings

Adicione um campo UPLOAD_BACKEND ao seu Settings (UploadSettings do SDK só carrega UPLOAD_DIR / UPLOAD_MAX_SIZE_BYTES / UPLOAD_ALLOWED_EXTENSIONS / UPLOAD_ALLOWED_MIMETYPES; a flag de seleção de backend é decisão do projeto consumidor).

# src/core/settings.py
from typing import Literal

from pydantic import Field
from tempest_fastapi_sdk import BaseAppSettings, MinIOSettings, UploadSettings


class Settings(MinIOSettings, UploadSettings, BaseAppSettings):
    UPLOAD_BACKEND: Literal["local", "minio"] = Field(
        default="local",
        title="Upload backend",
        description="Selects which UploadStorage wires the upload pipeline.",
        examples=["local", "minio"],
    )


settings = Settings()
# src/api/storage.py
from tempest_fastapi_sdk import (
    AsyncMinIOClient,
    LocalUploadStorage,
    MinIOUploadStorage,
    UploadStorage,
    UploadUtils,
)

from src.core.settings import settings


def make_storage() -> UploadStorage:
    """Pluga o backend correto conforme o ambiente."""
    if settings.UPLOAD_BACKEND == "minio":
        client = AsyncMinIOClient(
            settings.MINIO_ENDPOINT,
            access_key=settings.MINIO_ACCESS_KEY,
            secret_key=settings.MINIO_SECRET_KEY,
            default_bucket=settings.MINIO_DEFAULT_BUCKET,
        )
        return MinIOUploadStorage(client)
    return LocalUploadStorage(settings.UPLOAD_DIR)


storage: UploadStorage = make_storage()
# `UploadUtils.__init__` always mkdirs UPLOAD_DIR — even when the active
# backend is MinIO, the directory is created. Set UPLOAD_DIR to a path
# the process can write to, even if you never read from it.
utils = UploadUtils(settings.UPLOAD_DIR, max_size_bytes=settings.UPLOAD_MAX_SIZE_BYTES)

Operações comuns

# Save
await utils.save(file, storage=storage, filename="logo.png")

# Delete
await storage.delete("logo.png")

# Probe
exists = await storage.exists("logo.png")

# URL temporária (S3 retorna URL; local retorna None)
from datetime import timedelta
url = await storage.presigned_url("logo.png", expires=timedelta(hours=1))

Quando usar presigned PUT direto

Pra arquivos > 50 MB, evite buffer em memória — mande o cliente fazer PUT direto no MinIO via URL presigned. Veja Storage MinIO/S3.