Aplikasi kita di artikel sebelumnya sudah jalan, tapi validasinya masih dasar banget: title cuma dicek harus str, tanpa batasan panjang, dan kita belum bisa update atau hapus task. Di artikel ini kita perdalam tiga hal: validasi field yang lebih ketat, memisahkan skema request dari skema response, dan menangani error secara konsisten.
Field Constraints — Lebih dari Sekadar Tipe Data
Type hint biasa (str, int, bool) cuma memvalidasi tipe. Untuk aturan tambahan — panjang minimum, rentang angka, dan sejenisnya — Pydantic menyediakan Field:
from pydantic import BaseModel, Field
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
Sekarang, kalau title dikirim string kosong (""), FastAPI otomatis menolak dengan 422, lengkap dengan pesan "String should have at least 1 character". Kita tidak menulis if len(title) < 1: raise ... manual — aturan ini hidup di definisi model, satu tempat, dipakai konsisten di semua endpoint yang menerima TaskCreate.
Beberapa constraint umum lain yang sering dipakai:
class Example(BaseModel):
quantity: int = Field(ge=1, le=100) # ge = greater/equal, le = less/equal
email: str = Field(pattern=r"^[^@]+@[^@]+\.[^@]+___CODE_BLOCK_PLACEHOLDER___1___CODE_BLOCK_PLACEHOLDER___quot;)
Enum — Membatasi Nilai ke Pilihan yang Valid
Untuk field yang nilainya harus dari daftar terbatas — misalnya prioritas task — pakai Enum dari Python standar, dikombinasikan dengan str supaya nilainya tetap berupa string di JSON:
from enum import Enum
class Priority(str, Enum):
low = "low"
medium = "medium"
high = "high"
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
priority: Priority = Priority.medium
Begitu Priority dipakai sebagai tipe field, FastAPI otomatis: menolak nilai di luar low/medium/high dengan 422, DAN menampilkan dropdown pilihan yang valid di halaman /docs — bukan cuma text field kosong. Konsumen API tidak perlu menebak-nebak nilai apa saja yang diterima.
Memisahkan Request Model dari Response Model
Sejauh ini kita cuma punya satu model, TaskCreate, dipakai untuk menerima data. Tapi model untuk menerima data dan model untuk mengembalikan data sering kali perlu berbeda. Contoh nyata: field id tidak masuk akal ada di request pembuatan task (belum ada ID-nya, ID di-generate server), tapi WAJIB ada di response.
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
priority: Priority = Priority.medium
class TaskUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
done: bool | None = None
priority: Priority | None = None
class TaskRead(BaseModel):
id: int
title: str
done: bool
priority: Priority
Perhatikan TaskUpdate: semua field-nya opsional (| None, default None). Ini pola umum untuk endpoint PATCH — konsumen API cuma mengirim field yang mau diubah, bukan seluruh objek.
Sekarang pasang TaskRead sebagai response_model di endpoint:
@app.post("/api/tasks", response_model=TaskRead, status_code=201)
def create_task(payload: TaskCreate):
global next_id
task = {"id": next_id, "title": payload.title, "done": False, "priority": payload.priority}
tasks.append(task)
next_id += 1
return task
Dengan response_model=TaskRead, FastAPI melakukan dua hal otomatis: memvalidasi ulang data yang kita return supaya cocok skema TaskRead (jaring pengaman kalau ada bug di kode kita), dan memfilter field — kalau suatu saat objek internal kita punya field sensitif (misalnya catatan internal atau flag debug), field itu tidak akan pernah ikut ter-expose ke response selama tidak didefinisikan di TaskRead.
Endpoint PATCH dan DELETE
Sekarang kita lengkapi CRUD-nya:
from fastapi import HTTPException, status
@app.patch("/api/tasks/{task_id}", response_model=TaskRead)
def update_task(task_id: int, payload: TaskUpdate):
task = next((t for t in tasks if t["id"] == task_id), None)
if task is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
updates = payload.model_dump(exclude_unset=True)
task.update(updates)
return task
@app.delete("/api/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: int):
task = next((t for t in tasks if t["id"] == task_id), None)
if task is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
tasks.remove(task)
Satu detail penting: payload.model_dump(exclude_unset=True). Method ini mengembalikan dict yang hanya berisi field yang benar-benar dikirim di request, bukan semua field TaskUpdate (yang defaultnya None). Tanpa exclude_unset=True, mengirim {"done": true} saja bisa secara tidak sengaja menimpa title yang sudah ada menjadi None — bug yang gampang lolos kalau tidak hati-hati.
Perhatikan juga delete_task tidak me-return apa pun — ini sesuai konvensi 204 No Content, yang secara definisi tidak boleh punya response body.
HTTPException vs Validasi Otomatis — Dua Lapis Error yang Berbeda
Sampai titik ini kita sudah bertemu dua jenis error yang ditangani FastAPI secara berbeda:
422 Unprocessable Entity) — terjadi SEBELUM path operation function kita dijalankan, ketika data yang dikirim tidak cocok skema Pydantic (tipe salah, field wajib hilang, melanggar Field constraint). Kita tidak menulis kode apa pun untuk ini — FastAPI dan Pydantic yang menanganinya.HTTPException (kode manual) — kita lempar sendiri di dalam path operation function, untuk kondisi yang secara tipe data valid tapi secara business logic tidak bisa diproses — misalnya task_id yang formatnya benar (angka) tapi task-nya memang tidak ada (404).Membedakan dua lapis ini penting: jangan coba-coba mengecek manual "apakah title string" di dalam fungsi kita — itu sudah tugas Pydantic. Fokus HTTPException untuk aturan yang butuh konteks di luar skema data semata (data tidak ditemukan, tidak punya izin, konflik dengan data lain, dan sejenisnya).
Rangkuman
Field(...) untuk constraint tambahan di luar tipe data (panjang, rentang angka).Enum untuk membatasi field ke daftar nilai valid, sekaligus muncul sebagai dropdown di /docs.TaskCreate, TaskUpdate) dari response model (TaskRead) — beda kebutuhan, beda skema.response_model memvalidasi ulang dan memfilter data yang keluar dari endpoint.HTTPException untuk error business logic yang kita lempar manual.