Dua utang dari bagian-bagian sebelumnya kini dibayar. Pertama, id yang tidak ada masih memicu 500 Internal Server Error — klien tidak bisa membedakan salah alamat dari server yang benar-benar rusak. Kedua, belum ada cara menandai tugas selesai tanpa menulis ulang judulnya. Bagian ini mengganti LookupError dengan HTTPException berstatus 404, menambahkan PATCH untuk pembaruan sebagian, dan mendokumentasikan bentuk galat lewat satu model Pydantic — penerapan langsung chapter Status Codes dan Error Responses.
Struktur berkas tidak berubah dari Bagian 3; app/__init__.py dan app/database.py tetap seperti sebelumnya:
api-manajemen-tugas/
├── pyproject.toml
└── app/
├── __init__.py
├── database.py
├── main.py
└── models.pyModel Baru untuk Pembaruan dan Galat #
Ganti seluruh isi app/models.py — dua class pertama tidak berubah, dua class terakhir baru:
from pydantic import BaseModel, Field
class TugasBuat(BaseModel):
judul: str = Field(min_length=1, examples=["Belajar Python"])
class TugasPerbarui(BaseModel):
judul: str | None = Field(default=None, min_length=1)
selesai: bool | None = None
class TugasTampil(BaseModel):
id: int
judul: str
selesai: bool
class DetailGalat(BaseModel):
detail: strDua hal penting di TugasPerbarui: setiap field bertipe str | None / bool | None dengan default=None, dan min_length=1 tetap berlaku ketika judul memang dikirim. Karena semua field opsional, body PATCH sah walau hanya berisi satu field. Sementara DetailGalat mendokumentasikan bentuk tubuh galat {"detail": "..."} — perannya kita pakai di bagian bawah.
app/main.py Versi Lengkap #
Ganti seluruh isi app/main.py:
import sqlite3
from fastapi import Depends, FastAPI, HTTPException, status
from app.database import dapatkan_koneksi
from app.models import DetailGalat, TugasBuat, TugasPerbarui, TugasTampil
app = FastAPI(title="API Manajemen Tugas", version="0.4.0")
def baris_ke_tugas(baris: sqlite3.Row) -> dict:
return {
"id": baris["id"],
"judul": baris["judul"],
"selesai": bool(baris["selesai"]),
}
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/tugas", response_model=list[TugasTampil])
def daftar_tugas(koneksi: sqlite3.Connection = Depends(dapatkan_koneksi)) -> list[dict]:
baris_semua = koneksi.execute(
"SELECT id, judul, selesai FROM tugas ORDER BY id"
).fetchall()
return [baris_ke_tugas(baris) for baris in baris_semua]
@app.post(
"/tugas",
response_model=TugasTampil,
status_code=status.HTTP_201_CREATED,
responses={422: {"model": DetailGalat, "description": "Body tidak valid"}},
)
def buat_tugas(
payload: TugasBuat, koneksi: sqlite3.Connection = Depends(dapatkan_koneksi)
) -> dict:
with koneksi:
kursor = koneksi.execute(
"INSERT INTO tugas (judul, selesai) VALUES (?, ?)",
(payload.judul, 0),
)
baris = koneksi.execute(
"SELECT id, judul, selesai FROM tugas WHERE id = ?",
(kursor.lastrowid,),
).fetchone()
return baris_ke_tugas(baris)
@app.get(
"/tugas/{tugas_id}",
response_model=TugasTampil,
responses={404: {"model": DetailGalat, "description": "Tugas tidak ditemukan"}},
)
def ambil_tugas(
tugas_id: int, koneksi: sqlite3.Connection = Depends(dapatkan_koneksi)
) -> dict:
baris = koneksi.execute(
"SELECT id, judul, selesai FROM tugas WHERE id = ?",
(tugas_id,),
).fetchone()
if baris is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"tugas dengan id {tugas_id} tidak ditemukan",
)
return baris_ke_tugas(baris)
@app.patch(
"/tugas/{tugas_id}",
response_model=TugasTampil,
responses={
400: {"model": DetailGalat, "description": "Body kosong"},
404: {"model": DetailGalat, "description": "Tugas tidak ditemukan"},
422: {"model": DetailGalat, "description": "Body tidak valid"},
},
)
def perbarui_tugas(
tugas_id: int,
payload: TugasPerbarui,
koneksi: sqlite3.Connection = Depends(dapatkan_koneksi),
) -> dict:
baris = koneksi.execute(
"SELECT id, judul, selesai FROM tugas WHERE id = ?",
(tugas_id,),
).fetchone()
if baris is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"tugas dengan id {tugas_id} tidak ditemukan",
)
pembaruan = payload.model_dump(exclude_unset=True)
if not pembaruan:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="minimal satu field (judul atau selesai) harus dikirim",
)
with koneksi:
if "judul" in pembaruan:
koneksi.execute(
"UPDATE tugas SET judul = ? WHERE id = ?",
(pembaruan["judul"], tugas_id),
)
if "selesai" in pembaruan:
koneksi.execute(
"UPDATE tugas SET selesai = ? WHERE id = ?",
(int(pembaruan["selesai"]), tugas_id),
)
baris = koneksi.execute(
"SELECT id, judul, selesai FROM tugas WHERE id = ?",
(tugas_id,),
).fetchone()
return baris_ke_tugas(baris)
@app.delete(
"/tugas/{tugas_id}",
status_code=status.HTTP_204_NO_CONTENT,
responses={404: {"model": DetailGalat, "description": "Tugas tidak ditemukan"}},
)
def hapus_tugas(
tugas_id: int, koneksi: sqlite3.Connection = Depends(dapatkan_koneksi)
) -> None:
with koneksi:
kursor = koneksi.execute(
"DELETE FROM tugas WHERE id = ?",
(tugas_id,),
)
if kursor.rowcount == 0:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"tugas dengan id {tugas_id} tidak ditemukan",
)Galat yang Jujur dengan HTTPException #
Semua tempat yang tadinya melempar LookupError kini melempar HTTPException:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"tugas dengan id {tugas_id} tidak ditemukan",
)HTTPException tidak dibiarkan menggelembung menjadi 500. FastAPI menangkap exception ini, menjawab request dengan status yang kita pilih, dan membungkus pesannya menjadi JSON {"detail": "..."}. Bukti lewat skrip sementara cek_api.py:
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
client.post("/tugas", json={"judul": "Belajar Python"})
respons = client.get("/tugas/99")
print(respons.status_code, respons.json())
respons = client.get("/tugas/1")
print(respons.status_code, respons.json())Output yang Dihasilkan:
404 {'detail': 'tugas dengan id 99 tidak ditemukan'}
200 {'id': 1, 'judul': 'Belajar Python', 'selesai': False}Kini klien menerima 404 dengan pesan yang bisa dibaca orang — bukan 500 yang membisukan semua orang.
Anatomi Handler PATCH #
Empat bagian handler perbarui_tugas menjadi kuncinya:
payload.model_dump(exclude_unset=True): hanya field yang benar-benar dikirim klien yang masuk dictionarypembaruan. Tanpaexclude_unset, kedua field selalu hadir dengan nilaiNonedan kita tidak bisa membedakan “tidak dikirim” dari “dikirimnull”.if not pembaruan:: body kosong{}lolos validasi Pydantic karena semua field opsional, jadi handler sendiri yang menolaknya dengan400— permintaan valid secara sintaks tetapi tidak bisa dikerjakan.if "judul" in pembaruan:/if "selesai" in pembaruan:: dua pernyataanUPDATEeksplisit, bukan satu SQL yang dirangkai dari nama field — nama kolom tidak pernah dicampur dengan input klien, nilai tetap lewat tanda?.int(pembaruan["selesai"]): SQLite menyimpanselesaisebagaiINTEGER, jadiTruedari JSON diubah menjadi1di sini; konversi balik dilakukanbaris_ke_tugas.
Alur pembaruan sebagian berjalan seperti ini:
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
respons = client.post("/tugas", json={"judul": "Belajar Python"})
print(respons.status_code, respons.json())
respons = client.patch("/tugas/1", json={"selesai": True})
print(respons.status_code, respons.json())
respons = client.patch("/tugas/1", json={"judul": "Belajar Python dan REST"})
print(respons.status_code, respons.json())
respons = client.get("/tugas/1")
print(respons.status_code, respons.json())Output yang Dihasilkan:
201 {'id': 1, 'judul': 'Belajar Python', 'selesai': False}
200 {'id': 1, 'judul': 'Belajar Python', 'selesai': True}
200 {'id': 1, 'judul': 'Belajar Python dan REST', 'selesai': True}
200 {'id': 1, 'judul': 'Belajar Python dan REST', 'selesai': True}Pembaruan pertama hanya mengirim selesai — judul tidak tersentuh. Pembaruan kedua hanya mengirim judul — status selesai tetap True.
Validasi di Batas Input #
Batas input sudah tertanam di model, dan Pydantic menegakkannya sebelum handler jalan. Judul kosong ditolak 422 dengan laporan terstruktur:
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
respons = client.post("/tugas", json={"judul": ""})
print(respons.status_code, respons.json())Output yang Dihasilkan:
422 {'detail': [{'type': 'string_too_short', 'loc': ['body', 'judul'], 'msg': 'String should have at least 1 character', 'input': '', 'ctx': {'min_length': 1}}]}Struktur ini khas Pydantic v2: type menjelaskan jenis pelanggaran, loc menunjuk field yang bermasalah, dan msg menjelaskan aturannya. Aturan yang sama berlaku di PATCH — dan body kosong {} dijawab 400 oleh handler:
respons = client.patch("/tugas/1", json={})
print(respons.status_code, respons.json())Output yang Dihasilkan:
400 {'detail': 'minimal satu field (judul atau selesai) harus dikirim'}Model Galat untuk Kontrak Konsisten #
Parameter responses pada decorator tiap route — terlihat pada app/main.py versi lengkap di atas — memasangkan DetailGalat dengan status 400, 404, dan 422. Manfaatnya: dokumentasi OpenAPI di /docs ikut menampilkan skema galat per endpoint, sehingga konsumen API tahu pasti bentuk jawaban saat terjadi kesalahan — kelanjutan praktik dari chapter OpenAPI dan API Documentation.
DELETE kini juga memakai 404, dan menghapus dua kali menghasilkan pasangan status yang masuk akal:
respons = client.delete("/tugas/1")
print(respons.status_code, repr(respons.text))
respons = client.delete("/tugas/1")
print(respons.status_code, respons.json())Output yang Dihasilkan:
204 ''
404 {'detail': 'tugas dengan id 1 tidak ditemukan'}Kontrak API kini lengkap: semua operasi CRUD bekerja, galat dijawab dengan status dan pesan yang benar, dan bentuknya terdokumentasi. Kode lengkap tahap ini tersedia pada bagian Berkas Sumber di bawah halaman — app/database.py tidak berubah dari Bagian 3. Tinggal satu kebiasaan profesional yang belum ada: test otomatis, tugas Bagian 5.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
app/main.py
Unduh berkasimport sqlite3
from fastapi import Depends, FastAPI, HTTPException, status
from app.database import dapatkan_koneksi
from app.models import DetailGalat, TugasBuat, TugasPerbarui, TugasTampil
app = FastAPI(title="API Manajemen Tugas", version="0.4.0")
def baris_ke_tugas(baris: sqlite3.Row) -> dict:
return {
"id": baris["id"],
"judul": baris["judul"],
"selesai": bool(baris["selesai"]),
}
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/tugas", response_model=list[TugasTampil])
def daftar_tugas(koneksi: sqlite3.Connection = Depends(dapatkan_koneksi)) -> list[dict]:
baris_semua = koneksi.execute(
"SELECT id, judul, selesai FROM tugas ORDER BY id"
).fetchall()
return [baris_ke_tugas(baris) for baris in baris_semua]
@app.post(
"/tugas",
response_model=TugasTampil,
status_code=status.HTTP_201_CREATED,
responses={422: {"model": DetailGalat, "description": "Body tidak valid"}},
)
def buat_tugas(
payload: TugasBuat, koneksi: sqlite3.Connection = Depends(dapatkan_koneksi)
) -> dict:
with koneksi:
kursor = koneksi.execute(
"INSERT INTO tugas (judul, selesai) VALUES (?, ?)",
(payload.judul, 0),
)
baris = koneksi.execute(
"SELECT id, judul, selesai FROM tugas WHERE id = ?",
(kursor.lastrowid,),
).fetchone()
return baris_ke_tugas(baris)
@app.get(
"/tugas/{tugas_id}",
response_model=TugasTampil,
responses={404: {"model": DetailGalat, "description": "Tugas tidak ditemukan"}},
)
def ambil_tugas(
tugas_id: int, koneksi: sqlite3.Connection = Depends(dapatkan_koneksi)
) -> dict:
baris = koneksi.execute(
"SELECT id, judul, selesai FROM tugas WHERE id = ?",
(tugas_id,),
).fetchone()
if baris is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"tugas dengan id {tugas_id} tidak ditemukan",
)
return baris_ke_tugas(baris)
@app.patch(
"/tugas/{tugas_id}",
response_model=TugasTampil,
responses={
400: {"model": DetailGalat, "description": "Body kosong"},
404: {"model": DetailGalat, "description": "Tugas tidak ditemukan"},
422: {"model": DetailGalat, "description": "Body tidak valid"},
},
)
def perbarui_tugas(
tugas_id: int,
payload: TugasPerbarui,
koneksi: sqlite3.Connection = Depends(dapatkan_koneksi),
) -> dict:
baris = koneksi.execute(
"SELECT id, judul, selesai FROM tugas WHERE id = ?",
(tugas_id,),
).fetchone()
if baris is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"tugas dengan id {tugas_id} tidak ditemukan",
)
pembaruan = payload.model_dump(exclude_unset=True)
if not pembaruan:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="minimal satu field (judul atau selesai) harus dikirim",
)
with koneksi:
if "judul" in pembaruan:
koneksi.execute(
"UPDATE tugas SET judul = ? WHERE id = ?",
(pembaruan["judul"], tugas_id),
)
if "selesai" in pembaruan:
koneksi.execute(
"UPDATE tugas SET selesai = ? WHERE id = ?",
(int(pembaruan["selesai"]), tugas_id),
)
baris = koneksi.execute(
"SELECT id, judul, selesai FROM tugas WHERE id = ?",
(tugas_id,),
).fetchone()
return baris_ke_tugas(baris)
@app.delete(
"/tugas/{tugas_id}",
status_code=status.HTTP_204_NO_CONTENT,
responses={404: {"model": DetailGalat, "description": "Tugas tidak ditemukan"}},
)
def hapus_tugas(
tugas_id: int, koneksi: sqlite3.Connection = Depends(dapatkan_koneksi)
) -> None:
with koneksi:
kursor = koneksi.execute(
"DELETE FROM tugas WHERE id = ?",
(tugas_id,),
)
if kursor.rowcount == 0:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"tugas dengan id {tugas_id} tidak ditemukan",
)
app/models.py
Unduh berkasfrom pydantic import BaseModel, Field
class TugasBuat(BaseModel):
judul: str = Field(min_length=1, examples=["Belajar Python"])
class TugasPerbarui(BaseModel):
judul: str | None = Field(default=None, min_length=1)
selesai: bool | None = None
class TugasTampil(BaseModel):
id: int
judul: str
selesai: bool
class DetailGalat(BaseModel):
detail: str