Uploads — local disk + S3 / MinIO¶
Since v0.24.0 UploadUtils accepts a pluggable storage backend: keep the same upload code and switch between local disk and MinIO/S3 via a settings flag.
Validation stays in UploadUtils
Size, extension, MIME, magic bytes and content_validator are still validated by UploadUtils before any byte hits the backend — backends only see validated data.
Available backends¶
| Backend | When to use | Required extra |
|---|---|---|
LocalUploadStorage |
Dev / single-replica / local FS | [upload] |
MinIOUploadStorage |
Multi-replica, S3/MinIO/R2/B2/Spaces | [upload] + [minio] |
Both implement the UploadStorage protocol (write_stream, delete, exists, presigned_url).
Default: local disk (backwards-compat)¶
No change required — UploadUtils(upload_dir) keeps writing to disk:
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]:
"""Persist the file to disk and return the absolute path."""
path = await utils.save(file)
return {"path": str(path)}
Switching to MinIO/S3¶
Reuse the same AsyncMinIOClient the app already owns:
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", # still required but unused when storage= is passed
max_size_bytes=10 * 1024 * 1024,
)
router = APIRouter()
@router.post("/files")
async def upload(file: UploadFile) -> dict[str, str]:
"""Validate then push straight to MinIO."""
# `file.filename` is `str | None` — fall back to a stable name when the
# client omits the Content-Disposition header.
safe_name = file.filename or "upload.bin"
key = await utils.save(file, storage=remote, filename=safe_name)
return {"key": key.as_posix()}
Flag-driven backend selection¶
Add an UPLOAD_BACKEND field to your Settings (the SDK's UploadSettings only carries UPLOAD_DIR / UPLOAD_MAX_SIZE_BYTES / UPLOAD_ALLOWED_EXTENSIONS / UPLOAD_ALLOWED_MIMETYPES; the backend selector flag belongs to the consuming project).
# 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:
"""Pick the backend based on environment configuration."""
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)
Common operations¶
# Save
await utils.save(file, storage=storage, filename="logo.png")
# Delete
await storage.delete("logo.png")
# Probe
exists = await storage.exists("logo.png")
# Temporary URL (S3 returns a URL; local returns None)
from datetime import timedelta
url = await storage.presigned_url("logo.png", expires=timedelta(hours=1))
When to use presigned PUT directly¶
For files > 50 MB, skip the in-memory buffer — have the client PUT straight to MinIO via a presigned URL. See Storage MinIO/S3.