Endpoint daftar yang mengembalikan seluruh baris tabel hanya nyaman saat datanya dua puluh baris. Begitu koleksi tumbuh ke ribuan entri, respons seberat itu memperlambat klien, membengkakkan bandwidth, dan memperlambat server. Solusinya adalah tiga kemampuan yang berjalan bersama: pagination memotong hasil per halaman, filtering mempersempit baris yang relevan, dan sorting mengurutkan hasil sebelum dipotong. Pada chapter ini kita menerapkan ketiganya di atas net/http murni dengan parameter query, lalu mengujinya pada 25 item data deterministik lewat httptest.
Page/Limit atau Cursor #
Dua gaya pagination mendominasi praktik industri. Gaya page/limit memakai nomor halaman dan ukuran halaman — sederhana dipakai manusia di URL. Gaya cursor memakai penanda posisi (timestamp atau ID terakhir) — lebih tahan terhadap pergeseran data saat halaman berikut diminta.
| Aspek | Page/limit | Cursor |
|---|---|---|
| Kemudahan pemakaian | nomor halaman langsung dimanipulasi | klien harus menyimpan penanda |
| Lompat ke halaman acak | mudah | tidak dimungkinkan |
| Konsistensi saat data berubah | bisa berulang/terlewat | stabil untuk data bertambah |
| Biaya server (database offset) | makin mahal di halaman akhir | konstan dengan keyset |
Program chapter ini memakai page/limit karena datanya statis di memori dan mudah diperiksa; API besar dengan data yang terus berubah biasanya memilih cursor.
Parsing Parameter Query dengan Nilai Default #
Parameter pagination tiba sebagai string query dan harus diubah menjadi angka. Fungsi parseAngka memakai strconv.Atoi dan menjatuhkan input tidak valid — bukan angka, nol, atau negatif — ke nilai default; permintaan ?page=abc kembali ke halaman satu, bukan dijawab error. Kebijakan default yang dipilih: page=1, limit=10, kolom sort id, arah naik. Batasi limit maksimum (misalnya 100) di API produksi agar klien tidak meminta “semua data” lewat limit=1000000.
Sorting dengan Whitelist Kolom #
Parameter sort tidak boleh diteruskan begitu saja ke lapisan data. Program memeriksa kolom terhadap whitelist {"id", "title", "priority"}; nama kolom di luar daftar diganti kolom default. Pengurutan di sini berjalan di memori memakai slices.SortStableFunc dengan pembanding cmp.Compare — sort stabil menjaga urutan asli untuk item yang nilainya sama.
Metadata dan Link Header #
Respons daftar menyertakan meta berisi page, limit, total, dan pages — informasi minimal bagi klien untuk merender navigasi halaman. Selain metadata di body, header Link standar HTTP menyediakan tautan halaman tetangga: </items?...&page=3>; rel="next" dan rel="prev". Klien dapat berpindah halaman dengan mengikuti header tanpa merakit URL sendiri; url.Values.Encode() mengurutkan kunci secara alfabetis sehingga URL yang dihasilkan selalu deterministik.
Program: 25 Item dengan Filter, Sort, dan Pagination #
Program pagination-demo.go menyiapkan 25 item dengan prioritas unik hasil rumus (i*7)%n+1, lalu melayani GET /items dengan empat variasi query: tanpa parameter, halaman dua dengan sort prioritas menurun, filter judul dengan body utuh, dan query tidak valid yang jatuh ke default.
package main
import (
"bytes"
"cmp"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strconv"
"strings"
)
// Item adalah satu baris data katalog tugas.
type Item struct {
ID int `json:"id"`
Title string `json:"title"`
Priority int `json:"priority"`
}
// metaHalaman merangkum informasi pagination pada respons.
type metaHalaman struct {
Page int `json:"page"`
Limit int `json:"limit"`
Total int `json:"total"`
Pages int `json:"pages"`
}
type responsList struct {
Data []Item `json:"data"`
Meta metaHalaman `json:"meta"`
}
// kolomDiizinkan adalah whitelist kolom sorting.
// Kolom di luar daftar ini ditolak dan diganti kolom default.
var kolomDiizinkan = map[string]bool{
"id": true,
"title": true,
"priority": true,
}
// Handler melayani GET /items dengan filter, sorting, dan pagination.
type Handler struct {
items []Item
}
func (h *Handler) daftar(w http.ResponseWriter, r *http.Request) {
// Langkah 1: filter judul memakai substring (case-insensitive).
filter := strings.ToLower(r.URL.Query().Get("q"))
hasil := make([]Item, 0, len(h.items))
for _, it := range h.items {
if filter == "" || strings.Contains(strings.ToLower(it.Title), filter) {
hasil = append(hasil, it)
}
}
// Langkah 2: sorting — kolom harus lolos whitelist, arah hanya asc/desc.
kolom := r.URL.Query().Get("sort")
if !kolomDiizinkan[kolom] {
kolom = "id"
}
arah := 1
if r.URL.Query().Get("order") == "desc" {
arah = -1
}
slices.SortStableFunc(hasil, func(a, b Item) int {
var banding int
switch kolom {
case "title":
banding = strings.Compare(a.Title, b.Title)
case "priority":
banding = cmp.Compare(a.Priority, b.Priority)
default:
banding = cmp.Compare(a.ID, b.ID)
}
return arah * banding
})
// Langkah 3: pagination — nilai tidak valid jatuh ke default.
halaman := parseAngka(r.URL.Query().Get("page"), 1)
batas := parseAngka(r.URL.Query().Get("limit"), 10)
total := len(hasil)
jumlahHalaman := (total + batas - 1) / batas
if jumlahHalaman < 1 {
jumlahHalaman = 1
}
awal := (halaman - 1) * batas
if awal > total {
awal = total
}
akhir := awal + batas
if akhir > total {
akhir = total
}
// Langkah 4: Link header memberi tahu halaman tetangga,
// disusun dari parameter yang sudah tervalidasi.
if halaman < jumlahHalaman {
w.Header().Add("Link", fmt.Sprintf(`<%s>; rel="next"`, alamat(r, halaman+1, batas, kolom, arah)))
}
if halaman > 1 {
w.Header().Add("Link", fmt.Sprintf(`<%s>; rel="prev"`, alamat(r, halaman-1, batas, kolom, arah)))
}
tulisJSON(w, http.StatusOK, responsList{
Data: hasil[awal:akhir],
Meta: metaHalaman{Page: halaman, Limit: batas, Total: total, Pages: jumlahHalaman},
})
}
// parseAngka mengubah parameter query menjadi angka; input tidak
// valid atau di bawah satu menghasilkan nilai default.
func parseAngka(s string, standar int) int {
n, err := strconv.Atoi(s)
if err != nil || n < 1 {
return standar
}
return n
}
// alamat menyusun URL halaman tetangga dari parameter tervalidasi.
func alamat(r *http.Request, halaman, batas int, kolom string, arah int) string {
v := url.Values{}
v.Set("page", strconv.Itoa(halaman))
v.Set("limit", strconv.Itoa(batas))
v.Set("sort", kolom)
if arah < 0 {
v.Set("order", "desc")
}
if q := r.URL.Query().Get("q"); q != "" {
v.Set("q", q)
}
return r.URL.Path + "?" + v.Encode()
}
func tulisJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func main() {
h := &Handler{items: buatItem(25)}
mux := http.NewServeMux()
mux.HandleFunc("GET /items", h.daftar)
fmt.Println("GET /items (tanpa query, default page=1 limit=10 sort=id asc):")
ringkas(mux, "/items")
fmt.Println()
fmt.Println(`GET /items?page=2&limit=10&sort=priority&order=desc (halaman 2 dari 25 item, urut prioritas menurun):`)
ringkas(mux, "/items?page=2&limit=10&sort=priority&order=desc")
fmt.Println()
fmt.Println(`GET /items?q=tugas%202&limit=5&sort=title (filter judul, body respons utuh):`)
mentah(mux, "/items?q=tugas%202&limit=5&sort=title")
fmt.Println()
fmt.Println(`GET /items?page=abc&sort=nama (query tidak valid jatuh ke default):`)
mentah(mux, "/items?page=abc&sort=nama")
}
// buatItem menyiapkan 25 item dengan prioritas unik dan deterministik.
func buatItem(n int) []Item {
items := make([]Item, 0, n)
for i := 1; i <= n; i++ {
items = append(items, Item{
ID: i,
Title: fmt.Sprintf("Tugas %02d", i),
Priority: (i*7)%n + 1,
})
}
return items
}
// ringkas mengeksekusi permintaan lalu mencetak meta, Link header,
// dan urutan item tanpa mencetak body JSON mentah.
func ringkas(mux *http.ServeMux, target string) {
req := httptest.NewRequest(http.MethodGet, target, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
var out responsList
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
fmt.Println("decode gagal:", err)
return
}
fmt.Printf("status=%d link=%q\n", rec.Code, rec.Header().Get("Link"))
fmt.Printf("meta : page=%d limit=%d total=%d pages=%d\n",
out.Meta.Page, out.Meta.Limit, out.Meta.Total, out.Meta.Pages)
bagian := make([]string, 0, len(out.Data))
for _, it := range out.Data {
bagian = append(bagian, fmt.Sprintf("#%02d(p%d)", it.ID, it.Priority))
}
fmt.Println("item : " + strings.Join(bagian, " "))
}
// mentah mengeksekusi permintaan lalu mencetak body respons utuh
// dengan indentasi hasil json.Indent.
func mentah(mux *http.ServeMux, target string) {
req := httptest.NewRequest(http.MethodGet, target, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
fmt.Printf("status=%d link=%q\n", rec.Code, rec.Header().Get("Link"))
var rapi bytes.Buffer
if err := json.Indent(&rapi, rec.Body.Bytes(), "", " "); err != nil {
fmt.Println("indent gagal:", err)
return
}
fmt.Print(rapi.String())
}Output yang Dihasilkan:
GET /items (tanpa query, default page=1 limit=10 sort=id asc):
status=200 link="</items?limit=10&page=2&sort=id>; rel=\"next\""
meta : page=1 limit=10 total=25 pages=3
item : #01(p8) #02(p15) #03(p22) #04(p4) #05(p11) #06(p18) #07(p25) #08(p7) #09(p14) #10(p21)
GET /items?page=2&limit=10&sort=priority&order=desc (halaman 2 dari 25 item, urut prioritas menurun):
status=200 link="</items?limit=10&order=desc&page=3&sort=priority>; rel=\"next\""
meta : page=2 limit=10 total=25 pages=3
item : #02(p15) #09(p14) #16(p13) #23(p12) #05(p11) #12(p10) #19(p9) #01(p8) #08(p7) #15(p6)
GET /items?q=tugas%202&limit=5&sort=title (filter judul, body respons utuh):
status=200 link="</items?limit=5&page=2&q=tugas+2&sort=title>; rel=\"next\""
{
"data": [
{
"id": 20,
"title": "Tugas 20",
"priority": 16
},
{
"id": 21,
"title": "Tugas 21",
"priority": 23
},
{
"id": 22,
"title": "Tugas 22",
"priority": 5
},
{
"id": 23,
"title": "Tugas 23",
"priority": 12
},
{
"id": 24,
"title": "Tugas 24",
"priority": 19
}
],
"meta": {
"page": 1,
"limit": 5,
"total": 6,
"pages": 2
}
}
GET /items?page=abc&sort=nama (query tidak valid jatuh ke default):
status=200 link="</items?limit=10&page=2&sort=id>; rel=\"next\""
{
"data": [
{
"id": 1,
"title": "Tugas 01",
"priority": 8
},
{
"id": 2,
"title": "Tugas 02",
"priority": 15
},
{
"id": 3,
"title": "Tugas 03",
"priority": 22
},
{
"id": 4,
"title": "Tugas 04",
"priority": 4
},
{
"id": 5,
"title": "Tugas 05",
"priority": 11
},
{
"id": 6,
"title": "Tugas 06",
"priority": 18
},
{
"id": 7,
"title": "Tugas 07",
"priority": 25
},
{
"id": 8,
"title": "Tugas 08",
"priority": 7
},
{
"id": 9,
"title": "Tugas 09",
"priority": 14
},
{
"id": 10,
"title": "Tugas 10",
"priority": 21
}
],
"meta": {
"page": 1,
"limit": 10,
"total": 25,
"pages": 3
}
}Mari kita telaah baris demi baris program di atas:
- Baris 36-42 (
kolomDiizinkan): whitelist tiga kolom sorting; nama lain tidak pernah sampai ke pembanding. - Baris 50-57 (filter): parameter
qdicocokkan sebagai substring pada judul denganstrings.Containssetelah kedua sisi di-lowercase — filter kosong berarti semua item lolos. - Baris 59-79 (sorting): kolom divalidasi lalu
slices.SortStableFuncmengurutkanhasil; arahdesccukup mengalikan hasil pembanding dengan-1di baris 78. - Baris 81-96 (pagination):
parseAngkamenjatuhkan input tidak valid ke default; jumlah halaman dihitung dengan pembulatan ke atas(total+batas-1)/batas, batas sliceawal/akhirdipatok agar tidak keluar dari rentang. - Baris 98-105 (Link header): tautan
nextdanprevhanya dikirim bila halaman tetangga benar-benar ada. - Baris 113-121 (
parseAngka):strconv.Atoiplus pemeriksaan nilai minimal satu; input buruk menghasilkan nilai default, bukan error. - Baris 123-136 (
alamat):url.Valuesmerakit query halaman tetangga;Encode()mengurutkan kunci dan meng-escape spasi menjadi+. - Baris 144-163 (
main): empat permintaan dengan variasi query berbeda dijalankan; dua pertama dicetak ringkas, dua terakhir mencetak body JSON utuh hasiljson.Indent.
Permintaan kedua pada output memperlihatkan inti chapter: dari 25 item, halaman dua berisi tepat sepuluh item dengan prioritas menurun dari 15 ke 6 — nomor ID tampak acak karena rumus data memang mengacak prioritas. Permintaan ketiga memperlihatkan filter q=tugas 2 memangkas total menjadi 6 item sehingga pages turun menjadi 2, dan permintaan keempat membuktikan page=abc serta sort=nama jatuh kembali ke default page=1, sort=id tanpa error.
Kombinasi pagination, filter, dan sorting menuntaskan urusan data publik. Endpoint yang sama kini perlu menjawab pertanyaan berikutnya: siapa yang boleh memanggilnya — pembahasan autentikasi dan otorisasi menjadi materi chapter Authentication .
Referensi #
- Dokumentasi package net/url
— parsing query dan
Values.Encode. - Dokumentasi package strconv
— konversi
Atoidan galat konversinya. - Dokumentasi package httptest — menjalankan permintaan query tanpa jaringan.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
pagination-demo.go
Unduh berkaspackage main
import (
"bytes"
"cmp"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strconv"
"strings"
)
// Item adalah satu baris data katalog tugas.
type Item struct {
ID int `json:"id"`
Title string `json:"title"`
Priority int `json:"priority"`
}
// metaHalaman merangkum informasi pagination pada respons.
type metaHalaman struct {
Page int `json:"page"`
Limit int `json:"limit"`
Total int `json:"total"`
Pages int `json:"pages"`
}
type responsList struct {
Data []Item `json:"data"`
Meta metaHalaman `json:"meta"`
}
// kolomDiizinkan adalah whitelist kolom sorting.
// Kolom di luar daftar ini ditolak dan diganti kolom default.
var kolomDiizinkan = map[string]bool{
"id": true,
"title": true,
"priority": true,
}
// Handler melayani GET /items dengan filter, sorting, dan pagination.
type Handler struct {
items []Item
}
func (h *Handler) daftar(w http.ResponseWriter, r *http.Request) {
// Langkah 1: filter judul memakai substring (case-insensitive).
filter := strings.ToLower(r.URL.Query().Get("q"))
hasil := make([]Item, 0, len(h.items))
for _, it := range h.items {
if filter == "" || strings.Contains(strings.ToLower(it.Title), filter) {
hasil = append(hasil, it)
}
}
// Langkah 2: sorting — kolom harus lolos whitelist, arah hanya asc/desc.
kolom := r.URL.Query().Get("sort")
if !kolomDiizinkan[kolom] {
kolom = "id"
}
arah := 1
if r.URL.Query().Get("order") == "desc" {
arah = -1
}
slices.SortStableFunc(hasil, func(a, b Item) int {
var banding int
switch kolom {
case "title":
banding = strings.Compare(a.Title, b.Title)
case "priority":
banding = cmp.Compare(a.Priority, b.Priority)
default:
banding = cmp.Compare(a.ID, b.ID)
}
return arah * banding
})
// Langkah 3: pagination — nilai tidak valid jatuh ke default.
halaman := parseAngka(r.URL.Query().Get("page"), 1)
batas := parseAngka(r.URL.Query().Get("limit"), 10)
total := len(hasil)
jumlahHalaman := (total + batas - 1) / batas
if jumlahHalaman < 1 {
jumlahHalaman = 1
}
awal := (halaman - 1) * batas
if awal > total {
awal = total
}
akhir := awal + batas
if akhir > total {
akhir = total
}
// Langkah 4: Link header memberi tahu halaman tetangga,
// disusun dari parameter yang sudah tervalidasi.
if halaman < jumlahHalaman {
w.Header().Add("Link", fmt.Sprintf(`<%s>; rel="next"`, alamat(r, halaman+1, batas, kolom, arah)))
}
if halaman > 1 {
w.Header().Add("Link", fmt.Sprintf(`<%s>; rel="prev"`, alamat(r, halaman-1, batas, kolom, arah)))
}
tulisJSON(w, http.StatusOK, responsList{
Data: hasil[awal:akhir],
Meta: metaHalaman{Page: halaman, Limit: batas, Total: total, Pages: jumlahHalaman},
})
}
// parseAngka mengubah parameter query menjadi angka; input tidak
// valid atau di bawah satu menghasilkan nilai default.
func parseAngka(s string, standar int) int {
n, err := strconv.Atoi(s)
if err != nil || n < 1 {
return standar
}
return n
}
// alamat menyusun URL halaman tetangga dari parameter tervalidasi.
func alamat(r *http.Request, halaman, batas int, kolom string, arah int) string {
v := url.Values{}
v.Set("page", strconv.Itoa(halaman))
v.Set("limit", strconv.Itoa(batas))
v.Set("sort", kolom)
if arah < 0 {
v.Set("order", "desc")
}
if q := r.URL.Query().Get("q"); q != "" {
v.Set("q", q)
}
return r.URL.Path + "?" + v.Encode()
}
func tulisJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func main() {
h := &Handler{items: buatItem(25)}
mux := http.NewServeMux()
mux.HandleFunc("GET /items", h.daftar)
fmt.Println("GET /items (tanpa query, default page=1 limit=10 sort=id asc):")
ringkas(mux, "/items")
fmt.Println()
fmt.Println(`GET /items?page=2&limit=10&sort=priority&order=desc (halaman 2 dari 25 item, urut prioritas menurun):`)
ringkas(mux, "/items?page=2&limit=10&sort=priority&order=desc")
fmt.Println()
fmt.Println(`GET /items?q=tugas%202&limit=5&sort=title (filter judul, body respons utuh):`)
mentah(mux, "/items?q=tugas%202&limit=5&sort=title")
fmt.Println()
fmt.Println(`GET /items?page=abc&sort=nama (query tidak valid jatuh ke default):`)
mentah(mux, "/items?page=abc&sort=nama")
}
// buatItem menyiapkan 25 item dengan prioritas unik dan deterministik.
func buatItem(n int) []Item {
items := make([]Item, 0, n)
for i := 1; i <= n; i++ {
items = append(items, Item{
ID: i,
Title: fmt.Sprintf("Tugas %02d", i),
Priority: (i*7)%n + 1,
})
}
return items
}
// ringkas mengeksekusi permintaan lalu mencetak meta, Link header,
// dan urutan item tanpa mencetak body JSON mentah.
func ringkas(mux *http.ServeMux, target string) {
req := httptest.NewRequest(http.MethodGet, target, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
var out responsList
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
fmt.Println("decode gagal:", err)
return
}
fmt.Printf("status=%d link=%q\n", rec.Code, rec.Header().Get("Link"))
fmt.Printf("meta : page=%d limit=%d total=%d pages=%d\n",
out.Meta.Page, out.Meta.Limit, out.Meta.Total, out.Meta.Pages)
bagian := make([]string, 0, len(out.Data))
for _, it := range out.Data {
bagian = append(bagian, fmt.Sprintf("#%02d(p%d)", it.ID, it.Priority))
}
fmt.Println("item : " + strings.Join(bagian, " "))
}
// mentah mengeksekusi permintaan lalu mencetak body respons utuh
// dengan indentasi hasil json.Indent.
func mentah(mux *http.ServeMux, target string) {
req := httptest.NewRequest(http.MethodGet, target, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
fmt.Printf("status=%d link=%q\n", rec.Code, rec.Header().Get("Link"))
var rapi bytes.Buffer
if err := json.Indent(&rapi, rec.Body.Bytes(), "", " "); err != nil {
fmt.Println("indent gagal:", err)
return
}
fmt.Print(rapi.String())
}