Klien API tidak membaca kode server; klien hanya membaca respons. Dua endpoint yang mengembalikan data pengguna dengan bentuk JSON berbeda — sekali array polos, sekali objek berbungkus — memaksa klien menulis penanganan khusus untuk masing-masing, dan itulah sumber bug integrasi klasik. Pada chapter ini kita mengunci keputusan format respons: bentuk envelope atau flat, penamaan field yang konsisten, representasi resource yang menyembunyikan field internal, timestamp ISO 8601, header Location untuk 201, serta metadata halaman pada respons daftar. Seluruh keputusan diterapkan pada satu program yang dijalankan lewat httptest.
Envelope atau Flat #
Bentuk flat mengirim resource apa adanya: daftar pengguna menjadi array JSON, satu pengguna menjadi objek JSON. Bentuk envelope membungkus payload di dalam field data dan menyisakan ruang untuk error maupun meta. Perbandingan keduanya:
| Aspek | Flat | Envelope |
|---|---|---|
| Kesederhanaan klien | langsung dipakai | perlu membuka data dulu |
| Metadata (total, halaman) | tidak ada tempat | field meta siap pakai |
| Konsistensi error | bentuk beda saat gagal | error dengan bentuk sama |
| JSON murni / teori REST | lebih dekat | pembungkusan tambahan |
API kecil dengan endpoint seragam cukup memakai flat. Begitu API yang tumbuh — pagination, kode galat terstruktur, metadata — envelope membayar dirinya sendiri karena seluruh respons, sukses maupun gagal, memiliki kerangka yang sama. Program pada chapter ini memilih envelope {data, meta} dan {error}.
Menulis JSON dengan json.NewEncoder #
Cara menulis respons paling langsung: json.NewEncoder(w).Encode(v) — encoder menulis langsung ke http.ResponseWriter tanpa perantara buffer. Urutan pemanggilan wajib diperhatikan: header (Content-Type, Location) harus diset sebelum WriteHeader(status), dan WriteHeader harus dipanggil sebelum body pertama ditulis. Header yang diset setelah body mengalir akan diabaikan diam-diam.
Representasi Resource Menyembunyikan Field Internal #
Server hampir selalu menyimpan lebih banyak daripada yang boleh dilihat klien: hash password, flag internal, kolom audit. Representasi resource yang dikirim ke luar dibentuk oleh struct dengan struct tag — field PasswordHash diberi tag json:"-" sehingga tidak pernah muncul di output encoder, sekalipun field tersebut terisi di memori. Penggunaan tag json dibahas tuntas di chapter Encoding JSON
.
Konsistensi Casing dan Timestamp ISO 8601 #
Field JSON harus satu gaya penamaan dari ujung ke ujung. Chapter ini memilih snake_case — created_at, due_date — karena cocok dengan konvensi kolom database dan mudah dibaca; camelCase juga sah asalkan konsisten. Campuran keduanya dalam satu API adalah jaminan klien salah menulis field. Timestamp selalu dikirim dalam format ISO 8601 (RFC 3339) seperti "2026-01-15T08:00:00Z" — time.Time menghasilkan bentuk ini otomatis saat di-encode, dan program membekukan waktunya dengan time.Date agar contoh deterministik.
Header Location dan Metadata Pagination #
Respons 201 Created sebaiknya menyertakan header Location yang menunjuk URL resource baru — klien tinggal melakukan GET ke alamat tersebut tanpa menebak pola ID. Respons daftar menyertakan meta berisi nomor halaman dan total; metadata pagination yang lengkap (limit, pages, Link header) dibahas di chapter khusus.
Program: Format Respons Pengguna #
Program respons-demo.go membangun tiga endpoint pengguna di atas data yang sama, membandingkan bentuk flat dan envelope, lalu menjalankan empat permintaan lewat httptest.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"time"
)
// Pengguna menyimpan data internal lengkap, termasuk field yang tidak
// boleh pernah muncul di respons.
type Pengguna struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
PasswordHash string `json:"-"` // tidak pernah dikirim ke klien
CreatedAt time.Time `json:"created_at"`
}
// metaPaging menempelkan informasi halaman pada respons daftar.
type metaPaging struct {
Page int `json:"page"`
Total int `json:"total"`
}
// amplopDaftar adalah respons ber-envelope untuk daftar pengguna.
type amplopDaftar struct {
Data []Pengguna `json:"data"`
Meta metaPaging `json:"meta"`
}
// amplopSatu adalah respons ber-envelope untuk satu pengguna.
type amplopSatu struct {
Data Pengguna `json:"data"`
}
// amplopGalat adalah respons ber-envelope untuk kegagalan.
type amplopGalat struct {
Error galatRincian `json:"error"`
}
type galatRincian struct {
Code string `json:"code"`
Message string `json:"message"`
}
// Handler menyimpan data pengguna di memori.
type Handler struct {
data []Pengguna
waktu time.Time
}
func (h *Handler) routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /users", h.daftar)
mux.HandleFunc("POST /users", h.buat)
mux.HandleFunc("GET /users/{id}", h.detail)
return mux
}
func (h *Handler) daftar(w http.ResponseWriter, r *http.Request) {
tulisJSON(w, http.StatusOK, amplopDaftar{
Data: h.data,
Meta: metaPaging{Page: 1, Total: len(h.data)},
})
}
func (h *Handler) buat(w http.ResponseWriter, r *http.Request) {
var in struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
tulisJSON(w, http.StatusBadRequest, amplopGalat{Error: galatRincian{
Code: "INVALID_JSON", Message: "body bukan JSON yang valid",
}})
return
}
u := Pengguna{
ID: len(h.data) + 1,
Name: in.Name,
Email: in.Email,
PasswordHash: "hash:" + in.Password, // disimulasikan; hashing nyata dibahas di section Authentication dan Security
CreatedAt: h.waktu,
}
h.data = append(h.data, u)
w.Header().Set("Location", fmt.Sprintf("/users/%d", u.ID))
tulisJSON(w, http.StatusCreated, amplopSatu{Data: u})
}
func (h *Handler) detail(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
tulisJSON(w, http.StatusBadRequest, amplopGalat{Error: galatRincian{
Code: "INVALID_ID", Message: "id harus angka",
}})
return
}
for _, u := range h.data {
if u.ID == id {
tulisJSON(w, http.StatusOK, amplopSatu{Data: u})
return
}
}
tulisJSON(w, http.StatusNotFound, amplopGalat{Error: galatRincian{
Code: "NOT_FOUND", Message: fmt.Sprintf("pengguna dengan id %d tidak ditemukan", id),
}})
}
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{waktu: time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC)}
h.data = append(h.data, Pengguna{
ID: 1, Name: "Budi", Email: "[email protected]",
PasswordHash: "hash:rahasia-budi", CreatedAt: h.waktu,
})
mux := h.routes()
// Perbandingan dua bentuk respons untuk data yang sama.
contoh := []Pengguna{h.data[0]}
cetakBentuk("flat", contoh)
cetakBentuk("envelope", amplopDaftar{Data: contoh, Meta: metaPaging{Page: 1, Total: 1}})
kirim(mux, "POST", "/users", `{"name":"Siti","email":"[email protected]","password":"rahasia-siti"}`)
kirim(mux, "GET", "/users", "")
kirim(mux, "GET", "/users/1", "")
kirim(mux, "GET", "/users/9", "")
}
// cetakBentuk menulis satu nilai lewat json.NewEncoder ke recorder
// lalu mencetak header dan bodynya, sama seperti respons HTTP sungguhan.
func cetakBentuk(label string, v any) {
rec := httptest.NewRecorder()
tulisJSON(rec, http.StatusOK, v)
fmt.Printf("bentuk %s (Content-Type: %s):\n%s", label, rec.Header().Get("Content-Type"), rec.Body.String())
}
// kirim mengeksekusi satu permintaan lalu mencetak dump status, Location, dan body.
func kirim(mux *http.ServeMux, metode, target, body string) {
var pembaca io.Reader
if body != "" {
pembaca = strings.NewReader(body)
}
req := httptest.NewRequest(metode, target, pembaca)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
fmt.Printf("%s %s -> %d", metode, target, rec.Code)
if loc := rec.Header().Get("Location"); loc != "" {
fmt.Printf(" (Location: %s)", loc)
}
fmt.Println()
if rec.Body.Len() > 0 {
fmt.Printf("%s", rec.Body.String())
}
}Output yang Dihasilkan:
bentuk flat (Content-Type: application/json):
[{"id":1,"name":"Budi","email":"[email protected]","created_at":"2026-01-15T08:00:00Z"}]
bentuk envelope (Content-Type: application/json):
{"data":[{"id":1,"name":"Budi","email":"[email protected]","created_at":"2026-01-15T08:00:00Z"}],"meta":{"page":1,"total":1}}
POST /users -> 201 (Location: /users/2)
{"data":{"id":2,"name":"Siti","email":"[email protected]","created_at":"2026-01-15T08:00:00Z"}}
GET /users -> 200
{"data":[{"id":1,"name":"Budi","email":"[email protected]","created_at":"2026-01-15T08:00:00Z"},{"id":2,"name":"Siti","email":"[email protected]","created_at":"2026-01-15T08:00:00Z"}],"meta":{"page":1,"total":2}}
GET /users/1 -> 200
{"data":{"id":1,"name":"Budi","email":"[email protected]","created_at":"2026-01-15T08:00:00Z"}}
GET /users/9 -> 404
{"error":{"code":"NOT_FOUND","message":"pengguna dengan id 9 tidak ditemukan"}}Mari kita telaah baris demi baris program di atas:
- Baris 16-22 (
type Pengguna): field internalPasswordHashdiberi tagjson:"-"; field lain memakai snake_case konsisten. - Baris 24-44 (tipe amplop): tiga wadah respons —
amplopDaftardengandata+meta,amplopSatuuntuk satu resource, danamplopGalatuntuk kegagalan. - Baris 72-94 (
buat): setelah decode, resource baru dibuat lengkap dengan hash password di sisi server; headerLocationdiset di baris 92 sebelumtulisJSONmenulis status 201. - Baris 96-113 (
detail): ID yang tidak ditemukan dijawab envelope{"error":{code,message}}dengan status 404 — bentuk galat sama seperti sukses, hanya beda kunci. - Baris 115-119 (
tulisJSON): urutan wajib terlihat jelas — setContent-Type,WriteHeader(status), barujson.NewEncoder(w).Encode(v). - Baris 121-138 (
main):cetakBentukdipanggil dua kali, lalu empat permintaan — buat, daftar, baca, dan gagal — menghasilkan seluruh ragam respons. - Baris 140-146 (
cetakBentuk):httptest.NewRecorderdipakai sebagaihttp.ResponseWritersehingga perbandingan bentuk dihasilkan jalur kode yang sama dengan respons sungguhan.
Dua baris pertama output memperlihatkan perbedaan flat dan envelope pada data yang persis sama: array polos versus objek ber-data dan meta. Respons POST /users mengembalikan 201 dengan Location: /users/2 dan data Siti tanpa jejak password — bukti tag json:"-" bekerja. Pada respons GET /users, metadata {"page":1,"total":2} ikut terkirim di dalam meta.
Setiap respons kini punya bentuk yang bisa diprediksi klien. Namun bentuk hanyalah setengah dari kontrak: status code yang menyertainya menentukan bagaimana klien menafsirkan isi respons itu — pemetaan lengkapnya adalah materi chapter Status Code .
Referensi #
- Dokumentasi package encoding/json — Encoder, struct tag, dan format waktu.
- Dokumentasi package net/http
—
ResponseWriter, header, danWriteHeader.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
respons-demo.go
Unduh berkaspackage main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"time"
)
// Pengguna menyimpan data internal lengkap, termasuk field yang tidak
// boleh pernah muncul di respons.
type Pengguna struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
PasswordHash string `json:"-"` // tidak pernah dikirim ke klien
CreatedAt time.Time `json:"created_at"`
}
// metaPaging menempelkan informasi halaman pada respons daftar.
type metaPaging struct {
Page int `json:"page"`
Total int `json:"total"`
}
// amplopDaftar adalah respons ber-envelope untuk daftar pengguna.
type amplopDaftar struct {
Data []Pengguna `json:"data"`
Meta metaPaging `json:"meta"`
}
// amplopSatu adalah respons ber-envelope untuk satu pengguna.
type amplopSatu struct {
Data Pengguna `json:"data"`
}
// amplopGalat adalah respons ber-envelope untuk kegagalan.
type amplopGalat struct {
Error galatRincian `json:"error"`
}
type galatRincian struct {
Code string `json:"code"`
Message string `json:"message"`
}
// Handler menyimpan data pengguna di memori.
type Handler struct {
data []Pengguna
waktu time.Time
}
func (h *Handler) routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /users", h.daftar)
mux.HandleFunc("POST /users", h.buat)
mux.HandleFunc("GET /users/{id}", h.detail)
return mux
}
func (h *Handler) daftar(w http.ResponseWriter, r *http.Request) {
tulisJSON(w, http.StatusOK, amplopDaftar{
Data: h.data,
Meta: metaPaging{Page: 1, Total: len(h.data)},
})
}
func (h *Handler) buat(w http.ResponseWriter, r *http.Request) {
var in struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
tulisJSON(w, http.StatusBadRequest, amplopGalat{Error: galatRincian{
Code: "INVALID_JSON", Message: "body bukan JSON yang valid",
}})
return
}
u := Pengguna{
ID: len(h.data) + 1,
Name: in.Name,
Email: in.Email,
PasswordHash: "hash:" + in.Password, // disimulasikan; hashing nyata dibahas di section Authentication dan Security
CreatedAt: h.waktu,
}
h.data = append(h.data, u)
w.Header().Set("Location", fmt.Sprintf("/users/%d", u.ID))
tulisJSON(w, http.StatusCreated, amplopSatu{Data: u})
}
func (h *Handler) detail(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
tulisJSON(w, http.StatusBadRequest, amplopGalat{Error: galatRincian{
Code: "INVALID_ID", Message: "id harus angka",
}})
return
}
for _, u := range h.data {
if u.ID == id {
tulisJSON(w, http.StatusOK, amplopSatu{Data: u})
return
}
}
tulisJSON(w, http.StatusNotFound, amplopGalat{Error: galatRincian{
Code: "NOT_FOUND", Message: fmt.Sprintf("pengguna dengan id %d tidak ditemukan", id),
}})
}
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{waktu: time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC)}
h.data = append(h.data, Pengguna{
ID: 1, Name: "Budi", Email: "[email protected]",
PasswordHash: "hash:rahasia-budi", CreatedAt: h.waktu,
})
mux := h.routes()
// Perbandingan dua bentuk respons untuk data yang sama.
contoh := []Pengguna{h.data[0]}
cetakBentuk("flat", contoh)
cetakBentuk("envelope", amplopDaftar{Data: contoh, Meta: metaPaging{Page: 1, Total: 1}})
kirim(mux, "POST", "/users", `{"name":"Siti","email":"[email protected]","password":"rahasia-siti"}`)
kirim(mux, "GET", "/users", "")
kirim(mux, "GET", "/users/1", "")
kirim(mux, "GET", "/users/9", "")
}
// cetakBentuk menulis satu nilai lewat json.NewEncoder ke recorder
// lalu mencetak header dan bodynya, sama seperti respons HTTP sungguhan.
func cetakBentuk(label string, v any) {
rec := httptest.NewRecorder()
tulisJSON(rec, http.StatusOK, v)
fmt.Printf("bentuk %s (Content-Type: %s):\n%s", label, rec.Header().Get("Content-Type"), rec.Body.String())
}
// kirim mengeksekusi satu permintaan lalu mencetak dump status, Location, dan body.
func kirim(mux *http.ServeMux, metode, target, body string) {
var pembaca io.Reader
if body != "" {
pembaca = strings.NewReader(body)
}
req := httptest.NewRequest(metode, target, pembaca)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
fmt.Printf("%s %s -> %d", metode, target, rec.Code)
if loc := rec.Header().Get("Location"); loc != "" {
fmt.Printf(" (Location: %s)", loc)
}
fmt.Println()
if rec.Body.Len() > 0 {
fmt.Printf("%s", rec.Body.String())
}
}