Setiap request yang tiba pada API membawa dua informasi kunci: HTTP method (misalnya GET atau POST) dan path (misalnya /tasks/7). Router adalah bagian aplikasi yang memutuskan kombinasi keduanya diarahkan ke potongan kode mana. Pada Chapter ini kita akan membangun router sederhana berbasis tabel pencocokan, menambahkan dukungan segmen dinamis seperti /tasks/{id}, lalu menangani kasus tepi yang sering diabaikan: method yang tidak diizinkan, trailing slash, dan kapitalisasi path. Seperti biasa, semua contoh ditulis sebagai fungsi murni agar bisa diuji langsung dari CLI tanpa web server.

Peran Router pada REST API #

Pada Chapter Resource Modeling kita sudah merancang bentuk resource tasks. Router menghubungkan resource tersebut ke URL konkret. Prinsip REST: path berisi kata benda (resource), sedangkan method menentukan aksi — bukan sebaliknya. Path seperti /getTasks atau /deleteTask/7 mengangkangi prinsip ini karena kata kerja seharusnya hidup di method, bukan di URL.

Tabel berikut memetakan endpoint khas untuk resource tasks:

MethodPathAksiStatus sukses
GET/tasksDaftar semua task200
POST/tasksBuat task baru201
GET/tasks/{id}Detail satu task200
PUT / PATCH/tasks/{id}Perbarui task200
DELETE/tasks/{id}Hapus task204

Alur keputusan sebuah router dapat diringkas menjadi diagram berikut:

Diagram di atas menunjukkan bahwa pencocokan rute punya dua lapis: path-nya dikenali atau tidak, lalu method-nya diizinkan atau tidak. Kedua lapis tersebut menghasilkan status yang berbeda (405 versus 404), dan kita akan bahas implementasinya satu per satu.

Dispatch Table Sederhana #

Cara paling sederhana menyusun router adalah dengan dispatch table: array asosiatif yang memetakan string "METHOD /path" ke sebuah handler. Handler kita kembalikan dalam bentuk array [status, headers, body] — pola yang sama dengan callable biasa — sehingga seluruh router dapat diuji tanpa menjalankan web server sungguhan.

php
<?php
function listTasks(array $request): array
{
    $tasks = [
        ["id" => 1, "title" => "Belajar PHP"],
        ["id" => 2, "title" => "Menulis dokumentasi API"],
    ];

    return [
        200,
        ["Content-Type: application/json"],
        json_encode(["data" => $tasks], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
    ];
}

function createTask(array $request): array
{
    $task = [
        "id" => 3,
        "title" => $request["body"]["title"] ?? "",
    ];

    return [
        201,
        ["Content-Type: application/json"],
        json_encode(["data" => $task], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
    ];
}

function respondNotFound(): array
{
    return [
        404,
        ["Content-Type: application/json"],
        json_encode(
            ["error" => ["code" => "NOT_FOUND", "message" => "Endpoint tidak ditemukan"]],
            JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
        ),
    ];
}

function dispatch(array $routes, array $request): array
{
    $key = $request["method"] . " " . $request["path"];

    if (isset($routes[$key])) {
        return ($routes[$key])($request);
    }

    return respondNotFound();
}

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

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

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

Output yang Dihasilkan:

text
GET /tasks => 200
{"data":[{"id":1,"title":"Belajar PHP"},{"id":2,"title":"Menulis dokumentasi API"}]}

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

GET /stats => 404
{"error":{"code":"NOT_FOUND","message":"Endpoint tidak ditemukan"}}

Mari kita bedah bagian-bagian penting kode di atas:

  1. $routes: dispatch table-nya sendiri. Kunci "GET /tasks" dan "POST /tasks" sengaja berbeda meski path-nya sama, karena method adalah bagian dari identitas endpoint.
  2. dispatch(): fungsi penghubung. Baris $key = ... merangkai method dan path menjadi satu kunci pencarian, lalu isset($routes[$key]) memeriksa keberadaan rute tanpa memicu warning pada array asosiatif.
  3. ($routes[$key])($request): memanggil handler berupa string nama fungsi — string seperti "listTasks" adalah callable yang sah di PHP — dengan satu argumen berupa array $request lengkap.
  4. respondNotFound(): jalur gagal yang seragam. Ketika tidak ada rute yang cocok, router tetap mengembalikan JSON berbentuk error, bukan halaman kosong.

Perhatikan bahwa ketiga request tiruan di akhir kode dijalankan persis seperti request sungguhan: masuk ke dispatch(), keluar sebagai [status, headers, body]. Pola inilah yang membuat routing dapat diuji dengan unit test biasa.

Rute dengan Segmen Dinamis #

Endpoint nyata hampir selalu memuat data di path-nya: /tasks/42/comments/7. Menulis satu rute statis untuk setiap angka jelas mustahil, jadi kita perlu pattern dengan placeholder {nama}. Trik standarnya: ubah pattern menjadi regular expression dengan named group (?P<nama>[^/]+), lalu cocokkan dengan preg_match().

php
<?php
function compilePattern(string $pattern): string
{
    $regex = preg_replace("#\{(\w+)\}#", "(?P<$1>[^/]+)", $pattern);

    return "#^" . $regex . "$#";
}

function matchRoute(string $pattern, string $path): ?array
{
    $found = preg_match(compilePattern($pattern), $path, $matches);

    if ($found !== 1) {
        return null;
    }

    return array_filter($matches, "is_string", ARRAY_FILTER_USE_KEY);
}

$pattern = "/tasks/{taskId}/comments/{commentId}";

$params = matchRoute($pattern, "/tasks/42/comments/7");
echo "taskId: " . $params["taskId"] . "\n";
echo "commentId: " . $params["commentId"] . "\n";

var_dump(matchRoute($pattern, "/tasks/42"));

Output yang Dihasilkan:

text
taskId: 42
commentId: 7
NULL

Kode di atas bekerja dalam dua tahap. Pertama, compilePattern() mengganti setiap {taskId} menjadi grup bernama (?P<taskId>[^/]+); karakter [^/]+ berarti “satu atau lebih karakter selain garis miring”, sehingga satu segmen tidak akan menelan segmen berikutnya. Kedua, matchRoute() menjalankan preg_match() lalu menyaring $matches dengan ARRAY_FILTER_USE_KEY agar hanya grup bernama yang ikut kembali — kunci numerik hasil regex dibuang. Hasilnya array parameter bersih seperti ["taskId" => "42", "commentId" => "7"], atau null bila path tidak cocok.

Satu hal penting: nilai yang ditangkap pattern selalu bertipe string. Handler yang membutuhkan angka wajib mengonversinya secara eksplisit, misalnya (int) $params["taskId"], sebelum dipakai ke query database.

Menangani 405 Method Not Allowed #

Apa yang terjadi jika klien mengirim DELETE /tasks/5 padahal rutenya hanya tersedia untuk GET dan PUT? Mengembalikan 404 terasa salah — sumber dayanya ada, hanya method-nya yang tidak didukung. Status yang tepat adalah 405 Method Not Allowed, disertai header Allow yang memberi tahu method mana saja yang sah.

php
<?php
function dispatchRoutes(array $routes, string $method, string $path): array
{
    $allowed = [];

    foreach ($routes as $route) {
        if ($route["path"] !== $path) {
            continue;
        }

        if ($route["method"] === $method) {
            return ($route["handler"])(["method" => $method, "path" => $path, "body" => null]);
        }

        $allowed[] = $route["method"];
    }

    if ($allowed !== []) {
        return [
            405,
            ["Content-Type: application/json", "Allow: " . implode(", ", $allowed)],
            json_encode(
                [
                    "error" => [
                        "code" => "METHOD_NOT_ALLOWED",
                        "message" => "Method " . $method . " tidak didukung di " . $path,
                    ],
                ],
                JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
            ),
        ];
    }

    return [
        404,
        ["Content-Type: application/json"],
        json_encode(
            ["error" => ["code" => "NOT_FOUND", "message" => "Endpoint tidak ditemukan"]],
            JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
        ),
    ];
}

$routes = [
    ["method" => "GET", "path" => "/tasks/5", "handler" => fn(array $r): array => [200, ["Content-Type: application/json"], '{"data":{"id":5,"title":"Belajar PHP"}}']],
    ["method" => "PUT", "path" => "/tasks/5", "handler" => fn(array $r): array => [200, ["Content-Type: application/json"], '{"data":{"id":5,"title":"Belajar PHP (revisi)"}}']],
];

$cases = [
    ["DELETE", "/tasks/5"],
    ["DELETE", "/reports"],
];

foreach ($cases as [$method, $path]) {
    [$status, $headers, $body] = dispatchRoutes($routes, $method, $path);
    echo $method . " " . $path . " => " . $status . "\n";
    echo implode(" | ", $headers) . "\n";
    echo $body . "\n\n";
}

Output yang Dihasilkan:

text
DELETE /tasks/5 => 405
Content-Type: application/json | Allow: GET, PUT
{"error":{"code":"METHOD_NOT_ALLOWED","message":"Method DELETE tidak didukung di /tasks/5"}}

DELETE /reports => 404
Content-Type: application/json
{"error":{"code":"NOT_FOUND","message":"Endpoint tidak ditemukan"}}

Perbedaan dua kasus pada output terlihat jelas: DELETE /tasks/5 menabrak path yang dikenal sehingga router mengumpulkan $allowed (GET, PUT) dan membalas 405 dengan header Allow; sedangkan DELETE /reports tidak menemukan path sama sekali sehingga jatuh ke 404. Loop tunggal di dispatchRoutes() sekaligus melakukan pencocokan dan pengumpulan method — tanpa perlu memindai tabel rute dua kali.

Trailing Slash dan Kapitalisasi Path #

Dua kasus tepi yang sering membingungkan klien: /tasks/ dengan trailing slash dan /Tasks dengan kapital berbeda. Tanpa normalisasi, keduanya dianggap endpoint berbeda dari /tasks dan berujung 404 yang membingungkan. Kebijakan paling sederhana: buang trailing slash sebelum mencocokkan, tetapi biarkan path tetap case-sensitive.

php
<?php
function normalizePath(string $path): string
{
    $trimmed = rtrim($path, "/");

    return $trimmed === "" ? "/" : $trimmed;
}

foreach (["/tasks", "/tasks/", "/tasks/7/", "/"] as $path) {
    echo $path . " -> " . normalizePath($path) . "\n";
}

var_dump(normalizePath("/Tasks") === normalizePath("/tasks"));

Output yang Dihasilkan:

text
/tasks -> /tasks
/tasks/ -> /tasks
/tasks/7/ -> /tasks/7
/ -> /
bool(false)

Fungsi normalizePath() menggunakan rtrim() untuk membuang semua garis miring di akhir, dengan satu pengecualian: path root "/" tidak boleh menjadi string kosong. Baris var_dump terakhir menegaskan bahwa /Tasks dan /tasks tetap berbeda — sesuai spesifikasi HTTP, komponen path URL bersifat case-sensitive, jadi mereturn 404 untuk /Tasks adalah perilaku yang benar. Jika Anda ingin lebih ramah klien, sebagian API memilih mengarahkan ulang varian salah kapital dengan status 308, tetapi normalisasi otomatis semacam itu sebaiknya diputuskan sekali di level router, bukan per-handler.

Dari CLI ke Server Nyata #

Semua contoh di atas berjalan di CLI dengan array $request buatan. Pada server nyata, tugas router tetap sama; yang berubah hanyalah cara request dibaca dan respons dikirim. Potongan berikut adalah glue code yang mengisi array $request dari superglobal lalu mengirim hasil dispatch() ke klien:

php
<?php
// Glue code pada server nyata (bukan CLI):
$method = $_SERVER["REQUEST_METHOD"];
$path = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);

$request = [
    "method" => $method,
    "path" => $path,
    "query" => $_GET,
    "headers" => [],
    "body" => json_decode(file_get_contents("php://input"), true),
];

[$status, $headers, $body] = dispatch($routes, $request);

http_response_code($status);
foreach ($headers as $header) {
    header($header);
}
echo $body;

Glue code seperti ini hanya beberapa baris, tetapi memisahkannya dari logika routing membuat keseluruhan aplikasi tetap bisa diuji. Pembahasan superglobal $_SERVER dan $_GET sudah pernah kita singgung pada Section Web Fundamentals . Ketika kebutuhan Anda tumbuh — middleware, grup rute, caching pattern — pola tabel rute inilah yang dikembangkan menjadi router penuh, seperti yang kita bangun pada Chapter Membuat Router .

Ringkasan #

Router memetakan kombinasi method dan path ke handler melalui dispatch table, menangkap parameter dinamis dengan named group regex, dan membedakan 405 (path dikenal, method salah) dari 404 (path tidak dikenal). Normalisasi trailing slash dan kebijakan case-sensitivity sebaiknya diputuskan di satu tempat agar konsisten. Setelah router memilih handler, langkah berikutnya adalah memastikan handler memahami isi request itu sendiri — membaca body JSON, query string, dan header — yang akan kita bahas pada Chapter Request Parsing .

Referensi #