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:

text
api-manajemen-tugas/
├── pyproject.toml
└── app/
    ├── __init__.py
    ├── database.py
    ├── main.py
    └── models.py

Model Baru untuk Pembaruan dan Galat #

Ganti seluruh isi app/models.py — dua class pertama tidak berubah, dua class terakhir baru:

python
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: str

Dua 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:

python
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:

python
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:

python
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:

text
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:

  1. payload.model_dump(exclude_unset=True): hanya field yang benar-benar dikirim klien yang masuk dictionary pembaruan. Tanpa exclude_unset, kedua field selalu hadir dengan nilai None dan kita tidak bisa membedakan “tidak dikirim” dari “dikirim null”.
  2. if not pembaruan:: body kosong {} lolos validasi Pydantic karena semua field opsional, jadi handler sendiri yang menolaknya dengan 400 — permintaan valid secara sintaks tetapi tidak bisa dikerjakan.
  3. if "judul" in pembaruan: / if "selesai" in pembaruan:: dua pernyataan UPDATE eksplisit, bukan satu SQL yang dirangkai dari nama field — nama kolom tidak pernah dicampur dengan input klien, nilai tetap lewat tanda ?.
  4. int(pembaruan["selesai"]): SQLite menyimpan selesai sebagai INTEGER, jadi True dari JSON diubah menjadi 1 di sini; konversi balik dilakukan baris_ke_tugas.

Alur pembaruan sebagian berjalan seperti ini:

python
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:

text
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:

python
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:

text
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:

python
respons = client.patch("/tugas/1", json={})
print(respons.status_code, respons.json())

Output yang Dihasilkan:

text
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:

python
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:

text
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 berkas
python
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",
        )

app/models.py

Unduh berkas
python
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: str