FastAPI #
FastAPI adalah web framework modern untuk membangun API dengan Python 3.8+ yang menggabungkan kecepatan performa tinggi (setara Node.js dan Go), validasi data otomatis via Pydantic, dan dokumentasi interaktif yang di-generate otomatis. FastAPI dibangun di atas Starlette (untuk web) dan Pydantic (untuk data), mendukung async/await secara native, dan mengikuti standar OpenAPI dan JSON Schema. Pilihan utama untuk microservice, REST API, dan ML serving di lingkungan produksi modern.
Instalasi #
pip install fastapi uvicorn[standard] pydantic-settings
Jalankan server development:
uvicorn main:app --reload --host 0.0.0.0 --port 8000
# Dokumentasi otomatis tersedia di:
# http://localhost:8000/docs (Swagger UI)
# http://localhost:8000/redoc (ReDoc)
Aplikasi Pertama #
# main.py
from fastapi import FastAPI
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
# Kode saat startup (koneksi database, load model ML, dll.)
print("Aplikasi dimulai")
yield
# Kode saat shutdown (tutup koneksi, cleanup, dll.)
print("Aplikasi berhenti")
app = FastAPI(
title="MyApp API",
description="REST API untuk aplikasi saya",
version="1.0.0",
lifespan=lifespan
)
@app.get("/")
def root():
return {"message": "Selamat datang di MyApp API"}
@app.get("/health")
def health_check():
return {"status": "ok"}
Pydantic Models — Validasi Data #
Pydantic adalah fondasi FastAPI untuk validasi request body, response schema, dan konfigurasi. Mendefinisikan model yang baik adalah kunci API yang andal.
from pydantic import BaseModel, Field, EmailStr, field_validator
from typing import Optional, List
from decimal import Decimal
from datetime import datetime
from enum import Enum
class StatusProduk(str, Enum):
aktif = "aktif"
nonaktif = "nonaktif"
habis = "habis"
class ProdukBase(BaseModel):
nama: str = Field(..., min_length=2, max_length=200, examples=["Laptop Gaming ASUS"])
deskripsi: str = Field(default="", max_length=5000)
harga: Decimal = Field(..., gt=0, decimal_places=2, examples=[18500000])
stok: int = Field(default=0, ge=0)
status: StatusProduk = StatusProduk.aktif
@field_validator("nama")
@classmethod
def nama_tidak_boleh_angka_saja(cls, v):
if v.strip().isdigit():
raise ValueError("Nama produk tidak boleh hanya angka")
return v.strip()
class ProdukCreate(ProdukBase):
kategori_id: Optional[int] = None
class ProdukUpdate(BaseModel):
nama: Optional[str] = Field(None, min_length=2, max_length=200)
harga: Optional[Decimal] = Field(None, gt=0)
stok: Optional[int] = Field(None, ge=0)
status: Optional[StatusProduk] = None
kategori_id: Optional[int] = None
class ProdukResponse(ProdukBase):
id: int
slug: str
dibuat_pada: datetime
diubah_pada: datetime
model_config = {"from_attributes": True} # Pydantic V2 -- izinkan dari ORM object
class PaginatedResponse(BaseModel):
total: int
halaman: int
per_halaman: int
data: List[ProdukResponse]
Path, Query, dan Body Parameters #
from fastapi import FastAPI, Path, Query, Body, HTTPException, status
from typing import Optional, List
app = FastAPI()
@app.get("/produk/{produk_id}", response_model=ProdukResponse)
def ambil_produk(
produk_id: int = Path(..., gt=0, description="ID produk"),
):
produk = db_ambil_produk(produk_id)
if not produk:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Produk dengan ID {produk_id} tidak ditemukan"
)
return produk
@app.get("/produk", response_model=PaginatedResponse)
def daftar_produk(
q: Optional[str] = Query(None, min_length=2, description="Kata kunci pencarian"),
kategori_id: Optional[int] = Query(None, gt=0),
min_harga: Optional[Decimal] = Query(None, gt=0),
max_harga: Optional[Decimal] = Query(None, gt=0),
status: Optional[StatusProduk] = Query(None),
halaman: int = Query(1, ge=1),
per_halaman: int = Query(20, ge=1, le=100),
):
# Logika filter dan pagination
offset = (halaman - 1) * per_halaman
# ... query database
return PaginatedResponse(total=0, halaman=halaman, per_halaman=per_halaman, data=[])
@app.post("/produk", response_model=ProdukResponse, status_code=status.HTTP_201_CREATED)
def buat_produk(produk: ProdukCreate):
# produk sudah tervalidasi otomatis oleh Pydantic
hasil = db_buat_produk(produk)
return hasil
@app.patch("/produk/{produk_id}", response_model=ProdukResponse)
def update_produk(
produk_id: int = Path(..., gt=0),
data: ProdukUpdate = Body(...)
):
produk = db_ambil_produk(produk_id)
if not produk:
raise HTTPException(status_code=404, detail="Produk tidak ditemukan")
# Update hanya field yang dikirim (exclude_unset=True)
update_data = data.model_dump(exclude_unset=True)
return db_update_produk(produk_id, update_data)
@app.delete("/produk/{produk_id}", status_code=status.HTTP_204_NO_CONTENT)
def hapus_produk(produk_id: int = Path(..., gt=0)):
if not db_hapus_produk(produk_id):
raise HTTPException(status_code=404, detail="Produk tidak ditemukan")
Dependency Injection #
Dependency Injection (DI) di FastAPI memungkinkan kamu memisahkan logika yang reusable — koneksi database, autentikasi, pagination — dari handler route.
from fastapi import Depends, FastAPI
from sqlalchemy.orm import Session
from typing import Generator, Optional
# Database session dependency
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
# Pagination dependency
class PaginationParams:
def __init__(
self,
halaman: int = Query(1, ge=1),
per_halaman: int = Query(20, ge=1, le=100)
):
self.halaman = halaman
self.per_halaman = per_halaman
self.offset = (halaman - 1) * per_halaman
# Dependency untuk filter produk
class ProdukFilter:
def __init__(
self,
q: Optional[str] = Query(None),
kategori_id: Optional[int] = Query(None),
status: Optional[StatusProduk] = Query(None)
):
self.q = q
self.kategori_id = kategori_id
self.status = status
# Gunakan di route
@app.get("/produk")
def daftar_produk(
pagination: PaginationParams = Depends(PaginationParams),
filter: ProdukFilter = Depends(ProdukFilter),
db: Session = Depends(get_db)
):
query = db.query(Produk)
if filter.q:
query = query.filter(Produk.nama.ilike(f"%{filter.q}%"))
if filter.kategori_id:
query = query.filter(Produk.kategori_id == filter.kategori_id)
if filter.status:
query = query.filter(Produk.status == filter.status)
total = query.count()
produk = query.offset(pagination.offset).limit(pagination.per_halaman).all()
return {"total": total, "data": produk}
Autentikasi JWT #
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from passlib.context import CryptContext
from datetime import datetime, timedelta
from pydantic import BaseModel
import os
SECRET_KEY = os.getenv("SECRET_KEY", "ganti-di-produksi")
ALGORITHM = "HS256"
TOKEN_EXPIRE_MENIT = 30
pwd_context = CryptContext(schemes=["bcrypt"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
def buat_token(data: dict, expire_menit: int = TOKEN_EXPIRE_MENIT) -> str:
payload = data.copy()
payload["exp"] = datetime.utcnow() + timedelta(minutes=expire_menit)
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def verifikasi_token(token: str = Depends(oauth2_scheme)) -> dict:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=401, detail="Token tidak valid")
return payload
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token tidak valid atau kadaluarsa",
headers={"WWW-Authenticate": "Bearer"}
)
@app.post("/auth/token", response_model=TokenResponse)
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
pengguna = db.query(Pengguna).filter(Pengguna.email == form.username).first()
if not pengguna or not pwd_context.verify(form.password, pengguna.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email atau password salah",
headers={"WWW-Authenticate": "Bearer"}
)
token = buat_token({"sub": str(pengguna.id), "email": pengguna.email})
return TokenResponse(access_token=token)
# Gunakan di route yang butuh autentikasi
@app.get("/profil/saya")
def profil_saya(
payload: dict = Depends(verifikasi_token),
db: Session = Depends(get_db)
):
pengguna = db.query(Pengguna).filter(Pengguna.id == int(payload["sub"])).first()
if not pengguna:
raise HTTPException(status_code=404, detail="Pengguna tidak ditemukan")
return pengguna
Middleware #
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
import time
import logging
logger = logging.getLogger(__name__)
# CORS -- izinkan akses dari frontend
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "https://myapp.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Kompresi GZIP untuk response besar
app.add_middleware(GZipMiddleware, minimum_size=1000)
# Custom middleware untuk logging request
class RequestLoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
mulai = time.time()
response = await call_next(request)
durasi = (time.time() - mulai) * 1000
logger.info(
f"{request.method} {request.url.path} "
f"→ {response.status_code} ({durasi:.1f}ms)"
)
return response
app.add_middleware(RequestLoggingMiddleware)
Background Tasks #
Background tasks berguna untuk operasi yang tidak perlu menunggu selesai sebelum response dikirim — seperti mengirim email, memperbarui log, atau memproses gambar.
from fastapi import BackgroundTasks
from fastapi_mail import FastMail, MessageSchema
def kirim_email_konfirmasi(email: str, nama: str, order_id: int):
"""Fungsi ini dijalankan di background setelah response dikirim."""
# Simulasi kirim email
print(f"Mengirim email ke {email} untuk order #{order_id}")
# ... logika pengiriman email sesungguhnya
def perbarui_statistik(produk_id: int):
"""Update view counter di background."""
# ... update database
@app.post("/orders", response_model=OrderResponse, status_code=201)
def buat_order(
order: OrderCreate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
payload: dict = Depends(verifikasi_token)
):
# Buat order di database
order_baru = db_buat_order(db, order, int(payload["sub"]))
# Daftarkan background tasks -- dijalankan setelah response dikirim
background_tasks.add_task(
kirim_email_konfirmasi,
email=payload["email"],
nama=payload.get("nama", ""),
order_id=order_baru.id
)
return order_baru # response dikirim segera tanpa menunggu email
@app.get("/produk/{produk_id}")
def detail_produk(
produk_id: int = Path(..., gt=0),
background_tasks: BackgroundTasks = BackgroundTasks(),
db: Session = Depends(get_db)
):
produk = db_ambil_produk(db, produk_id)
if not produk:
raise HTTPException(status_code=404, detail="Produk tidak ditemukan")
background_tasks.add_task(perbarui_statistik, produk_id)
return produk
Error Handling Global #
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from sqlalchemy.exc import IntegrityError
# Handler untuk validation error Pydantic (422)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = []
for error in exc.errors():
errors.append({
"field": " → ".join(str(loc) for loc in error["loc"][1:]),
"message": error["msg"],
"type": error["type"]
})
return JSONResponse(
status_code=422,
content={"detail": "Data tidak valid", "errors": errors}
)
# Handler untuk database integrity error (409 Conflict)
@app.exception_handler(IntegrityError)
async def integrity_error_handler(request: Request, exc: IntegrityError):
return JSONResponse(
status_code=409,
content={"detail": "Data sudah ada atau melanggar constraint database"}
)
# Handler untuk semua exception yang tidak tertangani (500)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
logger.error(f"Unhandled exception: {exc}", exc_info=True)
return JSONResponse(
status_code=500,
content={"detail": "Terjadi kesalahan internal server"}
)
Testing #
# tests/test_produk.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from main import app, get_db
from database import Base
# Database in-memory untuk testing
SQLALCHEMY_TEST_URL = "sqlite:///./test.db"
engine_test = create_engine(SQLALCHEMY_TEST_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(bind=engine_test)
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
# Override dependency database untuk testing
app.dependency_overrides[get_db] = override_get_db
@pytest.fixture(autouse=True)
def setup_db():
Base.metadata.create_all(bind=engine_test)
yield
Base.metadata.drop_all(bind=engine_test)
client = TestClient(app)
def test_buat_produk():
response = client.post("/produk", json={
"nama": "Laptop Test",
"harga": 15000000,
"stok": 5
})
assert response.status_code == 201
data = response.json()
assert data["nama"] == "Laptop Test"
assert data["harga"] == "15000000.00"
assert "id" in data
def test_buat_produk_harga_negatif():
response = client.post("/produk", json={
"nama": "Laptop Invalid",
"harga": -1000,
})
assert response.status_code == 422
errors = response.json()["errors"]
assert any("harga" in e["field"] for e in errors)
def test_ambil_produk_tidak_ada():
response = client.get("/produk/9999")
assert response.status_code == 404
Ringkasan #
- Pydantic untuk semua validasi — definisikan schema terpisah untuk Create, Update, dan Response; gunakan
field_validatoruntuk validasi kustom danField(...)untuk constraint.model_dump(exclude_unset=True)— gunakan saat partial update (PATCH) agar hanya field yang dikirim yang diupdate, bukan semua field ke nilai default.- Dependency Injection — pisahkan koneksi DB, autentikasi, dan parameter query ke fungsi/kelas dependency yang di-
Depends(); ini membuat kode modular dan mudah di-test.lifespanbukan@app.on_event— cara modern mengelola startup/shutdown di FastAPI;on_eventsudah deprecated.status_codeeksplisit — selalu cantumkanstatus_codedi decorator route (201untuk create,204untuk delete); jangan andalkan default200.- Background tasks untuk operasi non-blocking — gunakan
BackgroundTasksuntuk email, notifikasi, dan update statistik yang tidak perlu ditunggu sebelum response dikirim.- CORS middleware — selalu konfigurasi
CORSMiddlewarejika frontend berada di domain berbeda; jangan gunakanallow_origins=["*"]di produksi.- Error handler global — daftarkan handler untuk
RequestValidationError(422) dan exception umum agar respons error konsisten di seluruh API.TestClient+ dependency override — gunakanapp.dependency_overridesuntuk mengganti koneksi database dengan database in-memory saat testing.- Dokumentasi otomatis — FastAPI generate Swagger UI (
/docs) dan ReDoc (/redoc) dari type hints dan Pydantic models; pastikandescriptiondanexamplesdi Field terisi dengan baik untuk dokumentasi yang informatif.