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:

AspekFlatEnvelope
Kesederhanaan klienlangsung dipakaiperlu membuka data dulu
Metadata (total, halaman)tidak ada tempatfield meta siap pakai
Konsistensi errorbentuk beda saat gagalerror dengan bentuk sama
JSON murni / teori RESTlebih dekatpembungkusan 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}.

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.

go
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:

text
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:

  1. Baris 16-22 (type Pengguna): field internal PasswordHash diberi tag json:"-"; field lain memakai snake_case konsisten.
  2. Baris 24-44 (tipe amplop): tiga wadah respons — amplopDaftar dengan data+meta, amplopSatu untuk satu resource, dan amplopGalat untuk kegagalan.
  3. Baris 72-94 (buat): setelah decode, resource baru dibuat lengkap dengan hash password di sisi server; header Location diset di baris 92 sebelum tulisJSON menulis status 201.
  4. Baris 96-113 (detail): ID yang tidak ditemukan dijawab envelope {"error":{code,message}} dengan status 404 — bentuk galat sama seperti sukses, hanya beda kunci.
  5. Baris 115-119 (tulisJSON): urutan wajib terlihat jelas — set Content-Type, WriteHeader(status), baru json.NewEncoder(w).Encode(v).
  6. Baris 121-138 (main): cetakBentuk dipanggil dua kali, lalu empat permintaan — buat, daftar, baca, dan gagal — menghasilkan seluruh ragam respons.
  7. Baris 140-146 (cetakBentuk): httptest.NewRecorder dipakai sebagai http.ResponseWriter sehingga 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 #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

respons-demo.go

Unduh berkas
go
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())
	}
}