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.
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.
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.
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.
// 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:
=== 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:
data class PageResponse<T>(val items, val page, val pageSize, val totalItems, val totalPages): generik response yang membawa items plus metadata. GenericTmemungkinkan reuse untukPageResponse<Produk>,PageResponse<User>, dst.((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.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.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”.(kategori == null || p.kategori == kategori): short-circuit evaluation. Bilakategori == null, kondisi true tanpa mengecekp.kategori. Bilakategoritidak 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
pagedansizeuntuk 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.