Kita telah membangun potongan-potongannya satu per satu: routing, parsing request, dan serialization respons. Chapter ini merangkai semuanya menjadi handler API utuh — menerima request, membaca database, memvalidasi masukan, dan memancarkan JSON — dalam satu putaran penuh yang bisa dijalankan dari CLI. Di sini kita juga menetapkan konvensi yang membuat API terasa matang: penamaan kunci camelCase, bentuk respons sukses dan error yang seragam, metadata paginasi, serta langkah keamanan khusus respons JSON. Database contoh memakai SQLite :memory: agar seluruh roundtrip bisa diuji tanpa server tambahan.

Konvensi Penamaan dan Bentuk Data #

Baris database lazimnya memakai snake_case (created_at, is_done), sedangkan konvensi JSON API modern memilih camelCase (createdAt, isDone). Kedua dunia ini dipertemukan di lapisan penyusun respons — bukan dibiarkan bocor satu sama lain. Konversi yang benar juga menyertai penyesuaian tipe: kolom INTEGER SQLite harus menjadi boolean JSON yang sesungguhnya, bukan angka 1 dan 0:

php
<?php
$row = ["created_at" => "2026-01-15T01:00:00+00:00", "is_done" => 1, "assignee_id" => 4];

$task = [
    "createdAt" => $row["created_at"],
    "isDone" => (bool) $row["is_done"],
    "assigneeId" => $row["assignee_id"],
];

echo json_encode($task, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";

Output yang Dihasilkan:

text
{"createdAt":"2026-01-15T01:00:00+00:00","isDone":true,"assigneeId":4}

Pemetaan eksplisit baris demi baris seperti ini memang lebih panjang daripada meng-encode $row langsung, tetapi tiga keuntungannya besar: kunci JSON lepas dari nama kolom database (rename kolom tidak mematahkan klien), tipe data dikendalikan ((bool) mengubah 1 menjadi true), dan hanya field yang memang dipublikasikan yang keluar. Konvensi lengkap yang kita pegang di seluruh API dirangkum pada tabel berikut:

AspekKonvensiAlasan
Kunci JSONcamelCaseStandar de-facto JavaScript dan kebanyakan klien API
Booleantrue / falseBukan 1 / 0 — klien tidak perlu mengonversi
TanggalISO 8601 UTC (2026-01-15T01:00:00+00:00)Dapat diurai pustaka tanggal mana pun
Field kosongnull atau tidak dikirimKonsisten, bukan string kosong atau 0
Sukses{"data": ..., "meta": ...}Data dan metadata terpisah rapi
Gagal{"error": {"code": ..., "message": ...}}Bentuk error tunggal di seluruh API

Handler Roundtrip Lengkap #

Sekarang rakitan utuhnya. Contoh berikut menyiapkan tabel tasks di SQLite :memory:, mendefinisikan handler listTasks() dan createTask(), lalu menggiring empat request tiruan melewati dispatch table — persis seperti yang akan dialami request sungguhan:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(
    "CREATE TABLE tasks (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        done INTEGER NOT NULL DEFAULT 0
    )"
);
$pdo->exec(
    "INSERT INTO tasks (title, done) VALUES
        ('Belajar PHP', 0),
        ('Menulis dokumentasi API', 1)"
);

function respond(int $status, array $payload): array
{
    return [
        $status,
        ["Content-Type: application/json; charset=utf-8"],
        json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
    ];
}

function fetchTasks(PDO $pdo): array
{
    $rows = $pdo
        ->query("SELECT id, title, done FROM tasks ORDER BY id")
        ->fetchAll(PDO::FETCH_ASSOC);

    return array_map(
        fn(array $row): array => [
            "id" => (int) $row["id"],
            "title" => $row["title"],
            "done" => (bool) $row["done"],
        ],
        $rows
    );
}

function listTasks(PDO $pdo, array $request): array
{
    $perPage = 2;
    $page = max(1, (int) ($request["query"]["page"] ?? 1));

    $tasks = fetchTasks($pdo);
    $slice = array_slice($tasks, ($page - 1) * $perPage, $perPage);

    return respond(200, [
        "data" => $slice,
        "meta" => [
            "page" => $page,
            "perPage" => $perPage,
            "total" => count($tasks),
        ],
    ]);
}

function createTask(PDO $pdo, array $request): array
{
    $title = $request["body"]["title"] ?? null;

    if (!is_string($title) || trim($title) === "") {
        return respond(422, [
            "error" => [
                "code" => "VALIDATION_ERROR",
                "message" => "Field title wajib diisi",
            ],
        ]);
    }

    $title = trim($title);
    $stmt = $pdo->prepare("INSERT INTO tasks (title, done) VALUES (:title, 0)");
    $stmt->execute([":title" => $title]);

    return respond(201, [
        "data" => [
            "id" => (int) $pdo->lastInsertId(),
            "title" => $title,
            "done" => false,
        ],
    ]);
}

$routes = [
    "GET /tasks" => "listTasks",
    "POST /tasks" => "createTask",
];

$requests = [
    ["method" => "GET", "path" => "/tasks", "query" => [], "body" => null],
    ["method" => "GET", "path" => "/tasks", "query" => ["page" => "2"], "body" => null],
    ["method" => "POST", "path" => "/tasks", "query" => [], "body" => ["title" => "Belanja bulanan"]],
    ["method" => "POST", "path" => "/tasks", "query" => [], "body" => ["title" => "   "]],
];

foreach ($requests as $request) {
    $key = $request["method"] . " " . $request["path"];
    [$status, $headers, $body] = $routes[$key]($pdo, $request);
    echo $key . " => " . $status . "\n" . $body . "\n\n";
}

Output yang Dihasilkan:

text
GET /tasks => 200
{"data":[{"id":1,"title":"Belajar PHP","done":false},{"id":2,"title":"Menulis dokumentasi API","done":true}],"meta":{"page":1,"perPage":2,"total":2}}

GET /tasks => 200
{"data":[],"meta":{"page":2,"perPage":2,"total":2}}

POST /tasks => 201
{"data":{"id":3,"title":"Belanja bulanan","done":false}}

POST /tasks => 422
{"error":{"code":"VALIDATION_ERROR","message":"Field title wajib diisi"}}

Mari kita bedah alur kerjanya tahap demi tahap:

  1. Persiapan database: tabel dibuat di :memory: dengan dua baris awal. PDO::ERRMODE_EXCEPTION memastikan kegagalan SQL menjadi PDOException yang bisa ditangkap, bukan false senyap.
  2. respond(): helper serialization dari Chapter Response Serialization — status, header JSON, dan body dalam satu nilai balik.
  3. fetchTasks(): membaca semua baris lalu memetakannya ke bentuk publik API lewat array_map(). Di sinilah snake_case kolom menjadi camelCase JSON dan INTEGER menjadi boolean.
  4. listTasks(): menghitung halaman dari query string dengan idiom max(1, (int) ...) dari Chapter Request Parsing , mengambil irisan array_slice(), lalu mengirim data bersama meta paginasi.
  5. createTask(): memvalidasi title lebih dulu (masuk akal atau tidak), baru mengeksekusi INSERT dengan prepared statement dan parameter terikat. Respons sukses berstatus 201 membawa task yang baru lahir.
  6. Loop akhir: empat request digiring lewat dispatch table yang sama seperti chapter routing. Dua request GET memperlihatkan efek page, dua request POST memperlihatkan jalur sukses dan jalur validasi gagal.

Perhatikan bahwa request page=2 menghasilkan data kosong dengan meta yang tetap jujur — total: 2, perPage: 2, page: 2. Klien yang membaca meta tahu persis kenapa daftarnya kosong tanpa perlu menebak.

Bentuk Error yang Konsisten #

Output request terakhir menunjukkan bentuk error yang kita pegang di seluruh API: objek error berisi code (identitas mesin, stabil, huruf kapital ber-underscore) dan message (kalimat manusia yang boleh berubah). Ketika aturan yang dilanggar lebih dari satu, field details opsional memuat rincian per-field. Perbandingan dua bentuk baku tersebut:

json
{
    "data": {"id": 3, "title": "Belanja bulanan", "done": false}
}
json
{
    "error": {
        "code": "VALIDATION_ERROR",
        "message": "Field title wajib diisi",
        "details": [
            {"field": "title", "issue": "wajib diisi"}
        ]
    }
}

Konsistensi inilah yang membuat klien bisa menulis satu fungsi penanganan error untuk seluruh API: cukup periksa ada tidaknya kunci error, lalu baca code untuk memutuskan tindakan. Status HTTP yang menyertainya — 422, 404, 409, dan kawan-kawannya — beserta desain renderer errornya adalah topik Chapter Status Codes dan Error Responses .

Pagination dalam Respons #

Bentuk meta pada listTasks() adalah pola paginasi paling umum: page, perPage, dan total. Klien menghitung sendiri jumlah halaman (ceil(total / perPage)) dan tautan halaman berikutnya (page + 1 selama masih ada data). Alternatif populer lain menyertakan tautan siap pakai:

json
{
    "data": [],
    "meta": {
        "page": 2,
        "perPage": 2,
        "total": 2,
        "nextPage": null,
        "prevPage": "/tasks?page=1"
    }
}

Keduanya sah; yang penting dipilih satu dan dipertahankan di semua endpoint daftar. Hal-hal yang perlu dijaga sama seperti chapter parsing: perPage sebaiknya memiliki batas atas (klien bisa meminta perPage=100000 — jangan dipatuhi), dan page selalu dijepit ke nilai minimal 1. Teknik filter dan sorting yang melengkapi paginasi dibahas khusus di Chapter Pagination, Filtering, dan Sorting .

Validasi Cepat dengan json_validate #

Sejak PHP 8.3 tersedia json_validate() — fungsi yang hanya memeriksa apakah sebuah string adalah JSON valid tanpa membangun struktur datanya. Untuk kasus seperti memvalidasi payload webhook sebelum diproses, atau memeriksa cache yang tersimpan sebagai string JSON, fungsi ini lebih murah daripada json_decode() penuh:

php
<?php
$inputs = [
    '{"title": "Belajar PHP"}',
    '{"title": }',
    '"hanya sebuah string"',
];

foreach ($inputs as $raw) {
    echo (json_validate($raw) ? "valid" : "tidak valid") . "\n";
}

Output yang Dihasilkan:

text
valid
tidak valid
valid

Perhatikan kasus ketiga: string "hanya sebuah string" dinyatakan valid, karena memang JSON yang sah — hanya saja bukan objek. Pelajaran pentingnya, json_validate() memeriksa sintaks, bukan bentuk; setelah lolos pemeriksaan ini Anda tetap membutuhkan json_decode() plus pemeriksaan is_array() (dan bila perlu array_is_list()) untuk memastikan strukturnya sesuai harapan. Gunakan urutan ini: json_validate() untuk menolak sampah sejak dini, json_decode() untuk mengambil strukturnya, lalu validasi isi di layer berikutnya.

Keamanan Respons JSON #

Respons JSON yang murni data sebenarnya relatif aman — yang berbahaya adalah saat browser salah menafsirkannya. Dua pertahanan standar wajib melekat di setiap respons API:

php
<?php
// header yang dikirim untuk setiap respons JSON API
$responseHeaders = [
    "Content-Type: application/json; charset=utf-8",
    "X-Content-Type-Options: nosniff",
];

Header X-Content-Type-Options: nosniff melarang browser melakukan content sniffing — menebak tipe konten dari isinya. Tanpa header ini, browser lama bisa mengeksekusi respons yang seharusnya JSON sebagai HTML, membuka pintu serangan. Daftar lengkap header keamanan HTTP dibahas di Chapter Security Headers .

Kasus kedua muncul ketika JSON akan disisipkan ke halaman HTML — misalnya hasil API di-embed langsung ke <script> untuk hydration. String yang berisi </script> akan menutup tag script lebih awal dan menyuntikkan kode:

php
<?php
$profile = ["bio" => "</script><script>alert('XSS')</script>"];

echo json_encode($profile, JSON_HEX_TAG) . "\n";

Output yang Dihasilkan:

text
{"bio":"\u003C\/script\u003E\u003Cscript\u003Ealert('XSS')\u003C\/script\u003E"}

Flag JSON_HEX_TAG mengubah < menjadi \u003C dan > menjadi \u003E, sehingga urutan </script> tidak pernah muncul secara harfiah di output — string tetap identik 100% setelah di-parse kembali sebagai JSON, tetapi tidak bisa lagi memutus keluar dari tag HTML. Perlu digarisbawahi: flag ini hanya relevan untuk skenario embedding; API murni yang dikonsumsi lewat fetch() atau HTTP client tidak membutuhkannya. Memahami kapan data berpindah konteks adalah inti pencegahan XSS — teorinya ada di Chapter Cross-Site Scripting .

Ringkasan #

Handler API yang matang adalah rangkaian bahan yang sudah kita miliki: dispatch table memilih handler, parsing mengubah body dan query menjadi array, lapisan pemetaan mengubah baris database menjadi JSON camelCase bertipe benar, dan helper respond() menyeragamkan header serta flag encoding. Konvensi data/meta untuk sukses dan error dengan code/message untuk gagal membuat seluruh API terasa satu kesatuan. Satu komponen kontrak yang belum kita bahas tuntas adalah status code itu sendiri — kapan memakai 201, 409, atau 429 — yang menjadi fokus Chapter Status Codes dan Error Responses .

Referensi #