Setiap web server layak produksi dimulai dari fondasi yang sama: konfigurasi yang jelas, perakitan http.Server dengan batas waktu yang eksplisit, dan siklus hidup yang bisa dimatikan dengan sopan. Pada bagian ini kita menyiapkan ketiganya di berkas main.go. Bootstrap yang tertata membuat bagian routing dan middleware tinggal mengisi celah yang sudah disiapkan.

Konfigurasi dari Lingkungan #

Server kita membaca dua pengaturan dari variabel lingkungan dan memberi nilai bawaan yang aman. Berikut konfigurasi lengkapnya, diambil dari main.go:

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

Mari kita telaah peran setiap bagian:

  1. Struct Config (baris 16-21): menampung seluruh pengaturan dalam satu tempat, termasuk dua time.Duration untuk batas waktu baca dan tulis.
  2. os.Getenv("PORT") (baris 26): membaca variabel lingkungan PORT; container dan platform deploy biasanya menyuntikkan port lewat variabel ini.
  3. Nilai bawaan (baris 27-29 dan 31-33): string kosong berarti pengatur tidak diisi, jadi kita jatuh ke 8080 dan static. Server tetap jalan tanpa pengaturan apa pun.
  4. Addr: ":" + port (baris 35): bentuk ":8080" berarti mendengarkan di semua antarmuka jaringan; gunakan "127.0.0.1:8080" bila hanya ingin akses lokal.

Pola env-terlebih-dahulu ini mengikuti praktik twelve-factor: konfigurasi berbeda antar lingkungan tanpa mengubah kode. Kedua timeout sengaja tidak diambil dari env karena nilainya jarang berubah antar lingkungan.

Merakit http.Server #

Fungsi NewServer membangun http.Server dari konfigurasi dan handler yang sudah dibungkus middleware:

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

ReadTimeout membatasi lama server menunggu permintaan masuk selesai dibaca; WriteTimeout membatasi lama server menulis respons. Keduanya diisi lewat struct literal dengan field eksplisit, bukan http.Server{Addr: ...} polos.

Menjalankan dan Mematikan dengan Mulus #

Fungsi run() adalah jantung bootstrap: memulai ListenAndServe di goroutine, menunggu sinyal berhenti, lalu memanggil Shutdown:

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

Mari kita telaah bagian-bagian pentingnya menurut baris di main.go:

  1. Baris 62 (cfg := ConfigFromEnv()): konfigurasi dibaca sekali di awal, bukan di tengah jalan, sehingga seluruh siklus hidup memakai nilai yang sama.
  2. Baris 64-67 (NewHandlers dan galatnya): template di-parse sebelum server menerima permintaan; galat parse dilaporkan sebagai galat ber-lapis (%w) agar penyebab aslinya ikut terbawa.
  3. Baris 71-75 (Chain): router dibungkus tiga middleware — request ID paling luar, lalu logging, lalu recover. Ketiganya dibahas tuntas pada Bagian 3.
  4. Baris 81 (signal.NotifyContext): mendaftarkan SIGINT dan SIGTERM; begitu salah satu tiba, context ctx dibatalkan dan <-ctx.Done() terpenuhi.
  5. Baris 84-89 (errCh dan goroutine): channel ber-buffer 1 menerima nilai balik ListenAndServe. Buffer mencegah goroutine ini bocor meskipun tidak ada yang membaca channel lagi.
  6. Baris 93-98 (cabang errCh): server berhenti sendiri tanpa sinyal — misalnya port sudah dipakai. Hanya http.ErrServerClosed yang diabaikan karena nilai itu muncul justru saat Shutdown berhasil.
  7. Baris 101-105 (Shutdown): permintaan yang sedang berjalan diberi waktu 5 detik untuk selesai; koneksi idle ditutup langsung. Jika tenggat terlampaui, Shutdown mengembalikan galat dan kita laporkan.

Perhatikan juga bentuk main di baris 52-57: fungsi main hanya memanggil run() dan menerjemahkan galat menjadi exit code 1. Pemisahan ini membuat keseluruhan siklus hidup bisa dipanggil ulang — misalnya oleh alat deploy yang menguji health check sebelum mengumumkan server siap.

Menjalankan Bootstrap #

Dari folder proyek, jalankan server dengan port pilihan agar tidak bertabrakan dengan server lain di mesin yang sama:

bash
PORT=8091 go run .

Output yang Dihasilkan (server berjalan; dihentikan dengan Ctrl+C):

text
web-server mendengarkan di http://localhost:8091
terima sinyal berhenti, mematikan server dengan mulus
server berhenti

Baris pertama muncul begitu ListenAndServe dimulai. Menekan Ctrl+C mengirim SIGINT; context dibatalkan, Shutdown menutup koneksi dengan sopan, lalu dua baris penutup tercetak sebelum proses keluar dengan exit code 0.

Rujukan Materi #

Bootstrap sudah berdiri: konfigurasi terbaca, server terakit dengan timeout, dan proses bisa mati dengan mulus. Pada Bagian 2 kita mengisi tabel routing beserta handler pertamanya.

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