Endpoint list yang mengembalikan seluruh data akan menjadi masalah saat dataset tumbuh — respons besar, memory tinggi, dan klien lambat. Solusinya adalah pagination (membagi data ke halaman kecil) dan filtering (mempersempit hasil). Pada chapter ini kita akan membandingkan dua strategi pagination — page-based dan cursor-based — serta menerapkan filter dinamis.

Page-Based Pagination #

Page-based adalah strategi paling sederhana. Klien mengirim ?page=1&pageSize=10, server menghitung offset (page - 1) * pageSize, dan mengembalikan slice data plus metadata. Metadata mencakup totalItems, totalPages, dan page saat ini — agar klien dapat menampilkan navigasi.

text Page-based URL
GET /produk?page=2&pageSize=10

Response:
{
  "items": [...],
  "page": 2,
  "pageSize": 10,
  "totalItems": 95,
  "totalPages": 10
}

Kelebihan: mudah diimplementasi, klien dapat melompat ke halaman berapa pun. Kekurangan: bila data berubah saat klien meminta halaman berikutnya (insert baru di awal), item dapat bergeser — klien melihat item sama dua kali atau melewati item tertentu.

Cursor-Based Pagination #

Cursor-based memakai pointer (cursor) ke item terakhir yang dilihat. Klien mengirim ?cursor=xxx&pageSize=10, server mengembalikan slice setelah cursor plus cursor berikutnya. Bila cursor null, artinya tidak ada lagi data.

text Cursor-based URL
GET /produk?pageSize=10                -> first page
GET /produk?pageSize=10&cursor=abc123 -> next page

Response:
{
  "items": [...],
  "nextCursor": "abc124"  // null bila sudah halaman terakhir
}

Kelebihan: stabil terhadap insert/delete — cursor menunjuk ke item tertentu, bukan posisi absolut. Cocok untuk infinite scroll timeline sosial media. Kekurangan: tidak bisa lompat ke halaman tertentu — klien harus traverse dari awal.

Filter Dinamis #

Filter mempersempit hasil berdasarkan atribut: ?kategori=minuman&minHarga=10000&maxHarga=20000. Server membangun query dinamis berdasarkan parameter yang ada — parameter null dilewati.

kotlin Filter dinamis
fun filter(
    kategori: String? = null,
    minHarga: Int? = null,
    maxHarga: Int? = null
): List<Produk> = data.filter { p ->
    (kategori == null || p.kategori == kategori) &&
    (minHarga == null || p.harga >= minHarga) &&
    (maxHarga == null || p.harga <= maxHarga)
}

Pola parameter == null || kondisi memastikan filter hanya aktif bila parameter diberikan. Hal ini membuat endpoint dapat menerima kombinasi filter apa pun.

Demo Pagination dan Filtering #

Kita akan membangun ProdukApi dengan dua method pagination (listPage, listCursor) dan satu method filter. Program menguji ketiga pola pada dataset 10 produk.

kotlin
// api_pagination_dan_filtering.kt
// Mendemonstrasikan pagination dan filtering pada REST API: page/pageSize,
// cursor-based pagination, dan filter dinamis.

data class Produk(
    val id: Int,
    val nama: String,
    val kategori: String,
    val harga: Int
)

// Page-based response: metadata + items
data class PageResponse<T>(
    val items: List<T>,
    val page: Int,
    val pageSize: Int,
    val totalItems: Int,
    val totalPages: Int
)

// Cursor response: gunakan nextCursor (opaque string) untuk halaman berikutnya
data class CursorResponse<T>(
    val items: List<T>,
    val nextCursor: String?
)

class ProdukApi(private val data: List<Produk>) {

    // Page-based: ?page=1&pageSize=10
    fun listPage(page: Int, pageSize: Int): PageResponse<Produk> {
        val total = data.size
        val totalPages = (total + pageSize - 1) / pageSize
        val fromIndex = ((page - 1) * pageSize).coerceAtLeast(0).coerceAtMost(total)
        val toIndex = (fromIndex + pageSize).coerceAtMost(total)
        val items = if (fromIndex < toIndex) data.subList(fromIndex, toIndex) else emptyList()
        return PageResponse(items, page, pageSize, total, totalPages)
    }

    // Cursor-based: cursor = "<id>:<kategori>" terakhir halaman sebelumnya
    fun listCursor(cursor: String?, pageSize: Int, kategoriFilter: String? = null): CursorResponse<Produk> {
        val filtered = if (kategoriFilter != null) data.filter { it.kategori == kategoriFilter } else data
        val startIdx = if (cursor == null) 0 else {
            val lastId = cursor.substringBefore(":").toInt()
            (filtered.indexOfFirst { it.id == lastId } + 1).coerceAtLeast(0)
        }
        val endIdx = (startIdx + pageSize).coerceAtMost(filtered.size)
        val items = if (startIdx < endIdx) filtered.subList(startIdx, endIdx) else emptyList()
        val nextCursor = if (endIdx < filtered.size) "${items.last().id}:${items.last().nama}" else null
        return CursorResponse(items, nextCursor)
    }

    // Filter dinamis: kategori + range harga
    fun filter(kategori: String? = null, minHarga: Int? = null, maxHarga: Int? = null): List<Produk> =
        data.filter { p ->
            (kategori == null || p.kategori == kategori) &&
            (minHarga == null || p.harga >= minHarga) &&
            (maxHarga == null || p.harga <= maxHarga)
        }
}

fun main() {
    val data = listOf(
        Produk(1, "Kopi Susu", "minuman", 18000),
        Produk(2, "Es Teh Manis", "minuman", 8000),
        Produk(3, "Nasi Goreng", "makanan", 25000),
        Produk(4, "Mie Goreng", "makanan", 22000),
        Produk(5, "Keripik Singkong", "snack", 12000),
        Produk(6, "Air Mineral", "minuman", 5000),
        Produk(7, "Ayam Bakar", "makanan", 30000),
        Produk(8, "Roti Bakar", "snack", 15000),
        Produk(9, "Es Jeruk", "minuman", 10000),
        Produk(10, "Sate Ayam", "makanan", 28000)
    )
    val api = ProdukApi(data)

    println("=== Page-based pagination ===")
    println("GET /produk?page=1&pageSize=3")
    val p1 = api.listPage(1, 3)
    println("  page=${p1.page} pageSize=${p1.pageSize} total=${p1.totalItems} totalPages=${p1.totalPages}")
    p1.items.forEach { println("    $it") }
    println("GET /produk?page=4&pageSize=3 (halaman terakhir kosong)")
    val p4 = api.listPage(4, 3)
    println("  items: ${p4.items.size}")

    println("\n=== Cursor-based pagination ===")
    println("GET /produk?pageSize=3 (no cursor)")
    var cursor: String? = null
    var resp = api.listCursor(cursor, 3)
    println("  nextCursor=${resp.nextCursor}")
    resp.items.forEach { println("    $it") }

    println("GET /produk?pageSize=3&cursor=${resp.nextCursor}")
    cursor = resp.nextCursor
    resp = api.listCursor(cursor, 3)
    println("  nextCursor=${resp.nextCursor}")
    resp.items.forEach { println("    $it") }

    println("GET /produk?pageSize=3&cursor=${resp.nextCursor} (halaman terakhir)")
    cursor = resp.nextCursor
    resp = api.listCursor(cursor, 3)
    println("  nextCursor=${resp.nextCursor}")
    resp.items.forEach { println("    $it") }

    println("\n=== Filter dinamis ===")
    println("GET /produk?kategori=minuman")
    api.filter(kategori = "minuman").forEach { println("    $it") }

    println("GET /produk?kategori=makanan&minHarga=25000")
    api.filter(kategori = "makanan", minHarga = 25000).forEach { println("    $it") }

    println("GET /produk?minHarga=15000&maxHarga=20000")
    api.filter(minHarga = 15000, maxHarga = 20000).forEach { println("    $it") }
}

Output yang Dihasilkan:

text
=== Page-based pagination ===
GET /produk?page=1&pageSize=3
  page=1 pageSize=3 total=10 totalPages=4
    Produk(id=1, nama=Kopi Susu, kategori=minuman, harga=18000)
    Produk(id=2, nama=Es Teh Manis, kategori=minuman, harga=8000)
    Produk(id=3, nama=Nasi Goreng, kategori=makanan, harga=25000)
GET /produk?page=4&pageSize=3 (halaman terakhir kosong)
  items: 1

=== Cursor-based pagination ===
GET /produk?pageSize=3 (no cursor)
  nextCursor=3:Nasi Goreng
    Produk(id=1, nama=Kopi Susu, kategori=minuman, harga=18000)
    Produk(id=2, nama=Es Teh Manis, kategori=minuman, harga=8000)
    Produk(id=3, nama=Nasi Goreng, kategori=makanan, harga=25000)
GET /produk?pageSize=3&cursor=3:Nasi Goreng
  nextCursor=6:Air Mineral
    Produk(id=4, nama=Mie Goreng, kategori=makanan, harga=22000)
    Produk(id=5, nama=Keripik Singkong, kategori=snack, harga=12000)
    Produk(id=6, nama=Air Mineral, kategori=minuman, harga=5000)
GET /produk?pageSize=3&cursor=6:Air Mineral (halaman terakhir)
  nextCursor=9:Es Jeruk
    Produk(id=7, nama=Ayam Bakar, kategori=makanan, harga=30000)
    Produk(id=8, nama=Roti Bakar, kategori=snack, harga=15000)
    Produk(id=9, nama=Es Jeruk, kategori=minuman, harga=10000)

=== Filter dinamis ===
GET /produk?kategori=minuman
    Produk(id=1, nama=Kopi Susu, kategori=minuman, harga=18000)
    Produk(id=2, nama=Es Teh Manis, kategori=minuman, harga=8000)
    Produk(id=6, nama=Air Mineral, kategori=minuman, harga=5000)
    Produk(id=9, nama=Es Jeruk, kategori=minuman, harga=10000)
GET /produk?kategori=makanan&minHarga=25000
    Produk(id=3, nama=Nasi Goreng, kategori=makanan, harga=25000)
    Produk(id=7, nama=Ayam Bakar, kategori=makanan, harga=30000)
    Produk(id=10, nama=Sate Ayam, kategori=makanan, harga=28000)
GET /produk?minHarga=15000&maxHarga=20000
    Produk(id=1, nama=Kopi Susu, kategori=minuman, harga=18000)
    Produk(id=8, nama=Roti Bakar, kategori=snack, harga=15000)

Mari kita bedah bagian penting kode di atas:

  1. data class PageResponse<T>(val items, val page, val pageSize, val totalItems, val totalPages): generik response yang membawa items plus metadata. Generic T memungkinkan reuse untuk PageResponse<Produk>, PageResponse<User>, dst.
  2. ((page - 1) * pageSize).coerceAtLeast(0).coerceAtMost(total): hitung offset dengan clamp. coerceAtLeast(0) memastikan offset tidak negatif (bila page=0 atau page=-1). coerceAtMost(total) memastikan tidak melebihi ukuran data.
  3. val startIdx = if (cursor == null) 0 else ...: cursor null artinya halaman pertama. Bila cursor ada, cari indeks item dengan ID yang sama di list filtered, lalu +1 untuk mulai setelahnya.
  4. val nextCursor = if (endIdx < filtered.size) "${items.last().id}:${items.last().nama}" else null: bila masih ada item berikutnya, kembalikan cursor berupa string ID-nama. Bila sudah di akhir, kembalikan null sebagai sinyal “tidak ada lagi”.
  5. (kategori == null || p.kategori == kategori): short-circuit evaluation. Bila kategori == null, kondisi true tanpa mengecek p.kategori. Bila kategori tidak null, baru cek kesamaan.

Kapan Pakai Page vs Cursor #

  • Page-based: dashboard admin, hasil pencarian dengan filter, daftar produk dengan navigasi halaman.
  • Cursor-based: timeline sosial media, feed notifikasi, log streaming — di mana data sering berubah dan stabilitas urutan penting.

Referensi Lanjutan #

  • Ktor requests — panduan membaca query parameter page dan size untuk paginasi API di Ktor
  • Spring guides — kumpulan panduan Spring yang merangkum pola paginasi dan filtering REST

Selanjutnya #

Setelah pagination, langkah berikutnya adalah mengelola evolusi API. Chapter API Versioning akan membahas URL versioning /v1/, header versioning X-API-Version, dan content negotiation via Accept header.