Router sudah tahu ke mana setiap permintaan pergi, tetapi server belum punya jejak: tidak ada catatan siapa meminta apa, dan satu handler yang panic cukup untuk memutus koneksi secara kasar. Middleware menyelesaikan keduanya dengan membungkus handler di lapisan-lapisan yang bisa dilihat sekelilingnya. Pada bagian ini kita menambahkan tiga middleware ke berkas middleware.go: request ID, logging, dan recover.

Middleware sebagai Fungsi Pembungkus #

Bentuk dasar middleware kita adalah func type yang menerima satu http.Handler dan mengembalikan http.Handler baru:

go
// Middleware membungkus satu http.Handler menjadi http.Handler baru.
// Bentuk func type seperti ini memudahkan perangkaian middleware.
type Middleware func(http.Handler) http.Handler

// Chain menyusun middleware di sekeliling handler dasar. Middleware yang
// ditulis lebih dulu berada di lapisan paling luar, sehingga urutan
// Chain(mux, A, B, C) menghasilkan pemanggilan A → B → C → mux.
func Chain(base http.Handler, mws ...Middleware) http.Handler {
	h := base
	for i := len(mws) - 1; i >= 0; i-- {
		h = mws[i](h)
	}
	return h
}

Perhatikan arah loop Chain di baris 18-24: iterasi berjalan dari belakang ke depan karena middleware pertama harus berada di lapisan paling luar. Pemanggilan Chain(mux, A, B) menempel B pada mux lebih dulu, lalu A membungkus hasilnya. Urutan pemanggilan akhirnya A → B → mux — persis seperti urutan penulisan argumen.

Alur satu permintaan melewati seluruh lapisan ini sebelum sampai ke handler:

Request ID di Context dan Header #

Middleware pertama memberi setiap permintaan satu identitas:

go
// NewRequestID membuat middleware yang menandai setiap permintaan dengan
// ID unik. ID disimpan di context dan di header respons X-Request-ID
// sehingga satu permintaan dapat dilacak dari log sampai ke pemanggil.
//
// Pada proyek ini ID dibuat dari penghitung sederhana agar output demo dan
// pengujian bersifat deterministik. Pada produksi, ganti generator dengan
// nilai acak dari crypto/rand.
func NewRequestID() Middleware {
	var hitung atomic.Uint64
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			id := fmt.Sprintf("req-%03d", hitung.Add(1))
			w.Header().Set("X-Request-ID", id)
			ctx := context.WithValue(r.Context(), kunciRequestID{}, id)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

Mari kita telaah peran tiap barisnya di middleware.go:

  1. Baris 44 (var hitung atomic.Uint64): penghitung dibuat sekali per middleware — berada di luar closure yang dikembalikan — sehingga seluruh permintaan berbagi urutan yang sama. Tipe atomic menjaga penambahan tetap aman dari data race.
  2. Baris 47 (fmt.Sprintf("req-%03d", ...)): menghasilkan ID seperti req-001; format %03d membuat ID tetap terurut rapi saat dibaca manusia.
  3. Baris 48 (w.Header().Set): ID dikirim balik ke pemanggil lewat header X-Request-ID sehingga laporan galat dari pengguna bisa langsung dicocokkan ke baris log.
  4. Baris 49-50 (context.WithValue dan r.WithContext): ID juga disimpan di context dengan kunci bertipe kunciRequestID struct{} — tipe unexported yang tidak mungkin bertabrakan dengan kunci package lain. Permintaan baru hasil r.WithContext diteruskan ke lapisan dalam.

Logging dengan Status Recorder #

Middleware logging mencatat baris log setelah handler selesai. Persoalannya: status code ditulis handler di lapisan dalam, sedangkan http.ResponseWriter asli tidak menyimpannya. Solusinya adalah pembungkus perekam:

go
// perekamStatus membungkus http.ResponseWriter untuk menangkap status code
// yang ditulis handler. Tanpa pembungkus ini, middleware tidak bisa tahu
// status akhir karena WriteHeader dipanggil di lapisan dalam.
type perekamStatus struct {
	http.ResponseWriter
	status int
}

func (p *perekamStatus) WriteHeader(code int) {
	p.status = code
	p.ResponseWriter.WriteHeader(code)
}

// NewLogging membuat middleware yang mencatat setiap permintaan selesai
// diproses: request ID, metode, path, dan status respons.
func NewLogging(logger io.Writer) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			rec := &perekamStatus{ResponseWriter: w, status: http.StatusOK}
			next.ServeHTTP(rec, r)
			fmt.Fprintf(logger, "%s %s %s %d\n",
				RequestIDFrom(r.Context()), r.Method, r.URL.Path, rec.status)
		})
	}
}

Embedding http.ResponseWriter pada perekamStatus (baris 59) mewariskan seluruh method asli, lalu WriteHeader dioverride untuk mencatat status sebelum meneruskan. Nilai bawaan http.StatusOK menutupi kasus handler yang menulis body tanpa WriteHeader — protokol HTTP menganggapnya status 200. Baris log diakhiri dengan rec.status, bukan hasil tebakan, dan ID diambil dari context yang diisi middleware request ID di lapisan luar.

Recover: Panic Menjadi 500 #

Middleware terakhir menahan panic agar server tidak mati:

go
// NewRecover membuat middleware yang menangkap panic dari handler,
// mencatatnya, lalu mengirim respons 500. Tanpa middleware ini, satu
// handler yang panic akan memutus koneksi secara kasar.
func NewRecover(logger io.Writer) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				if err := recover(); err != nil {
					fmt.Fprintf(logger, "PANIC %s: %v\n",
						RequestIDFrom(r.Context()), err)
					w.WriteHeader(http.StatusInternalServerError)
					fmt.Fprintln(w, "kesalahan internal server")
				}
			}()
			next.ServeHTTP(w, r)
		})
	}
}

Pola defer + recover() hanya sah di dalam fungsi yang sama dengan tempat panic terjadi — di sini closure yang di-defer berada satu frame di atas pemanggilan next.ServeHTTP, sehingga panic dari handler mana pun di lapisan dalam tertangkap. Respons 500 ditulis di dalam recover, bukan setelahnya, karena alur normal sudah terputus. Middleware ini sengaja paling dalam: request ID dan logging tetap berjalan normal untuk permintaan yang panik.

Rangkaian dalam Aksi #

Perakitan di main.go memakai urutan request ID → logging → recover. Jalankan server, sentuh beberapa rute dengan curl, lalu hentikan dengan Ctrl+C:

bash
PORT=8091 go run .

Output yang Dihasilkan (permintaan dikirim dari terminal lain sebelum Ctrl+C):

text
web-server mendengarkan di http://localhost:8091
req-001 GET /healthz 200
req-002 POST /healthz 405
req-003 GET /tidak-ada 404
req-004 GET / 200
req-005 GET /static/style.css 200
req-006 GET /kontak 200
terima sinyal berhenti, mematikan server dengan mulus
server berhenti

Setiap baris permintaan memuat empat informasi dari tiga middleware sekaligus: ID dari request ID, metode dan path dari logging, serta status akhir dari perekam status. Jawaban 405 dan 404 tetap tercatat karena penolakan itu terjadi di router — masih di dalam lapisan recover.

Menguji Middleware #

Dua pengujian kunci dari server_test.go memeriksa perilaku yang paling mudah salah: header request ID yang berurutan dan panic yang berubah menjadi 500:

go
func TestRecoverMengubahPanicMenjadi500(t *testing.T) {
	var log bytes.Buffer

	// Handler uji sengaja panic; hanya middleware Recover yang membungkusnya.
	panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		panic("kegagalan sengaja untuk pengujian")
	})
	ts := httptest.NewServer(Chain(panicHandler, NewRecover(&log)))
	t.Cleanup(ts.Close)

	resp, err := http.Get(ts.URL + "/")
	if err != nil {
		t.Fatalf("GET / ke handler panic gagal: %v", err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)

	if resp.StatusCode != http.StatusInternalServerError {
		t.Errorf("status = %d, ingin 500", resp.StatusCode)
	}
	if !strings.Contains(string(body), "kesalahan internal server") {
		t.Errorf("body respons panic = %q", body)
	}
	if !strings.Contains(log.String(), "PANIC") {
		t.Errorf("log tidak mencatat panic: %q", log.String())
	}
}

Pengujian ini memakai httptest.NewServer langsung di atas Chain(panicHandler, NewRecover(&log)) — tanpa router dan tanpa middleware lain — sehingga bila gagal, penyebabnya pasti di recover, bukan di lapisan lain. Jalankan seluruh pengujian:

bash
go test -v ./...

Output yang Dihasilkan (cuplikan tiga pengujian middleware):

text
=== RUN   TestRequestIDHeaderTersediaDanBerurutan
--- PASS: TestRequestIDHeaderTersediaDanBerurutan (0.00s)
=== RUN   TestRecoverMengubahPanicMenjadi500
--- PASS: TestRecoverMengubahPanicMenjadi500 (0.00s)
=== RUN   TestLoggingMencatatPermintaan
--- PASS: TestLoggingMencatatPermintaan (0.00s)

Rujukan Materi #

Tiga middleware kini menjaga setiap permintaan. Satu kebutuhan terakhir belum tersentuh: halaman utama masih mengirim HTML statis dari string. Bagian 4 merender template dan menyajikan berkas statis.

Berkas Sumber

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

handlers.go

Unduh berkas
go
package main

import (
	"embed"
	"fmt"
	"html/template"
	"net/http"
)

//go:embed templates/index.tmpl
var templateFS embed.FS

// templateKontak adalah template halaman kontak dalam bentuk raw string.
// Template pendek seperti ini cukup ditulis sebagai konstanta di dalam kode.
const templateKontak = `<!DOCTYPE html>
<html lang="id">
<head>
        <meta charset="utf-8">
        <title>{{.Judul}}</title>
</head>
<body>
        <main class="kartu">
                <h1>{{.Judul}}</h1>
                <p>Email: {{.Email}}</p>
        </main>
</body>
</html>
`

// Halaman adalah data yang diteruskan ke template saat rendering.
type Halaman struct {
	Judul string
	Pesan string
	Email string
}

// Handlers menyimpan seluruh handler beserta template yang sudah di-parse
// sekali di awal. Parsing sekali lalu render berkali-kali jauh lebih efisien
// daripada mem-parse ulang pada setiap permintaan.
type Handlers struct {
	homeTemplate   *template.Template
	kontakTemplate *template.Template
}

// NewHandlers mem-parse kedua template di awal: template halaman utama dari
// embed FS dan template kontak dari raw string. Galat parse terjadi di sini —
// jauh sebelum server menerima permintaan pertama.
func NewHandlers() (*Handlers, error) {
	homeTpl, err := template.ParseFS(templateFS, "templates/index.tmpl")
	if err != nil {
		return nil, fmt.Errorf("parse template index: %w", err)
	}
	kontakTpl, err := template.New("kontak").Parse(templateKontak)
	if err != nil {
		return nil, fmt.Errorf("parse template kontak: %w", err)
	}
	return &Handlers{homeTemplate: homeTpl, kontakTemplate: kontakTpl}, nil
}

// Home merender halaman utama dari templates/index.tmpl.
func (h *Handlers) Home(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	data := Halaman{
		Judul: "Selamat Datang di Web Server",
		Pesan: "Server ini dibangun hanya dengan net/http, tanpa framework eksternal.",
	}
	if err := h.homeTemplate.Execute(w, data); err != nil {
		// Bagian header sudah terkirim; cukup catat galatnya.
		fmt.Printf("gagal render home: %v\n", err)
	}
}

// Healthz melaporkan status server. Endpoint ini sengaja dibuat paling
// ringan mungkin agar load balancer dapat memeriksanya sesering apa pun.
func (h *Handlers) Healthz(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.WriteHeader(http.StatusOK)
	fmt.Fprintln(w, "ok")
}

// Kontak merender halaman kontak dari raw string template.
func (h *Handlers) Kontak(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	data := Halaman{
		Judul: "Hubungi Kami",
		Email: "[email protected]",
	}
	if err := h.kontakTemplate.Execute(w, data); err != nil {
		fmt.Printf("gagal render kontak: %v\n", err)
	}
}

main.go

Unduh berkas
go
package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

// Config menyimpan pengaturan server yang dibaca dari lingkungan.
type Config struct {
	Addr         string
	StaticDir    string
	ReadTimeout  time.Duration
	WriteTimeout time.Duration
}

// ConfigFromEnv membaca konfigurasi dari variabel lingkungan dengan nilai
// bawaan yang aman sehingga server langsung jalan tanpa pengaturan apa pun.
func ConfigFromEnv() Config {
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}
	staticDir := os.Getenv("STATIC_DIR")
	if staticDir == "" {
		staticDir = "static"
	}
	return Config{
		Addr:         ":" + port,
		StaticDir:    staticDir,
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
	}
}

// NewServer merakit http.Server lengkap dengan handler hasil middleware chain.
func NewServer(cfg Config, handler http.Handler) *http.Server {
	return &http.Server{
		Addr:         cfg.Addr,
		Handler:      handler,
		ReadTimeout:  cfg.ReadTimeout,
		WriteTimeout: cfg.WriteTimeout,
	}
}

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, "galat:", err)
		os.Exit(1)
	}
}

// run menjalankan server sampai menerima sinyal berhenti, lalu mematikan
// server dengan mulus. Logika dipisah ke fungsi run agar mudah diperiksa.
func run() error {
	cfg := ConfigFromEnv()

	handlers, err := NewHandlers()
	if err != nil {
		return fmt.Errorf("menyiapkan handler: %w", err)
	}

	logger := log.New(os.Stdout, "", 0)
	mux := NewRouter(handlers, cfg.StaticDir)
	handler := Chain(mux,
		NewRequestID(),
		NewLogging(logger.Writer()),
		NewRecover(logger.Writer()),
	)

	srv := NewServer(cfg, handler)

	// signal.NotifyContext membatalkan context ketika proses menerima
	// SIGINT (Ctrl+C) atau SIGTERM (perintah kill).
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	errCh := make(chan error, 1)
	go func() {
		// ListenAndServe mengembalikan http.ErrServerClosed setelah
		// Shutdown dipanggil; nilai itu bukan galat sungguhan.
		errCh <- srv.ListenAndServe()
	}()

	logger.Printf("web-server mendengarkan di http://localhost%s", cfg.Addr)

	select {
	case err := <-errCh:
		if err != nil && !errors.Is(err, http.ErrServerClosed) {
			return fmt.Errorf("server berhenti: %w", err)
		}
		return nil
	case <-ctx.Done():
		logger.Print("terima sinyal berhenti, mematikan server dengan mulus")
		shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		if err := srv.Shutdown(shutdownCtx); err != nil {
			return fmt.Errorf("graceful shutdown gagal: %w", err)
		}
		logger.Print("server berhenti")
		return nil
	}
}

middleware.go

Unduh berkas
go
package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"sync/atomic"
)

// Middleware membungkus satu http.Handler menjadi http.Handler baru.
// Bentuk func type seperti ini memudahkan perangkaian middleware.
type Middleware func(http.Handler) http.Handler

// Chain menyusun middleware di sekeliling handler dasar. Middleware yang
// ditulis lebih dulu berada di lapisan paling luar, sehingga urutan
// Chain(mux, A, B, C) menghasilkan pemanggilan A → B → C → mux.
func Chain(base http.Handler, mws ...Middleware) http.Handler {
	h := base
	for i := len(mws) - 1; i >= 0; i-- {
		h = mws[i](h)
	}
	return h
}

// kunciRequestID adalah tipe unexported untuk kunci context. Tipe khusus
// mencegah tabrakan kunci dengan package lain yang menyimpan nilai context.
type kunciRequestID struct{}

// RequestIDFrom mengambil request ID dari context.
func RequestIDFrom(ctx context.Context) string {
	id, _ := ctx.Value(kunciRequestID{}).(string)
	return id
}

// NewRequestID membuat middleware yang menandai setiap permintaan dengan
// ID unik. ID disimpan di context dan di header respons X-Request-ID
// sehingga satu permintaan dapat dilacak dari log sampai ke pemanggil.
//
// Pada proyek ini ID dibuat dari penghitung sederhana agar output demo dan
// pengujian bersifat deterministik. Pada produksi, ganti generator dengan
// nilai acak dari crypto/rand.
func NewRequestID() Middleware {
	var hitung atomic.Uint64
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			id := fmt.Sprintf("req-%03d", hitung.Add(1))
			w.Header().Set("X-Request-ID", id)
			ctx := context.WithValue(r.Context(), kunciRequestID{}, id)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

// perekamStatus membungkus http.ResponseWriter untuk menangkap status code
// yang ditulis handler. Tanpa pembungkus ini, middleware tidak bisa tahu
// status akhir karena WriteHeader dipanggil di lapisan dalam.
type perekamStatus struct {
	http.ResponseWriter
	status int
}

func (p *perekamStatus) WriteHeader(code int) {
	p.status = code
	p.ResponseWriter.WriteHeader(code)
}

// NewLogging membuat middleware yang mencatat setiap permintaan selesai
// diproses: request ID, metode, path, dan status respons.
func NewLogging(logger io.Writer) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			rec := &perekamStatus{ResponseWriter: w, status: http.StatusOK}
			next.ServeHTTP(rec, r)
			fmt.Fprintf(logger, "%s %s %s %d\n",
				RequestIDFrom(r.Context()), r.Method, r.URL.Path, rec.status)
		})
	}
}

// NewRecover membuat middleware yang menangkap panic dari handler,
// mencatatnya, lalu mengirim respons 500. Tanpa middleware ini, satu
// handler yang panic akan memutus koneksi secara kasar.
func NewRecover(logger io.Writer) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				if err := recover(); err != nil {
					fmt.Fprintf(logger, "PANIC %s: %v\n",
						RequestIDFrom(r.Context()), err)
					w.WriteHeader(http.StatusInternalServerError)
					fmt.Fprintln(w, "kesalahan internal server")
				}
			}()
			next.ServeHTTP(w, r)
		})
	}
}

router.go

Unduh berkas
go
package main

import "net/http"

// NewRouter membangun tabel routing dengan pattern method milik ServeMux
// Go 1.22+. Setiap rute menyatakan metode HTTP secara eksplisit sehingga
// permintaan dengan metode salah ditolak otomatis dengan status 405.
//
// staticDir sengaja dijadikan parameter agar pengujian dapat mengarahkan
// file server ke direktori sementara miliknya.
func NewRouter(h *Handlers, staticDir string) *http.ServeMux {
	mux := http.NewServeMux()

	// Pattern "/{$}" hanya cocok untuk path "/" secara persis, bukan
	// seluruh path di bawah "/" — tanpa pattern ini, rute "/" akan
	// menangkap semua path yang tidak terdaftar.
	mux.HandleFunc("GET /{$}", h.Home)
	mux.HandleFunc("GET /healthz", h.Healthz)
	mux.HandleFunc("GET /kontak", h.Kontak)

	// Berkas statis dilayani dari direktori tertentu; StripPrefix
	// membuang bagian "/static/" sebelum path diteruskan ke file server.
	fileServer := http.FileServer(http.Dir(staticDir))
	mux.Handle("GET /static/", http.StripPrefix("/static/", fileServer))

	return mux
}

server_test.go

Unduh berkas
go
package main

import (
	"bytes"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

// newTestServer merakit server uji dari komponen produksi yang sama:
// handler, router, dan middleware chain. httptest.NewServer menjalankan
// server sungguhan di alamat localhost acak — server produksi tidak ikut
// listen di port berapa pun selama pengujian.
func newTestServer(t *testing.T, staticDir string, logger io.Writer) *httptest.Server {
	t.Helper()

	handlers, err := NewHandlers()
	if err != nil {
		t.Fatalf("NewHandlers() galat: %v", err)
	}

	mux := NewRouter(handlers, staticDir)
	handler := Chain(mux,
		NewRequestID(),
		NewLogging(logger),
		NewRecover(logger),
	)

	ts := httptest.NewServer(handler)
	t.Cleanup(ts.Close)
	return ts
}

// buatStaticDir membuat direktori statis sementara berisi style.css.
// Pengujian tidak boleh bergantung pada berkas di folder proyek.
func buatStaticDir(t *testing.T) string {
	t.Helper()

	dir := t.TempDir()
	isi := "body { margin: 0; }\n"
	if err := os.WriteFile(filepath.Join(dir, "style.css"), []byte(isi), 0o644); err != nil {
		t.Fatalf("menulis style.css gagal: %v", err)
	}
	return dir
}

func TestHomeMerenderHalaman(t *testing.T) {
	ts := newTestServer(t, buatStaticDir(t), io.Discard)

	resp, err := http.Get(ts.URL + "/")
	if err != nil {
		t.Fatalf("GET / gagal: %v", err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)

	if resp.StatusCode != http.StatusOK {
		t.Errorf("status GET / = %d, ingin 200", resp.StatusCode)
	}
	if !strings.Contains(string(body), "Selamat Datang di Web Server") {
		t.Errorf("body home tidak memuat judul halaman: %q", body)
	}
	if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
		t.Errorf("Content-Type home = %q, ingin text/html", ct)
	}
}

func TestHealthzMenjawabOk(t *testing.T) {
	ts := newTestServer(t, buatStaticDir(t), io.Discard)

	resp, err := http.Get(ts.URL + "/healthz")
	if err != nil {
		t.Fatalf("GET /healthz gagal: %v", err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)

	if resp.StatusCode != http.StatusOK {
		t.Errorf("status GET /healthz = %d, ingin 200", resp.StatusCode)
	}
	if strings.TrimSpace(string(body)) != "ok" {
		t.Errorf("body /healthz = %q, ingin \"ok\"", body)
	}
}

func TestKontakMenampilkanEmail(t *testing.T) {
	ts := newTestServer(t, buatStaticDir(t), io.Discard)

	resp, err := http.Get(ts.URL + "/kontak")
	if err != nil {
		t.Fatalf("GET /kontak gagal: %v", err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)

	if resp.StatusCode != http.StatusOK {
		t.Errorf("status GET /kontak = %d, ingin 200", resp.StatusCode)
	}
	if !strings.Contains(string(body), "[email protected]") {
		t.Errorf("body kontak tidak memuat email: %q", body)
	}
}

func TestRuteTidakDikenalMenjawab404(t *testing.T) {
	ts := newTestServer(t, buatStaticDir(t), io.Discard)

	resp, err := http.Get(ts.URL + "/tidak-ada")
	if err != nil {
		t.Fatalf("GET /tidak-ada gagal: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusNotFound {
		t.Errorf("status GET /tidak-ada = %d, ingin 404", resp.StatusCode)
	}
}

func TestMetodeSalahMenjawab405(t *testing.T) {
	ts := newTestServer(t, buatStaticDir(t), io.Discard)

	resp, err := http.Post(ts.URL+"/healthz", "text/plain", strings.NewReader("tes"))
	if err != nil {
		t.Fatalf("POST /healthz gagal: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusMethodNotAllowed {
		t.Errorf("status POST /healthz = %d, ingin 405", resp.StatusCode)
	}
}

func TestStaticFileDilayaniDariDirektoriUji(t *testing.T) {
	ts := newTestServer(t, buatStaticDir(t), io.Discard)

	resp, err := http.Get(ts.URL + "/static/style.css")
	if err != nil {
		t.Fatalf("GET /static/style.css gagal: %v", err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)

	if resp.StatusCode != http.StatusOK {
		t.Errorf("status GET /static/style.css = %d, ingin 200", resp.StatusCode)
	}
	if !strings.Contains(string(body), "margin: 0") {
		t.Errorf("body style.css tidak berisi CSS uji: %q", body)
	}
}

func TestRequestIDHeaderTersediaDanBerurutan(t *testing.T) {
	ts := newTestServer(t, buatStaticDir(t), io.Discard)

	resp1, err := http.Get(ts.URL + "/healthz")
	if err != nil {
		t.Fatalf("permintaan pertama gagal: %v", err)
	}
	resp1.Body.Close()
	resp2, err := http.Get(ts.URL + "/healthz")
	if err != nil {
		t.Fatalf("permintaan kedua gagal: %v", err)
	}
	resp2.Body.Close()

	if got := resp1.Header.Get("X-Request-ID"); got != "req-001" {
		t.Errorf("X-Request-ID permintaan pertama = %q, ingin req-001", got)
	}
	if got := resp2.Header.Get("X-Request-ID"); got != "req-002" {
		t.Errorf("X-Request-ID permintaan kedua = %q, ingin req-002", got)
	}
}

func TestRecoverMengubahPanicMenjadi500(t *testing.T) {
	var log bytes.Buffer

	// Handler uji sengaja panic; hanya middleware Recover yang membungkusnya.
	panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		panic("kegagalan sengaja untuk pengujian")
	})
	ts := httptest.NewServer(Chain(panicHandler, NewRecover(&log)))
	t.Cleanup(ts.Close)

	resp, err := http.Get(ts.URL + "/")
	if err != nil {
		t.Fatalf("GET / ke handler panic gagal: %v", err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)

	if resp.StatusCode != http.StatusInternalServerError {
		t.Errorf("status = %d, ingin 500", resp.StatusCode)
	}
	if !strings.Contains(string(body), "kesalahan internal server") {
		t.Errorf("body respons panic = %q", body)
	}
	if !strings.Contains(log.String(), "PANIC") {
		t.Errorf("log tidak mencatat panic: %q", log.String())
	}
}

func TestLoggingMencatatPermintaan(t *testing.T) {
	var log bytes.Buffer
	ts := newTestServer(t, buatStaticDir(t), &log)

	resp, err := http.Get(ts.URL + "/healthz")
	if err != nil {
		t.Fatalf("GET /healthz gagal: %v", err)
	}
	resp.Body.Close()

	want := "req-001 GET /healthz 200"
	if !strings.Contains(log.String(), want) {
		t.Errorf("log = %q, ingin memuat %q", log.String(), want)
	}
}

static/style.css

Unduh berkas
css
/* Berkas statis untuk halaman utama web-server. */

body {
	font-family: system-ui, sans-serif;
	background: #f4f6f8;
	color: #1f2933;
	margin: 2rem auto;
	max-width: 40rem;
}

.kartu {
	background: #ffffff;
	border: 1px solid #d9e2ec;
	border-radius: 8px;
	padding: 1.5rem 2rem;
}

h1 {
	margin-top: 0;
	color: #102a43;
}

a {
	color: #1565d8;
}