Middleware menjaga ketertiban, tetapi pengunjung datang untuk isi halaman. Bagian terakhir proyek ini mengisi dua kebutuhan penyajian: halaman HTML hasil render html/template dan berkas statis seperti CSS. Template halaman utama disimpan sebagai berkas templates/index.tmpl lalu ditanam ke binary dengan embed, sementara template kontak yang pendek cukup hidup sebagai raw string di dalam kode.
Template Halaman Utama #
Isi templates/index.tmpl adalah HTML biasa dengan action {{ }} milik html/template:
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Judul}}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main class="kartu">
<h1>{{.Judul}}</h1>
<p>{{.Pesan}}</p>
<ul>
<li><a href="/healthz">Status server</a></li>
<li><a href="/kontak">Halaman kontak</a></li>
</ul>
</main>
</body>
</html>Action {{.Judul}} dan {{.Pesan}} diganti nilai field struct saat render. Dua tautan di bawahnya sengaja menunjuk ke rute yang kita bangun pada Bagian 2, dan stylesheet ditarik dari /static/style.css yang dilayani file server pada bagian ini.
Embed dan ParseFS #
Berikut deklarasi embed beserta parsing template di handlers.go:
//go:embed templates/index.tmpl
var templateFS embed.FSDirektif //go:embed menempelkan isi templates/index.tmpl ke variabel templateFS pada saat kompilasi. Binary hasil go build lalu memuat template dari memorinya sendiri — deployment cukup menyalin satu berkas binary tanpa membawa folder templates. Parsing dilakukan sekali di NewHandlers:
// 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
}template.ParseFS membaca pola path dari embed FS, bukan dari disk, sehingga template yang dipakai server pasti template yang ikut dikompilasi. Galat parse (misalnya action salah tulis) muncul saat server menyala di run() — bukan di tengah trafik jam sibuk.
Render di Handler #
Handler Home menyiapkan data lalu memanggil Execute:
// 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)
}
}Data dikirim sebagai struct Halaman dengan field Judul dan Pesan — field yang tidak diisi menghasilkan string kosong, bukan galat. Execute menulis langsung ke w, satu langkah tanpa buffer perantara.
Template pendek seperti halaman kontak tidak perlu berkas terpisah. Konstanta raw string di handlers.go memuat seluruh templatenya:
// 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>
`Raw string memakai backtick sehingga tanda kutip ganda di HTML tidak perlu di-escape. Handler Kontak merender konstanta ini dengan cara yang sama persis seperti Home.
Menyajikan Berkas Statis #
CSS dan aset lain dilayani http.FileServer yang terdaftar pada Bagian 2:
fileServer := http.FileServer(http.Dir(staticDir))
mux.Handle("GET /static/", http.StripPrefix("/static/", fileServer))http.Dir membuka direktori dan mencegah path .. keluar dari akarnya; StripPrefix membuang /static/ sebelum path diteruskan. File server otomatis mengirim Content-Type sesuai extension — CSS dijawab text/css.
Jalankan server lalu tarik halaman utama beserta stylesheet-nya:
PORT=8091 go run .
curl -s http://localhost:8091/
curl -s http://localhost:8091/static/style.cssOutput yang Dihasilkan (permintaan pertama, halaman utama):
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Selamat Datang di Web Server</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main class="kartu">
<h1>Selamat Datang di Web Server</h1>
<p>Server ini dibangun hanya dengan net/http, tanpa framework eksternal.</p>
<ul>
<li><a href="/healthz">Status server</a></li>
<li><a href="/kontak">Halaman kontak</a></li>
</ul>
</main>
</body>
</html>Output yang Dihasilkan (permintaan kedua, stylesheet):
/* 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;
}Perhatikan bahwa nilai {{.Judul}} terisi di elemen title dan h1, sementara {{.Pesan}} menjadi paragraf pembuka — persis data yang dikirim handler Home. File server mengirim isi static/style.css apa adanya tanpa proses apa pun.
Menguji Template dan Berkas Statis #
Pengujian template memeriksa isi hasil render, bukan struktur HTML lengkap — cukup pastikan data kunci muncul:
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)
}
}Pengujian berkas statis tidak boleh bergantung pada folder proyek. Helper buatStaticDir membuat direktori sementara berisi CSS uji, lalu router diarahkan ke sana lewat parameter staticDir yang kita siapkan sejak Bagian 2:
// 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
}t.TempDir() otomatis dihapus saat tes selesai, dan t.Helper() menandai fungsi ini sebagai helper sehingga laporan galat menunjuk baris pemanggil, bukan baris di dalam helper. Jalankan seluruh rangkaian:
go test -v ./...Output yang Dihasilkan (cuplikan tiga pengujian terakhir):
=== RUN TestHomeMerenderHalaman
--- PASS: TestHomeMerenderHalaman (0.00s)
=== RUN TestStaticFileDilayaniDariDirektoriUji
--- PASS: TestStaticFileDilayaniDariDirektoriUji (0.00s)
=== RUN TestKontakMenampilkanEmail
--- PASS: TestKontakMenampilkanEmail (0.00s)Rujukan Materi #
- HTML Template — action, konteks escaping, dan pemetaan data ke template.
- Embed
— direktif
//go:embeduntuk menanam berkas ke binary. - File Server — penyajian berkas statis dan pencegahan path traversal.
- Dokumentasi html/template — referensi resmi API template HTML.
Proyek web-server kini utuh: bootstrap yang bisa mati dengan sopan, routing dengan pattern method, middleware yang menjaga jejak dan ketahanan, serta template dan berkas statis yang membuatnya layak dilihat. Pola perangkaian yang sama — handler, middleware chain, httptest — siap dipakai ulang pada proyek REST API berikutnya.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
handlers.go
Unduh berkaspackage 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 berkaspackage 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 berkaspackage 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 berkaspackage 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 berkaspackage 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/* 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;
}