Menerima file dengan UploadFile dan File, memvalidasi tipe MIME dan ukuran, menyimpan file dengan nama aman, serta melayani static files dan media yang telah diunggah lewat StaticFiles dan FileResponse.

Setelah di episode 14 API kalian bisa bekerja di belakang layar dengan background tasks dan database async, sekarang kita tangani salah satu kebutuhan paling umum di aplikasi nyata: file. Avatar user, lampiran, dokumen, gambar produk — hampir tidak ada aplikasi yang tidak pernah menerima upload.
Mengapa episode ini penting? Karena upload file adalah salah satu titik paling berbahaya di API: eksekusi file berbahaya, path traversal, dan file yang menghabiskan disk adalah risiko nyata. Episode ini membangun upload yang aman — dan melayani file kembali ke klien dengan benar.
Upload memakai multipart/form-data, yang butuh package python-multipart (sama seperti Form di episode 6):
pip install python-multipartTanpa ini, FastAPI error saat import File atau UploadFile.
Dua cara menerima file: bytes (seluruh file di memory) atau UploadFile (streaming, cocok untuk file besar):
from pathlib import Path
from fastapi import FastAPI, File, UploadFile
app = FastAPI()
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
@app.post("/upload/")
async def upload_file(file: UploadFile = File(...)) -> dict[str, str]:
destination = UPLOAD_DIR / file.filename
with destination.open("wb") as buffer:
while chunk := await file.read(1024 * 1024):
buffer.write(chunk)
return {"filename": file.filename, "size": destination.stat().st_size}Poin penting:
UploadFile membaca file bertahap (chunk) — tidak memuat file 2 GB ke memory.while chunk := await file.read(...) adalah idiom Python 3.8+ untuk membaca sampai habis.file.filename berisi nama asli dari klien.Caution
Kode di atas memakai file.filename langsung sebagai nama file tujuan — jangan pernah lakukan ini di produksi. Nama dari klien bisa mengandung path traversal (../../etc/passwd) dan karakter berbahaya. Kita perbaiki di bagian "Nama File yang Aman".
File yang masuk harus divalidasi. Dua lapis: tipe (yang diperbolehkan) dan ukuran (yang wajar):
from fastapi import FastAPI, File, HTTPException, UploadFile
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
MAX_SIZE = 5 * 1024 * 1024 # 5 MB
@app.post("/images/")
async def upload_image(file: UploadFile = File(...)) -> dict[str, str]:
if file.content_type not in ALLOWED_TYPES:
raise HTTPException(
status_code=415,
detail="Hanya JPG, PNG, dan WebP yang diizinkan",
)
size = 0
destination = Path(f"uploads/{file.filename}")
with destination.open("wb") as buffer:
while chunk := await file.read(1024 * 1024):
size += len(chunk)
if size > MAX_SIZE:
destination.unlink(missing_ok=True)
raise HTTPException(
status_code=413,
detail="Ukuran file melebihi 5 MB",
)
buffer.write(chunk)
return {"filename": file.filename, "size": size}Perhatikan: validasi ukuran dilakukan saat membaca chunk, bukan setelah selesai — sehingga file yang terlalu besar ditolak sebelum menghabiskan disk.
Warning
file.content_type datang dari klien dan bisa dipalsukan. Untuk keamanan nyata, verifikasi isi file — misalnya dengan memeriksa magic bytes (png, jpeg, webp) atau library seperti Pillow untuk gambar. Content type klien hanya lapis pertama.
Selalu buat nama penyimpanan sendiri — jangan percaya nama dari klien. Pola umum: UUID + ekstensi yang disanitasi:
import uuid
from pathlib import Path
from fastapi import UploadFile
def safe_filename(file: UploadFile) -> str:
ext = Path(file.filename or "").suffix.lower()
allowed_ext = {".jpg", ".jpeg", ".png", ".webp"}
if ext not in allowed_ext:
raise ValueError("ekstensi tidak diizinkan")
return f"{uuid.uuid4().hex}{ext}"Dengan pola ini, file tersimpan sebagai a1b2c3....png — unik, tanpa path traversal, dan ekstensi tetap dipertahankan (penting untuk content-type saat di-serve).
Upload nyata sering menyertakan metadata. Kombinasi Form dan UploadFile dalam satu endpoint:
from fastapi import FastAPI, File, Form, UploadFile
app = FastAPI()
@app.post("/documents/")
async def upload_document(
title: str = Form(...),
category: str = Form("general"),
file: UploadFile = File(...),
) -> dict[str, str]:
return {"title": title, "category": category, "file": file.filename}Klien mengirim multipart dengan field title, category, dan file — frontend <form> dengan enctype="multipart/form-data" bisa langsung memakai endpoint ini.
Untuk melayani file yang sudah diunggah (dan asset frontend), gunakan StaticFiles:
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
app = FastAPI()
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
app.mount("/static", StaticFiles(directory="static"), name="static")Setelah mount, file uploads/abc.png bisa diakses di http://host/uploads/abc.png — tanpa menulis endpoint. Perbedaan mount vs path operation: mount mendelegasikan seluruh sub-path ke aplikasi Starlette terpisah, bukan melewati routing FastAPI.
Tip
Perhatikan urutan: mount/path operations tidak boleh bentrok. Jika kalian punya route /uploads/ sebagai endpoint, jangan mount StaticFiles di path yang sama. Di produksi, file media biasanya dilayani langsung oleh CDN/reverse proxy (episode 24) karena lebih efisien.
Untuk men-download file sebagai attachment:
from pathlib import Path
from fastapi.responses import FileResponse
@app.get("/uploads/{filename}")
def download_file(filename: str) -> FileResponse:
file_path = Path("uploads") / filename
if not file_path.is_file():
raise HTTPException(status_code=404, detail="File tidak ditemukan")
return FileResponse(
file_path,
media_type="application/octet-stream",
filename=file_path.name,
)FileResponse menangani streaming file besar tanpa memuat ke memory — dan dengan filename=..., browser akan menawarkan download, bukan membukanya.
| Pitfall | Solusi |
|---|---|
| Nama file klien dipakai langsung | Sanitasi + UUID |
| Validasi hanya content type | Verifikasi magic bytes/isi |
| Ukuran tidak dibatasi | Tolak saat chunk melebihi batas |
Upload ke memory dengan bytes | UploadFile + chunk untuk file besar |
| Path traversal | Jangan terima path dari klien |
Inti yang harus dibawa pulang:
UploadFile untuk streaming; butuh python-multipart.StaticFiles untuk melayani media; FileResponse untuk download.Di episode 16 selanjutnya kita akan membahas configuration & environment — memindahkan secret dan setting ke pydantic-settings dengan file .env, membuat konfigurasi terpusat yang bertipe, serta memakai environment-specific config. Ini langkah penting menuju deployment!