Skip to content

Direct-to-Cloud Uploads

For large files, don't proxy the bytes through your API — presign an upload URL and let the client talk to S3 (or GCS/Azure) directly. This example presigns uploads and downloads, tracks keys, and cleans up.

Full Example

app.py
import uuid
from pathlib import PurePosixPath

from fastapi import FastAPI, HTTPException
from filestore import NotSupportedError, PresignedURL, S3Engine
from pydantic import BaseModel

app = FastAPI()
engine = S3Engine(bucket="my-uploads", region="us-east-1")

ALLOWED_TYPES = {"image/png", "image/jpeg", "application/pdf"}


class PresignRequest(BaseModel):
    filename: str
    content_type: str


@app.post("/uploads/presign")
async def presign_upload(body: PresignRequest) -> PresignedURL:
    if body.content_type not in ALLOWED_TYPES:
        raise HTTPException(422, f"Content type {body.content_type!r} not allowed")

    # Never trust the client filename for the key
    suffix = PurePosixPath(body.filename).suffix.lower()
    key = f"uploads/{uuid.uuid4()}{suffix}"

    return await engine.presign_upload(
        key,
        expires_in=600,
        content_type=body.content_type,  # locked into the signature
    )


@app.get("/uploads/{key:path}/download")
async def presign_download(key: str) -> PresignedURL:
    try:
        return await engine.presign_download(key, expires_in=300)
    except NotSupportedError:
        raise HTTPException(400, "This backend does not support presigned downloads")


@app.delete("/uploads/{key:path}")
async def delete_upload(key: str):
    await engine.delete(key)
    return {"deleted": key}

Client Side

// 1. Ask the API for a presigned URL
const presigned = await (
  await fetch("/uploads/presign", {
    method: "POST",
    headers: {"Content-Type": "application/json"},
    body: JSON.stringify({filename: file.name, content_type: file.type}),
  })
).json();

// 2. Upload straight to cloud storage — bytes never touch your API
await fetch(presigned.url, {
  method: presigned.method,       // "PUT"
  headers: presigned.headers,     // must be sent verbatim
  body: file,
});

// 3. Tell your API the upload finished (verify + persist presigned.key)

Key Points

  • Server generates the key — a UUID plus the original extension; the client filename is never trusted
  • Content type is signed — the client can't upload a different type than it declared
  • Short expiries — 10 minutes for uploads, 5 for downloads
  • Works identically with GCSEngine (service-account credentials) and AzureBlobEngine (connection-string auth)
  • Swap in endpoint_url="http://localhost:9000" on S3Engine to develop against MinIO