Bootstrap dari Bagian 1 masih menerima handler kosong; sekarang kita mengisi tabel routing yang memutuskan permintaan mana ditangani siapa. Sejak Go 1.22, http.ServeMux memahami pattern seperti "GET /healthz" — metode dan path dinyatakan sekaligus dalam satu string. Pada bagian ini kita menyusun seluruh rute proyek beserta handler health check, lalu membuktikan bahwa jawaban 404 dan 405 keluar otomatis dari pattern tersebut.

Tabel Routing dengan Pattern Method #

Seluruh keputusan routing berada di router.go, fungsi NewRouter:

go
// 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
}

Mari kita telaah baris-baris kuncinya di router.go:

  1. Baris 12 (http.NewServeMux()): membuat multiplexer baru; satu mux menampung seluruh rute proyek sehingga tidak ada router pihak ketiga.
  2. Baris 17 ("GET /{$}"): mendaftarkan halaman utama. Token {$} berarti “akhir path di sini”, sehingga pattern hanya cocok untuk / dan tidak untuk /apa-saja.
  3. Baris 18-19 (h.Healthz dan h.Kontak): handler didaftarkan sebagai method dari struct Handlers, bukan fungsi lepas — template yang sudah di-parse menempel pada struct yang sama.
  4. Baris 23-24 (FileServer dan StripPrefix): http.Dir(staticDir) membuka direktori berkas statis, sementara StripPrefix membuang /static/ dari path permintaan sehingga GET /static/style.css membaca berkas static/style.css di disk.

Hasil akhirnya adalah tabel routing yang ringkas:

PatternHandlerKeterangan
GET /{$}h.HomeHalaman utama hasil render template
GET /healthzh.HealthzHealth check untuk load balancer
GET /kontakh.KontakHalaman kontak dari raw string template
GET /static/http.FileServerBerkas statis (CSS)

Parameter staticDir sengaja tidak diambil dari variabel global. Pengujian pada Bagian 4 mengarahkan file server ke t.TempDir() sehingga keberhasilan tes tidak bergantung pada isi folder proyek.

Pattern Akar yang Presisi #

Beda satu karakter pada pattern akar mengubah perilaku seluruh server. Pattern "/" tanpa {$} adalah prefix pattern: mux menangkap semua path yang belum terdaftar dan halaman utama menjadi halaman 404. Pattern "/{$}" hanya cocok untuk path / persis, sehingga path yang tidak dikenal jatuh ke jawaban 404 bawaan mux.

Handler Health Check #

Endpoint health check dibuat seminimal mungkin — tidak ada query database, tidak ada render template:

go
// 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")
}

Alurnya tiga langkah: set header Content-Type, tulis status 200, lalu tulis body ok. Endpoint seperti ini boleh dipanggil setiap detik oleh load balancer karena biayanya nyaris nol. Pembedaan liveness dan readiness yang lebih lengkap dibahas pada chapter Health, Readiness, dan Liveness .

Jawaban 405 dan 404 dari ServeMux #

Pattern method membuat mux menolak permintaan dengan metode salah secara otomatis. Jalankan server (PORT=8091 go run .), lalu amati tiga permintaan berikut:

bash
curl -si http://localhost:8091/healthz | rg -v "^Date:"
curl -si -X POST http://localhost:8091/healthz | rg -v "^Date:"
curl -si http://localhost:8091/tidak-ada | rg -v "^Date:"

Output yang Dihasilkan (baris Date: disaring karena berisi waktu permintaan):

text
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
X-Request-Id: req-001
Content-Length: 3

ok
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
X-Request-Id: req-002
Content-Length: 19

Method Not Allowed
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
X-Request-Id: req-003
Content-Length: 19

404 page not found

Tiga perilaku berbeda keluar dari mux tanpa satu baris kode pengecekan manual: GET /healthz berjaya, POST /healthz ditolak 405 lengkap dengan header Allow yang memberi tahu metode apa saja yang sah, dan path asing dijawab 404 standar. Header X-Request-Id hadir di ketiganya — itu buatan middleware dari Bagian 3.

Menguji Routing dengan httptest #

Pengujian routing tidak membuka port produksi. httptest.NewServer menjalankan server sungguhan di localhost dengan port acak, lalu tes melakukan permintaan HTTP biasa. Dua pengujian berikut diambil dari server_test.go:

go
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)
	}
}

Kedua tes memanggil helper newTestServer yang merakit handler, router, dan middleware chain persis seperti produksi, lalu membungkusnya dengan httptest.NewServer. Jalankan seluruh pengujian:

bash
go test -v ./...

Output yang Dihasilkan (cuplikan dua pengujian routing):

text
=== RUN   TestRuteTidakDikenalMenjawab404
--- PASS: TestRuteTidakDikenalMenjawab404 (0.00s)
=== RUN   TestMetodeSalahMenjawab405
--- PASS: TestMetodeSalahMenjawab405 (0.00s)

Rujukan Materi #

  • Routing — aturan lengkap pattern ServeMux: metode, wildcard, dan prioritas.
  • File Server — penyajian berkas statis dengan http.FileServer dan StripPrefix.
  • httptest — pembuatan server dan client palsu untuk pengujian handler.
  • Dokumentasi net/http ServeMux — referensi resmi sintaks pattern.

Tabel routing dan health check sudah berdiri, tetapi setiap permintaan masih datang tanpa identitas dan tanpa jejak di log. Bagian 3 menambahkan tiga middleware: request ID, logging, dan recover.

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;
}