API publik cenderung berubah: field baru ditambah, field lama dihapus, struktur respons direstrukturisasi. Tanpa versioning, perubahan breaking akan merusak klien yang sudah terlanjur produksi. Versioning memberi jalur migrasi — klien lama tetap memakai versi lama, klien baru pindah ke versi baru. Pada chapter ini kita akan membandingkan tiga strategi versioning: URL, header, dan content negotiation.

URL Versioning #

Strategi paling populer adalah menanam versi di URL: /v1/users, /v2/users, /v3/users. Klien memilih versi dengan mengubah path; server memilih handler berdasarkan prefix.

text URL versioning
GET /v1/users  -> {"id":1,"nama":"Budi Santoso"}
GET /v2/users  -> {"id":1,"namaDepan":"Budi","namaBelakang":"Santoso"}
GET /v3/users  -> {"id":1,"namaDepan":"Budi","namaBelakang":"Santoso","email":"[email protected]"}

Kelebihan: jelas terlihat di log, mudah di-debug, kompatibel dengan cache HTTP (URL berbeda = cache berbeda). Kekurangan: URL menjadi panjang dan banyak versi dapat aktif bersamaan, meningkatkan beban maintenance.

Header Versioning #

Header versioning menyimpan versi di header HTTP X-API-Version. Klien mengirim header ini; server membaca dan memilih handler.

text Header versioning
GET /users
X-API-Version: 2

Response:
{"id":1,"namaDepan":"Budi","namaBelakang":"Santoso"}

Kelebihan: URL tetap bersih. Kekurangan: tidak terlihat di log URL, sulit diuji via browser tanpa ekstensi. Beberapa proxy/cache mungkin tidak menyertakan header custom.

Content Negotiation #

Content negotiation memakai header Accept dengan custom media type: application/vnd.example.v3+json. Versi dibedakan via media type, bukan URL atau header custom.

text Content negotiation
GET /users
Accept: application/vnd.example.v3+json

Response:
Content-Type: application/vnd.example.v3+json
{"id":1,"namaDepan":"Budi","namaBelakang":"Santoso","email":"[email protected]"}

Kelebihan: mengikuti semantik HTTP asli. Kekurangan: media type custom jarang dipahami tool umum (mis. Postman default tetap application/json).

Deprecation dan Sunset #

Saat versi lama dihapus, klien perlu diperingatkan. HTTP RFC mendefinisikan header Deprecation: true dan Sunset: <date> untuk tujuan ini. Deprecation menandai bahwa versi akan dihapus; Sunset menentukan tanggal penghapusan.

text Deprecation header
GET /v0/users

Response:
HTTP/1.1 410 Gone
Deprecation: true
Sunset: Wed, 01 Jun 2026 00:00:00 GMT

{"error":"Gone: v0 sunset 2026-06-01, pakai v3"}

Status 410 Gone menandakan resource permanen hilang. Klien yang melihat header ini sebaiknya memigrasi sebelum tanggal Sunset.

Demo Versioning #

Kita akan membangun tiga handler — handleUrlVersioning, handleHeaderVersioning, handleContentNegotiation — yang menerima request dan mengembalikan respons berdasarkan versi yang diminta. Program ini mendemonstrasikan lima skenario.

kotlin
// api_versioning.kt
// Mendemonstrasikan strategi versioning REST API: URL versioning (/v1/),
// header versioning, content negotiation (Accept header), dan deprecation.

data class UserV1(val id: Int, val nama: String)
data class UserV2(val id: Int, val namaDepan: String, val namaBelakang: String)
data class UserV3(val id: Int, val namaDepan: String, val namaBelakang: String, val email: String)

object UserStore {
    val v1 = listOf(UserV1(1, "Budi Santoso"), UserV1(2, "Siti Aminah"))
    val v2 = listOf(
        UserV2(1, "Budi", "Santoso"),
        UserV2(2, "Siti", "Aminah")
    )
    val v3 = listOf(
        UserV3(1, "Budi", "Santoso", "[email protected]"),
        UserV3(2, "Siti", "Aminah", "[email protected]")
    )
}

data class HttpRequest(
    val path: String,
    val headers: Map<String, String> = emptyMap()
)
data class HttpResponse(val status: Int, val body: String, val headers: Map<String, String> = emptyMap())

// URL Versioning: /v1/users, /v2/users, /v3/users
fun handleUrlVersioning(req: HttpRequest): HttpResponse {
    val path = req.path.trimStart('/')
    return when {
        path.startsWith("v1/users") -> {
            val body = UserStore.v1.joinToString(prefix = "[", postfix = "]") {
                """{"id":${it.id},"nama":"${it.nama}"}"""
            }
            HttpResponse(200, body, mapOf("X-API-Version" to "v1"))
        }
        path.startsWith("v2/users") -> {
            val body = UserStore.v2.joinToString(prefix = "[", postfix = "]") {
                """{"id":${it.id},"namaDepan":"${it.namaDepan}","namaBelakang":"${it.namaBelakang}"}"""
            }
            HttpResponse(200, body, mapOf("X-API-Version" to "v2"))
        }
        path.startsWith("v3/users") -> {
            val body = UserStore.v3.joinToString(prefix = "[", postfix = "]") {
                """{"id":${it.id},"namaDepan":"${it.namaDepan}","namaBelakang":"${it.namaBelakang}","email":"${it.email}"}"""
            }
            HttpResponse(200, body, mapOf("X-API-Version" to "v3"))
        }
        path.startsWith("v0/users") -> {
            // Deprecated: kembalikan data V1 dengan header Deprecation + Sunset
            HttpResponse(410, """{"error":"Gone: v0 sunset 2026-06-01, pakai v3"}""",
                mapOf("Deprecation" to "true", "Sunset" to "Wed, 01 Jun 2026 00:00:00 GMT"))
        }
        else -> HttpResponse(404, """{"error":"Not Found"}""")
    }
}

// Header Versioning: X-API-Version: 1, 2, 3
fun handleHeaderVersioning(req: HttpRequest): HttpResponse {
    val version = req.headers["X-API-Version"] ?: "1"
    return when (version) {
        "1" -> HttpResponse(200, """{"id":1,"nama":"Budi Santoso"}""", mapOf("X-API-Version" to "1"))
        "2" -> HttpResponse(200, """{"id":1,"namaDepan":"Budi","namaBelakang":"Santoso"}""",
            mapOf("X-API-Version" to "2"))
        "3" -> HttpResponse(200,
            """{"id":1,"namaDepan":"Budi","namaBelakang":"Santoso","email":"[email protected]"}""",
            mapOf("X-API-Version" to "3"))
        else -> HttpResponse(400, """{"error":"Unknown version $version"}""")
    }
}

// Content Negotiation: Accept: application/vnd.example.v3+json
fun handleContentNegotiation(req: HttpRequest): HttpResponse {
    val accept = req.headers["Accept"] ?: "application/json"
    val version = Regex("""vnd\.example\.v(\d+)\+json""").find(accept)?.groupValues?.get(1)
    return when (version) {
        "1" -> HttpResponse(200, """{"id":1,"nama":"Budi Santoso"}""",
            mapOf("Content-Type" to "application/vnd.example.v1+json"))
        "2" -> HttpResponse(200, """{"id":1,"namaDepan":"Budi","namaBelakang":"Santoso"}""",
            mapOf("Content-Type" to "application/vnd.example.v2+json"))
        "3" -> HttpResponse(200,
            """{"id":1,"namaDepan":"Budi","namaBelakang":"Santoso","email":"[email protected]"}""",
            mapOf("Content-Type" to "application/vnd.example.v3+json"))
        else -> HttpResponse(200, """{"id":1,"nama":"Budi Santoso"}""",
            mapOf("Content-Type" to "application/json"))
    }
}

fun main() {
    println("=== URL Versioning: /v1/users ===")
    val r1 = handleUrlVersioning(HttpRequest("v1/users"))
    println("  Status=${r1.status} X-API-Version=${r1.headers["X-API-Version"]}")
    println("  Body=${r1.body}")

    println("\n=== URL Versioning: /v3/users ===")
    val r3 = handleUrlVersioning(HttpRequest("v3/users"))
    println("  Status=${r3.status} X-API-Version=${r3.headers["X-API-Version"]}")
    println("  Body=${r3.body}")

    println("\n=== URL Versioning: /v0/users (deprecated, sunset) ===")
    val r0 = handleUrlVersioning(HttpRequest("v0/users"))
    println("  Status=${r0.status}")
    println("  Deprecation=${r0.headers["Deprecation"]} Sunset=${r0.headers["Sunset"]}")
    println("  Body=${r0.body}")

    println("\n=== Header Versioning: X-API-Version=2 ===")
    val rh2 = handleHeaderVersioning(HttpRequest("/users", mapOf("X-API-Version" to "2")))
    println("  Status=${rh2.status} Body=${rh2.body}")

    println("\n=== Content Negotiation: Accept=application/vnd.example.v3+json ===")
    val rc3 = handleContentNegotiation(HttpRequest("/users",
        mapOf("Accept" to "application/vnd.example.v3+json")))
    println("  Status=${rc3.status} Content-Type=${rc3.headers["Content-Type"]}")
    println("  Body=${rc3.body}")

    println("\n=== Content Negotiation: Accept=application/json (default v1) ===")
    val rc0 = handleContentNegotiation(HttpRequest("/users",
        mapOf("Accept" to "application/json")))
    println("  Status=${rc0.status} Content-Type=${rc0.headers["Content-Type"]}")
    println("  Body=${rc0.body}")
}

Output yang Dihasilkan:

text
=== URL Versioning: /v1/users ===
  Status=200 X-API-Version=v1
  Body=[{"id":1,"nama":"Budi Santoso"}, {"id":2,"nama":"Siti Aminah"}]

=== URL Versioning: /v3/users ===
  Status=200 X-API-Version=v3
  Body=[{"id":1,"namaDepan":"Budi","namaBelakang":"Santoso","email":"[email protected]"}, {"id":2,"namaDepan":"Siti","namaBelakang":"Aminah","email":"[email protected]"}]

=== URL Versioning: /v0/users (deprecated, sunset) ===
  Status=410
  Deprecation=true Sunset=Wed, 01 Jun 2026 00:00:00 GMT
  Body={"error":"Gone: v0 sunset 2026-06-01, pakai v3"}

=== Header Versioning: X-API-Version=2 ===
  Status=200 Body={"id":1,"namaDepan":"Budi","namaBelakang":"Santoso"}

=== Content Negotiation: Accept=application/vnd.example.v3+json ===
  Status=200 Content-Type=application/vnd.example.v3+json
  Body={"id":1,"namaDepan":"Budi","namaBelakang":"Santoso","email":"[email protected]"}

=== Content Negotiation: Accept=application/json (default v1) ===
  Status=200 Content-Type=application/json
  Body={"id":1,"nama":"Budi Santoso"}

Mari kita bedah bagian penting kode di atas:

  1. data class UserV1, UserV2, UserV3: tiga DTO terpisah untuk tiap versi. Field nama di v1 dipecah menjadi namaDepan+namaBelakang di v2, lalu ditambah email di v3. DTO terpisah memastikan tipe safety — klien tidak dapat akses field yang tidak ada di versi mereka.
  2. when { path.startsWith("v1/users") -> ... }: pencocokan prefix pada URL. Spring MVC memakai @RequestMapping("/v1/users") untuk pola yang sama — di sini kita lakukan manual.
  3. val version = req.headers["X-API-Version"] ?: "1": default versi 1 bila header tidak ada. Default penting untuk backward compatibility — klien lama yang tidak mengirim header tetap mendapat respons v1.
  4. Regex("vnd\\.example\\.v(\\d+)\\+json"): regex mengekstrak nomor versi dari media type custom. vnd.example adalah vendor prefix sesuai RFC 6838. Versi v3 di-capture sebagai group pertama.
  5. mapOf("Deprecation" to "true", "Sunset" to "Wed, 01 Jun 2026 ..."): header deprecation yang dibaca klien untuk migrasi. Status 410 Gone menandakan resource permanen dihapus.

Kapan Perlu Versioning #

Tidak semua perubahan memerlukan versi baru. Penambahan field (non-breaking) tidak perlu versi — klien lama tetap dapat memparse respons baru (field tambahan diabaikan bila klien tidak membacanya). Perubahan breaking (menghapus field, mengubah tipe, mengubah semantik) wajib versi baru.

Referensi Lanjutan #

  • Ktor routing — panduan routing Ktor untuk menata prefix versi seperti /api/v1 dan /api/v2
  • Spring Boot reference — rujukan Spring Boot untuk strategi versioning berbasis header dan URL

Selanjutnya #

Setelah versioning dipahami, langkah berikutnya adalah dokumentasi API. Chapter API Documentation akan membahas OpenAPI/Swagger dan springdoc-openapi yang menghasilkan dokumentasi otomatis dari kode.