API bukan sekadar kode — API adalah kontrak dengan klien yang tidak bisa Anda paksa ikut memperbarui programnya. Aplikasi mobile yang sudah terpasang di jutaan ponsel akan terus memanggil bentuk respons yang dikenalnya selama berbulan-bulan, apa pun yang terjadi di sisi server. Versioning adalah mekanisme untuk mengubah kontrak itu secara terkendali: klien lama tetap dilayani oleh versi lama, klien baru menikmati versi baru. Pada Chapter ini kita bandingkan tiga strategi penempatan versi, bangun router yang melayani dua versi sekaligus, lalu susun proses deprecation dengan header Sunset.

Perubahan Breaking dan Non-Breaking #

Kebutuhan versi baru hanya muncul dari perubahan breaking — perubahan yang membuat klien lama gagal memahami respons atau requestnya ditolak. Kategori paling umumnya:

  • Menghapus atau mengganti nama field respons, misalnya name dipecah menjadi first_name dan last_name.
  • Mengubah tipe field, misalnya id dari angka menjadi string.
  • Menjadikan field request wajib yang sebelumnya opsional.
  • Mengubah makna endpoint — status code yang berubah, aturan validasi yang makin ketat, atau format error yang berubah bentuk.

Sebaliknya, perubahan berikut aman dilakukan tanpa versi baru karena klien lama tetap berjalan: menambah field baru pada respons, menambah endpoint baru, menambah parameter request opsional yang punya nilai default, dan mengubah implementasi internal selama kontraknya utuh. Klien yang tidak mengenali field baru cukup mengabaikannya.

Tiga Strategi Penempatan Versi #

Ada tiga tempat populer untuk menyimpan nomor versi. Pemilihannya memengaruhi kenyamanan debugging, infrastruktur cache, dan beban klien:

AspekURL path (/v1/)Custom headerContent negotiation
Visibilitas saat debuggingTinggi — versi terlihat di URL dan log aksesRendah — versi tersembunyi di headerRendah — versi tersembunyi di header
Cache dan routing CDNMudah — tiap versi berbeda pathPerlu kehati-hatian agar cache membedakan versiPerlu kehati-hatian agar cache membedakan versi
Usaha klienPaling rendah — cukup ganti URLSedang — klien harus set header tambahanTertinggi — klien mengelola header Accept
Identitas resourceSatu resource tampak sebagai URL berbedaSatu URL untuk semua versiSatu URL untuk semua versi

Strategi URL path menjadi paling umum karena kemenangannya di kesederhanaan: log, dokumentasi, dan curl semuanya langsung memperlihatkan versi yang dipakai. Custom header (misalnya X-API-Version: 2) menyembunyikan versi dari URL dan cocok ketika versi dianggap detail implementasi. Content negotiation menempatkan versi di header Accept sebagai bagian dari tipe media — pendekatan yang dipakai GitHub dengan media type application/vnd.github+json.

Router Berdasarkan Versi #

Kita wujudkan strategi URL path pada router dari Chapter Routing dan Endpoints . Tabel $routes dikunci dua tingkat: versi lebih dulu, lalu kunci METHOD /path. Handler v1 sudah kita tandai deprecated dengan header Deprecation dan Sunset:

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

function parseVersion(string $path): ?string
{
    if (preg_match("#^/v(\d+)/#", $path, $matches) === 1) {
        return "v" . $matches[1];
    }

    return null;
}

function dispatch(array $request, array $routes): array
{
    $version = parseVersion($request["path"]);

    if ($version === null || !isset($routes[$version])) {
        return respond(404, [
            "error" => [
                "code" => "VERSION_NOT_FOUND",
                "message" => "Versi API tidak dikenal atau tidak tersedia",
            ],
        ]);
    }

    $pathWithoutVersion = preg_replace("#^/v\d+#", "", $request["path"]);
    $routeKey = $request["method"] . " " . $pathWithoutVersion;
    $handler = $routes[$version][$routeKey] ?? null;

    if ($handler === null) {
        return respond(404, [
            "error" => ["code" => "NOT_FOUND", "message" => "Endpoint tidak ditemukan"],
        ]);
    }

    return $handler($request);
}

// v1: nama pengguna masih satu field "name" (sudah masuk masa deprecation)
function showUserV1(array $request): array
{
    return respond(200, [
        "data" => ["id" => 42, "name" => "Budi Santoso", "email" => "[email protected]"],
    ], [
        "Deprecation: true",
        "Sunset: Sat, 01 Jan 2027 00:00:00 GMT",
    ]);
}

// v2: name dipecah menjadi first_name dan last_name (perubahan breaking)
function showUserV2(array $request): array
{
    return respond(200, [
        "data" => [
            "id" => 42,
            "first_name" => "Budi",
            "last_name" => "Santoso",
            "email" => "[email protected]",
        ],
    ]);
}

$routes = [
    "v1" => ["GET /users/42" => showUserV1(...)],
    "v2" => ["GET /users/42" => showUserV2(...)],
];

$requests = [
    ["method" => "GET", "path" => "/v1/users/42", "query" => [], "headers" => [], "body" => null],
    ["method" => "GET", "path" => "/v2/users/42", "query" => [], "headers" => [], "body" => null],
    ["method" => "GET", "path" => "/v3/users/42", "query" => [], "headers" => [], "body" => null],
    ["method" => "GET", "path" => "/v2/users/99", "query" => [], "headers" => [], "body" => null],
];

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

    foreach ($headers as $header) {
        if (str_starts_with($header, "Deprecation:") || str_starts_with($header, "Sunset:")) {
            echo "    " . $header . "\n";
        }
    }
}

Output yang Dihasilkan:

text
/v1/users/42 => HTTP 200 {"data":{"id":42,"name":"Budi Santoso","email":"[email protected]"}}
    Deprecation: true
    Sunset: Sat, 01 Jan 2027 00:00:00 GMT
/v2/users/42 => HTTP 200 {"data":{"id":42,"first_name":"Budi","last_name":"Santoso","email":"[email protected]"}}
/v3/users/42 => HTTP 404 {"error":{"code":"VERSION_NOT_FOUND","message":"Versi API tidak dikenal atau tidak tersedia"}}
/v2/users/99 => HTTP 404 {"error":{"code":"NOT_FOUND","message":"Endpoint tidak ditemukan"}}

Mari kita bedah alurnya:

  1. parseVersion(): regex ^/v(\d+)/ menangkap segmen versi di awal path — /v1/users/42 menghasilkan "v1". Path tanpa versi mengembalikan null sehingga dispatch menolak lebih awal.
  2. Pemeriksaan !isset($routes[$version]): versi yang tidak terdaftar (request /v3/...) dibalas 404 dengan kode VERSION_NOT_FOUND — pesan yang jauh lebih membantu daripada rasa bingung setelah menerima bentuk respons tak dikenal.
  3. preg_replace("#^/v\d+#", "", ...): mengupas segmen versi sehingga /v1/users/42 dan /v2/users/42 jatuh ke kunci route yang sama, GET /users/42.
  4. showUserV1(...): sintaks first-class callable dari PHP 8.1 — ekspresi ini menghasilkan Closure dari fungsi tanpa memanggilnya, lebih ringkas daripada "showUserV1" plus function_exists().
  5. Header pada v1: handler lama tetap mengembalikan bentuk data lamanya, tetapi setiap respons kini membawa Deprecation: true dan Sunset berisi tanggal jam mati — detailnya kita bahas di bagian deprecation.

Perhatikan bahwa dua versi hidup berdampingan dalam satu proses tanpa if bersarang: tabel routes sudah memisahkan dunianya masing-masing.

Versi lewat Content Negotiation #

Strategi ketiga memakai header Accept dengan vendor media type — tipe media kustom bernamespace, seperti application/vnd.taskapp.v2+json. Fungsi kecil berikut mengekstraksi versi dari header tersebut:

php
<?php
function versionFromAccept(?string $accept): ?string
{
    if ($accept === null) {
        return null;
    }

    if (preg_match("#application/vnd\.taskapp\.v(\d+)\+json#", $accept, $matches) === 1) {
        return "v" . $matches[1];
    }

    return null;
}

$acceptHeaders = [
    "application/vnd.taskapp.v2+json",
    "application/vnd.taskapp.v1+json, application/json;q=0.8",
    "application/json",
];

foreach ($acceptHeaders as $accept) {
    echo $accept . " => " . (versionFromAccept($accept) ?? "tanpa versi") . "\n";
}

Output yang Dihasilkan:

text
application/vnd.taskapp.v2+json => v2
application/vnd.taskapp.v1+json, application/json;q=0.8 => v1
application/json => tanpa versi

Regex menangkap angka setelah vnd.taskapp.v sebelum +json, sehingga q=0.8 di baris kedua — bobot preferensi dari mekanisme content negotiation — tidak mengganggu. Header ketiga tidak memuat tipe media kustom, jadi fungsi mengembalikan null dan dispatcher tinggal memilih versi default (biasanya versi stabil terbaru). Pada server nyata nilai ini dibaca dari $_SERVER["HTTP_ACCEPT"], pola yang sudah kita kenal di Chapter Request Parsing . Keunggulan pendekatan ini: URL resource tunggal untuk semua versi, dan klien bisa menegosiasikan format sekaligus versi dalam satu header.

Biaya Menjalankan Versi Secara Paralel #

Setiap versi yang tetap hidup adalah kode yang tetap harus dirawat. Perbaikan bug pada logika bersama harus diterapkan dua kali; rangkaian test harus dijalankan untuk matriks dua versi; dokumentasi dan changelog dobel. Tim juga cenderung menunda penghapusan versi lama tanpa batas karena “belum ada yang komplain” — utang teknis yang diam-diam menumpuk.

Kunci mengendalikan biayanya adalah menjaga handler versi setipis mungkin. Seluruh logika bisnis — akses database, aturan validasi, kalkulasi — hidup di satu lapisan service bersama; handler v1 dan v2 hanya menjadi adapter yang membentuk respons sesuai kontrak masing-masing. Pemecahan name menjadi first_name/last_name misalnya tetap membaca data pengguna yang sama, hanya cara menyusun JSON-nya yang berbeda. Menyalin-tempel handler utuh untuk membuat v2 adalah kesalahan yang paling mahal: kedua salinan akan segera berbeda perilakunya.

Depresiasi dan Header Sunset #

Menghapus versi lama adalah proses, bukan perintah sekali jalan. Alurnya yang sehat: umumkan jadwal penghapusan di changelog dan dokumentasi, lalu tandai setiap respons versi lama dengan header Deprecation dan Sunset seperti pada router di atas, pantau porsi trafik per versi dari log akses, dan hanya setelah trafik versi lama mendekati nol, hapus handler dan balas panggilan sisanya dengan 410 Gone — status yang tepat untuk resource yang sengaja dihapus permanen, selengkapnya di Chapter Status Codes dan Error Responses .

Header Sunset didefinisikan RFC 8594 sebagai tanggal ketika resource diperkirakan tidak lagi tersedia. Nilainya ditulis dalam format tanggal HTTP seperti Sat, 01 Jan 2027 00:00:00 GMT. Manfaat utamanya bukan untuk manusia, melainkan untuk mesin: SDK dan alat monitoring bisa membaca header ini secara otomatis, memperingatkan pemilik integrasi jauh sebelum tenggat, dan bahkan menolai jadwal migrasi dari tanggal yang terbaca. Beri jeda yang layak — enam hingga dua belas bulan untuk API publik adalah rentang yang umum.

Ringkasan #

Versioning menuntut disiplin mengenali perubahan breaking, memilih strategi penempatan versi (URL path menang di kesederhanaan), dan menjalankan versi paralel dengan handler tipis di atas service bersama. Deprecation yang sopan memakai header Deprecation dan Sunset serta pemantauan trafik sebelum penghapusan. Kontrak tiap versi kini perlu diumumkan ke dunia luar dalam bentuk yang bisa dibaca mesin — itulah pekerjaan spesifikasi OpenAPI, fokus Chapter berikutnya.

Referensi #