Dockerfile dan compose.yaml adalah bagian akhir dari cerita ini; bagian awalnya adalah aplikasi yang memang dirancang untuk hidup di dalam container. Aplikasi sembarangan tidak akan nyaman di container: ia harus membaca konfigurasi dari environment variable karena berkas konfigurasi tidak ikut terkirim ke image, tidak boleh meng-hardcode alamat database karena alamat itu berubah setiap kali stack menyala, harus siap menerima kegagalan koneksi sesaat karena tetangganya belum tentu siap, dan harus mati dengan sopan saat diberi sinyal berakhir. Bagian ini membaca source code proyek — tiga berkas Go, satu berkas SQL — dan membedah keputusan desainnya satu per satu, karena setiap barisnya akan berbuah di Dockerfile Bagian 2 dan compose.yaml Bagian 3.

Peta Arsitektur #

Sebelum membaca kode, mari lihat bentuk keseluruhan sistem yang kita bangun:

Diagram ini memuat seluruh keputusan arsitektur proyek dalam empat kotak. Klien hanya berbicara dengan app lewat port 8080 yang diterbitkan ke host; app berbicara dengan db lewat port 5432 yang tidak pernah keluar dari jaringan internal; dan db menumpangkan seluruh cluster-nya di volume katalog_db-data milik host. Pembagian kerjanya tegas — persis pembedaan stateless dan stateful dari Arsitektur Multi-Container: app tidak menyimpan apa pun sehingga boleh dibuang dan dibangun ulang kapan saja, sementara seluruh memori sistem hidup di db dan bertahan di volume. Pola aplikasi-ke-database seperti ini juga yang dibedah pada Application dan Database; proyek kita mengambil pola itu dan melengkapi detailnya satu per satu.

Peta Endpoint dan Routing #

Seluruh endpoint dideklarasikan di satu fungsi kecil dalam main.go:

go
// routes memetakan endpoint ke handler dengan pola routing Go 1.22+.
func routes(db *sql.DB) http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", handleHealthz(db))
	mux.HandleFunc("GET /api/items", handleListItems(db))
	mux.HandleFunc("POST /api/items", handleCreateItem(db))
	mux.HandleFunc("GET /api/items/{id}", handleGetItem(db))
	return mux
}

Mari kita telaah tiga hal dari sembilan baris ini:

  1. Metode dan path menjadi satu string — "GET /healthz" bukan sekadar path; ServeMux versi Go 1.22 ke atas mencocokkan pasangan metode dan jalur sekaligus. Permintaan DELETE /api/items tidak akan tersesat ke handler mana pun, melainkan ditolak otomatis dengan 405 Method Not Allowed — perilaku yang kita dapat gratis tanpa satu baris validasi.
  2. {id} adalah wildcard bernama — pola "GET /api/items/{id}" menangkap segmen jalur terakhir, dan handler mengambilnya lewat r.PathValue("id"). Satu deklarasi ini yang membuat GET /api/items/1 dan GET /api/items/999 mendarat di handler yang sama dengan nilai berbeda.
  3. Handler menerima *sql.DB lewat closure — setiap handler dibungkus bersama koneksi database tanpa variabel global sedikit pun. Fungsi routes mengembalikan http.Handler siap pakai, sehingga menyusun ulang peta endpoint cukup menyunting satu tempat ini.

Skema Database dan Migrasi Idempoten #

Satu-satunya berkas SQL di proyek ini mendefinisikan tabel utama:

sql
-- Tabel utama katalog: satu baris mewakili satu item yang bisa dibuat
-- lewat POST /api/items. id dibangkitkan otomatis oleh BIGSERIAL.
CREATE TABLE IF NOT EXISTS items (
    id         BIGSERIAL PRIMARY KEY,
    name       TEXT NOT NULL,
    note       TEXT DEFAULT '',
    created_at TIMESTAMPTZ DEFAULT now()
);

Empat kolomnya sengaja sederhana. id BIGSERIAL PRIMARY KEY adalah bilangan 64-bit yang nilainya ditarik dari sequence otomatis — itulah asal angka 1 dan 2 pada respons POST kita. created_at TIMESTAMPTZ DEFAULT now() meminta PostgreSQL mengisi waktu saat baris dibuat, lengkap dengan zona waktu, sehingga aplikasi tidak perlu mencatat jam sendiri. Klausa IF NOT EXISTS adalah kunci idempotensi: skrip ini boleh dijalankan berulang tanpa merusak tabel yang sudah ada.

Yang menjalankan berkas ini adalah fungsi migrate() di database.go, dan desainnya melengkapi IF NOT EXISTS dengan dua lapis pengaman lain. Pertama, sebelum membaca direktori migrations/, fungsi membuat tabel schema_migrations yang mencatat nama berkas yang sudah diterapkan; berkas yang namanya sudah tercatat dilewati dengan pesan log tersendiri. Kedua, setiap berkas dijalankan dalam satu transaksi bersama pencatatan versinya — bila CREATE TABLE gagal di tengah jalan, pencatatan versinya ikut dibatalkan, sehingga mustahil ada versi tercatat yang skemanya tidak benar-benar diterapkan. Gabungan ketiganya membuat migrasi aman dijalankan setiap kali container menyala: build pertama menerapkan skema, restart berikutnya melewatinya. Pola tabel pencatat versi ini sendiri dibahas tuntas pada Migrasi Versi Database, dan alasan mengapa inisialisasi lewat aplikasi — bukan manual — berhubungan dengan Inisialisasi Database.

Koneksi Database dengan Retry #

Fungsi Open() di database.go tidak sekadar membuka koneksi — ia memastikan database benar-benar hidup sebelum aplikasi melangkah:

go
// jumlahPercobaan dan jedaPercobaan mengatur retry koneksi database.
const (
	jumlahPercobaan = 10
	jedaPercobaan   = 500 * time.Millisecond
)

// Open membuka koneksi PostgreSQL dan memastikan benar-benar hidup
// dengan PingContext sebelum dikembalikan.
func Open(ctx context.Context, dsn string) (*sql.DB, error) {
	var errTerakhir error
	for percobaan := 1; percobaan <= jumlahPercobaan; percobaan++ {
		db, err := sql.Open("pgx", dsn)
		if err == nil {
			if err = db.PingContext(ctx); err == nil {
				return db, nil
			}
			db.Close()
		}
		errTerakhir = err
		log.Printf("koneksi database gagal (percobaan %d/%d): %v", percobaan, jumlahPercobaan, err)
		select {
		case <-ctx.Done():
			return nil, ctx.Err()
		case <-time.After(jedaPercobaan):
		}
	}
	return nil, fmt.Errorf("database tidak dapat dijangkau setelah %d percobaan: %w", jumlahPercobaan, errTerakhir)
}

Mari kita telaah tiga keputusan di dalamnya:

  1. sql.Open tidak benar-benar menyambung — panggilan itu hanya memvalidasi DSN dan menyiapkan pool; koneksi sungguhan baru terjadi saat PingContext mengirim query SELECT 1 ke server. Tanpa ping, aplikasi akan menganggap dirinya sehat padahal database tidak pernah dijawab — jebakan klasik database/sql yang ditutup oleh satu baris ini.
  2. Sepuluh percobaan berjeda 500 milidetik — total lima detik kesabaran untuk menutup celah startup. Container database bisa saja sudah “menyala” tetapi masih menyelesaikan inisialisasi internalnya; pg_isready di sisi Compose membantu, namun lapisan retry di aplikasi memastikan tidak ada race kecil yang lolos.
  3. select dengan dua cabang — selama menunggu jeda, fungsi mendengarkan dua sinyal sekaligus: ctx.Done() yang menyala saat aplikasi menerima SIGTERM, dan time.After untuk jeda normal. Bila stack dimatikan saat sedang retry, aplikasi langsung menyerah daripada membuat Docker menunggu masa tenggang penuh — kelak pasangan sinyal ini juga yang menghentikan HTTP server dengan sopan di main.

Alur POST dari Ujung ke Ujung #

Dengan routing, skema, dan koneksi di tangan, mari lacak satu request POST /api/items dari keyboard sampai baris database — enam langkah yang masing-masing kita sudah kenali bentuknya:

  1. Klien mengirim request — curl menulis header Content-Type: application/json dan body {"name":"Kopi Torabika","note":"favorit pelanggan"} ke localhost:8080; Docker meneruskan porta itu ke container app.
  2. Router mencocokkan pola — ServeMux mencocokkan string "POST /api/items" secara persis dan menyerahkan ke handleCreateItem; method lain di jalur yang sama tidak pernah sampai ke sini.
  3. Validasi menyaring body — body didekode JSON lewat io.LimitReader setengah megabyte agar request raksasa tidak menghabiskan memori; name dipangkas spasinya dan wajib tidak kosong; note dihitung per karakter UTF-8 dan ditolak bila melebihi 200 — dua tolakan ini kelak diuji pada Bagian 5.
  4. Database menyimpan sekaligus membalas — satu pernyataan INSERT INTO items (name, note) VALUES ($1, $2) RETURNING id, name, note, created_at menulis baris dan membaca hasilnya dalam satu round-trip; id dan created_at diisi PostgreSQL, bukan aplikasi.
  5. Respons dirakit — writeJSON mengirim status 201 Created dengan Content-Type: application/json dan body item lengkap; itulah baris JSON yang kita terima pada bagian Hasil Akhir di halaman overview.
  6. Data menetap — baris itu kini milik volume db-data lewat container db; setiap GET /api/items dan GET /api/items/{id} berikutnya membacanya kembali tanpa container app menyimpan apa pun.

Peran Berkas dan Tempat Hidupnya #

Satu pertanyaan penutup yang sering mengejutkan pemula: setelah proyek berjalan, di mana sebenarnya tiap berkas hidup? Jawabannya menegaskan batas antara mesin build, image, container, dan host:

BerkasPeranHidup di mana
main.go, database.go, handlers.goSource aplikasi: konfigurasi, routing, koneksi, migrasi, handlerDikompilasi menjadi /usr/local/bin/server di dalam image
go.mod, go.sumManifest dan checksum dependensiHanya tahap builder — tidak ikut ke image akhir
migrations/001_create_items.sqlSkema awal tabel itemsDisalin ke /app/migrations/ di image, dibaca saat startup
tests/integration_test.goTest black-box HTTPDi host, di-bind mount ke service test — tidak masuk image
Dockerfile, .dockerignoreResep dan penyaring buildDi host, hanya relevan saat docker build
compose.yamlDeklarasi stack app, db, testDi host, dibaca Docker Compose setiap perintah
.envPassword untuk interpolasi ComposeDi host, dibaca Compose — tidak pernah terkirim ke builder

Desain sudah utuh di atas kertas: aplikasi tahu cara membaca environment, menyapa database dengan sabar, dan memigrasikan skemanya sendiri. Tiga keputusan itu kini menuntut satu wadah yang tepat — image yang memuat binary tanpa membawa toolchain, berkas migrasi tanpa membawa source code. Bagian berikutnya menulis Dockerfile multi-stage yang memenuhi tuntutan itu, lalu membuktikan dengan dua build berturut-turut bahwa urutan penulisannya bukan kebetulan estetis melainkan strategi cache.

Referensi #

Berkas Sumber

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

compose.yaml

Unduh berkas
yaml
name: katalog

services:
  app:
    build: .
    image: katalog-app:1.0
    ports:
      - "8080:8080"
    environment:
      PGHOST: db
      PGPORT: "5432"
      PGUSER: katalog
      PGPASSWORD: ${POSTGRES_PASSWORD:?wajib diset dari .env}
      PGDATABASE: katalog
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_USER: katalog
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?wajib diset dari .env}
      POSTGRES_DB: katalog
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U katalog -d katalog"]
      interval: 2s
      timeout: 2s
      retries: 15
    restart: unless-stopped

  # Service test hanya berjalan dengan profile khusus:
  #   docker compose --profile test run --rm test
  test:
    profiles: ["test"]
    image: golang:1.24-alpine
    working_dir: /src
    volumes:
      - ./:/src
    environment:
      APP_URL: http://app:8080
      PGHOST: db
      PGPORT: "5432"
      PGUSER: katalog
      PGPASSWORD: ${POSTGRES_PASSWORD:?wajib diset dari .env}
      PGDATABASE: katalog
    depends_on:
      app:
        condition: service_started
      db:
        condition: service_healthy
    command: go test -v ./tests/

volumes:
  db-data:

database.go

Unduh berkas
go
// koneksi database dan runner migrasi sederhana.
//
// Open() mencoba terhubung hingga 10 kali dengan jeda 500 ms — pola ini
// menutup celah startup saat container database belum sepenuhnya siap.
// migrate() membaca migrations/*.sql secara berurutan dan mencatat versi
// yang sudah diterapkan ke tabel schema_migrations.
package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	_ "github.com/jackc/pgx/v5/stdlib" // mendaftarkan driver "pgx" untuk database/sql
)

// jumlahPercobaan dan jedaPercobaan mengatur retry koneksi database.
const (
	jumlahPercobaan = 10
	jedaPercobaan   = 500 * time.Millisecond
)

// Open membuka koneksi PostgreSQL dan memastikan benar-benar hidup
// dengan PingContext sebelum dikembalikan.
func Open(ctx context.Context, dsn string) (*sql.DB, error) {
	var errTerakhir error
	for percobaan := 1; percobaan <= jumlahPercobaan; percobaan++ {
		db, err := sql.Open("pgx", dsn)
		if err == nil {
			if err = db.PingContext(ctx); err == nil {
				return db, nil
			}
			db.Close()
		}
		errTerakhir = err
		log.Printf("koneksi database gagal (percobaan %d/%d): %v", percobaan, jumlahPercobaan, err)
		select {
		case <-ctx.Done():
			return nil, ctx.Err()
		case <-time.After(jedaPercobaan):
		}
	}
	return nil, fmt.Errorf("database tidak dapat dijangkau setelah %d percobaan: %w", jumlahPercobaan, errTerakhir)
}

// migrate menerapkan setiap berkas migrations/*.sql yang belum tercatat
// di schema_migrations. Satu berkas berjalan dalam satu transaksi,
// bersama pencatatan versinya, supaya keduanya sukses atau gagal bersama.
func migrate(ctx context.Context, db *sql.DB) error {
	if _, err := db.ExecContext(ctx, `
		CREATE TABLE IF NOT EXISTS schema_migrations (
			version    TEXT PRIMARY KEY,
			applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
		)`); err != nil {
		return fmt.Errorf("membuat tabel schema_migrations: %w", err)
	}

	entri, err := os.ReadDir("migrations")
	if err != nil {
		return fmt.Errorf("membaca direktori migrations: %w", err)
	}

	var berkas []string
	for _, e := range entri {
		if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
			berkas = append(berkas, e.Name())
		}
	}
	sort.Strings(berkas)

	for _, nama := range berkas {
		var sudah bool
		if err := db.QueryRowContext(ctx,
			`SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, nama,
		).Scan(&sudah); err != nil {
			return fmt.Errorf("memeriksa versi %s: %w", nama, err)
		}
		if sudah {
			log.Printf("migrasi %s sudah diterapkan, dilewati", nama)
			continue
		}

		isi, err := os.ReadFile(filepath.Join("migrations", nama))
		if err != nil {
			return fmt.Errorf("membaca %s: %w", nama, err)
		}

		tx, err := db.BeginTx(ctx, nil)
		if err != nil {
			return fmt.Errorf("memulai transaksi %s: %w", nama, err)
		}
		if _, err := tx.ExecContext(ctx, string(isi)); err != nil {
			tx.Rollback()
			return fmt.Errorf("menjalankan %s: %w", nama, err)
		}
		if _, err := tx.ExecContext(ctx,
			`INSERT INTO schema_migrations (version) VALUES ($1)`, nama,
		); err != nil {
			tx.Rollback()
			return fmt.Errorf("mencatat %s: %w", nama, err)
		}
		if err := tx.Commit(); err != nil {
			return fmt.Errorf("komit %s: %w", nama, err)
		}
		log.Printf("migrasi %s diterapkan", nama)
	}
	return nil
}

handlers.go

Unduh berkas
go
// handler HTTP untuk API katalog.
//
// Semua respons JSON dikirim lewat writeJSON agar Content-Type konsisten.
// Validasi input dilakukan sebelum menyentuh database: name wajib terisi,
// note opsional dengan batas 200 karakter.
package main

import (
	"database/sql"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"strconv"
	"strings"
	"time"
	"unicode/utf8"
)

// item adalah satu baris tabel items; tag json menentukan bentuk respons.
type item struct {
	ID        int64     `json:"id"`
	Name      string    `json:"name"`
	Note      string    `json:"note"`
	CreatedAt time.Time `json:"created_at"`
}

// itemBaru adalah bentuk body request POST /api/items.
type itemBaru struct {
	Name string `json:"name"`
	Note string `json:"note"`
}

// batasCatatan membatasi panjang field note agar data tetap wajar.
const batasCatatan = 200

// writeJSON menulis respons JSON dengan status HTTP yang diberikan.
func writeJSON(w http.ResponseWriter, status int, data any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(data)
}

// tulisError mengirim pesan error standar berbentuk {"error": "..."}.
func tulisError(w http.ResponseWriter, status int, pesan string) {
	writeJSON(w, status, map[string]string{"error": pesan})
}

// handleHealthz memastikan aplikasi DAN database sama-sama hidup.
func handleHealthz(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var satu int
		if err := db.QueryRowContext(r.Context(), `SELECT 1`).Scan(&satu); err != nil {
			tulisError(w, http.StatusServiceUnavailable, "database tidak merespons")
			return
		}
		writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
	}
}

// handleListItems mengembalikan seluruh item sebagai array JSON.
func handleListItems(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		rows, err := db.QueryContext(r.Context(),
			`SELECT id, name, note, created_at FROM items ORDER BY id`)
		if err != nil {
			tulisError(w, http.StatusInternalServerError, "gagal membaca items")
			return
		}
		defer rows.Close()

		items := []item{}
		for rows.Next() {
			var it item
			if err := rows.Scan(&it.ID, &it.Name, &it.Note, &it.CreatedAt); err != nil {
				tulisError(w, http.StatusInternalServerError, "gagal membaca baris item")
				return
			}
			items = append(items, it)
		}
		writeJSON(w, http.StatusOK, items)
	}
}

// handleCreateItem memvalidasi body lalu menyimpan item baru.
func handleCreateItem(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		// Body dibatasi 1 MB agar request besar tidak menghabiskan memori.
		var body itemBaru
		if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
			tulisError(w, http.StatusBadRequest, "body JSON tidak valid")
			return
		}

		name := strings.TrimSpace(body.Name)
		if name == "" {
			tulisError(w, http.StatusBadRequest, "name wajib diisi")
			return
		}
		if utf8.RuneCountInString(body.Note) > batasCatatan {
			tulisError(w, http.StatusBadRequest, "note maksimal 200 karakter")
			return
		}

		var it item
		err := db.QueryRowContext(r.Context(),
			`INSERT INTO items (name, note) VALUES ($1, $2)
             RETURNING id, name, note, created_at`,
			name, body.Note,
		).Scan(&it.ID, &it.Name, &it.Note, &it.CreatedAt)
		if err != nil {
			tulisError(w, http.StatusInternalServerError, "gagal menyimpan item")
			return
		}
		writeJSON(w, http.StatusCreated, it)
	}
}

// handleGetItem mengembalikan satu item berdasarkan {id} di path.
func handleGetItem(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
		if err != nil {
			tulisError(w, http.StatusBadRequest, "id tidak valid")
			return
		}

		var it item
		err = db.QueryRowContext(r.Context(),
			`SELECT id, name, note, created_at FROM items WHERE id = $1`, id,
		).Scan(&it.ID, &it.Name, &it.Note, &it.CreatedAt)
		if errors.Is(err, sql.ErrNoRows) {
			tulisError(w, http.StatusNotFound, "item tidak ditemukan")
			return
		}
		if err != nil {
			tulisError(w, http.StatusInternalServerError, "gagal membaca item")
			return
		}
		writeJSON(w, http.StatusOK, it)
	}
}

main.go

Unduh berkas
go
// Package katalog adalah REST API sederhana untuk daftar item.
//
// Konfigurasi dibaca dari environment variable (pola twelve-factor):
// PORT menentukan port HTTP, sedangkan PG* menentukan koneksi PostgreSQL.
package main

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

// konfigurasi menyimpan seluruh nilai yang dibaca dari environment.
type konfigurasi struct {
	Port string
	DSN  string
}

// bacaKonfigurasi mengambil PORT dan membangun DSN dari variabel PG*.
// Nilai default dipilih agar aplikasi juga bisa jalan di luar container.
func bacaKonfigurasi() konfigurasi {
	ambil := func(kunci, standar string) string {
		if v := os.Getenv(kunci); v != "" {
			return v
		}
		return standar
	}
	dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
		ambil("PGHOST", "localhost"),
		ambil("PGPORT", "5432"),
		ambil("PGUSER", "katalog"),
		os.Getenv("PGPASSWORD"),
		ambil("PGDATABASE", "katalog"),
	)
	return konfigurasi{
		Port: ambil("PORT", "8080"),
		DSN:  dsn,
	}
}

// routes memetakan endpoint ke handler dengan pola routing Go 1.22+.
func routes(db *sql.DB) http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", handleHealthz(db))
	mux.HandleFunc("GET /api/items", handleListItems(db))
	mux.HandleFunc("POST /api/items", handleCreateItem(db))
	mux.HandleFunc("GET /api/items/{id}", handleGetItem(db))
	return mux
}

func main() {
	cfg := bacaKonfigurasi()

	// Context dibatalkan saat proses menerima SIGINT atau SIGTERM.
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	db, err := Open(ctx, cfg.DSN)
	if err != nil {
		log.Fatalf("membuka database: %v", err)
	}
	defer db.Close()

	if err := migrate(ctx, db); err != nil {
		log.Fatalf("menjalankan migrasi: %v", err)
	}

	srv := &http.Server{
		Addr:              ":" + cfg.Port,
		Handler:           routes(db),
		ReadHeaderTimeout: 5 * time.Second,
	}

	// Saat sinyal berakhir diterima, beri waktu 10 detik untuk
	// menyelesaikan request yang sedang diproses (graceful shutdown).
	go func() {
		<-ctx.Done()
		log.Println("sinyal berakhir diterima, mematikan server...")
		shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		if err := srv.Shutdown(shutdownCtx); err != nil {
			log.Printf("shutdown: %v", err)
		}
	}()

	log.Printf("server mendengarkan di :%s", cfg.Port)
	if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Fatalf("server: %v", err)
	}
}

migrations/001_create_items.sql

Unduh berkas
sql
-- Tabel utama katalog: satu baris mewakili satu item yang bisa dibuat
-- lewat POST /api/items. id dibangkitkan otomatis oleh BIGSERIAL.
CREATE TABLE IF NOT EXISTS items (
    id         BIGSERIAL PRIMARY KEY,
    name       TEXT NOT NULL,
    note       TEXT DEFAULT '',
    created_at TIMESTAMPTZ DEFAULT now()
);

tests/integration_test.go

Unduh berkas
go
// Test black-box untuk API katalog.
//
// Test ini tidak memanggil fungsi internal aplikasi; yang diperiksa adalah
// perilaku HTTP persis seperti yang dirasakan klien sungguhan. Aplikasi
// harus sudah berjalan di APP_URL (default http://localhost:8080) dengan
// database yang dapat dijangkau lewat TEST_DATABASE_URL atau variabel PG*.
package tests

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
	"testing"
	"time"

	"github.com/jackc/pgx/v5"
)

// appURL adalah alamat aplikasi yang sedang diuji.
var appURL string

// TestMain memastikan aplikasi siap sebelum seluruh test dijalankan.
func TestMain(m *testing.M) {
	appURL = os.Getenv("APP_URL")
	if appURL == "" {
		appURL = "http://localhost:8080"
	}
	if err := tungguSiap(30 * time.Second); err != nil {
		fmt.Println("aplikasi tidak siap:", err)
		os.Exit(1)
	}
	os.Exit(m.Run())
}

// tungguSiap melakukan polling /healthz sampai aplikasi merespons
// atau batas waktu terlampaui — container sering butuh beberapa detik
// sebelum benar-benar siap menerima request.
func tungguSiap(batas time.Duration) error {
	mulai := time.Now()
	for {
		resp, err := http.Get(appURL + "/healthz")
		if err == nil {
			resp.Body.Close()
			if resp.StatusCode == http.StatusOK {
				return nil
			}
		}
		if time.Since(mulai) > batas {
			return fmt.Errorf("timeout menunggu %s/healthz setelah %s", appURL, batas)
		}
		time.Sleep(500 * time.Millisecond)
	}
}

// dsnTest menyusun DSN untuk reset database: TEST_DATABASE_URL diutamakan,
// jika kosong variabel PG* dirangkai dengan nilai default lokal.
func dsnTest() string {
	if dsn := os.Getenv("TEST_DATABASE_URL"); dsn != "" {
		return dsn
	}
	ambil := func(kunci, standar string) string {
		if v := os.Getenv(kunci); v != "" {
			return v
		}
		return standar
	}
	return fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=disable",
		ambil("PGHOST", "localhost"),
		ambil("PGPORT", "5432"),
		ambil("PGUSER", "katalog"),
		ambil("PGDATABASE", "katalog"),
	)
}

// resetItems mengosongkan tabel items dan mengembalikan counter id ke 1
// sehingga setiap test mulai dari kondisi yang sama.
func resetItems(t *testing.T) {
	t.Helper()
	ctx := context.Background()
	conn, err := pgx.Connect(ctx, dsnTest())
	if err != nil {
		t.Fatalf("koneksi database untuk reset: %v", err)
	}
	defer conn.Close(ctx)
	if _, err := conn.Exec(ctx, `TRUNCATE items RESTART IDENTITY`); err != nil {
		t.Fatalf("reset tabel items: %v", err)
	}
}

// postJSON mengirim body JSON dan mengembalikan respons mentahnya.
func postJSON(t *testing.T, path string, body any) *http.Response {
	t.Helper()
	isi, err := json.Marshal(body)
	if err != nil {
		t.Fatalf("marshal body: %v", err)
	}
	resp, err := http.Post(appURL+path, "application/json", bytes.NewReader(isi))
	if err != nil {
		t.Fatalf("POST %s: %v", path, err)
	}
	t.Cleanup(func() { resp.Body.Close() })
	return resp
}

// getJSON mengirim GET dan mengurai hasilnya ke target.
func getJSON(t *testing.T, path string, target any) *http.Response {
	t.Helper()
	resp, err := http.Get(appURL + path)
	if err != nil {
		t.Fatalf("GET %s: %v", path, err)
	}
	t.Cleanup(func() { resp.Body.Close() })
	if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
		t.Fatalf("decode respons GET %s: %v", path, err)
	}
	return resp
}

func TestHealthz(t *testing.T) {
	var body struct {
		Status string `json:"status"`
	}
	resp := getJSON(t, "/healthz", &body)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, ingin 200", resp.StatusCode)
	}
	if body.Status != "ok" {
		t.Fatalf("status body = %q, ingin \"ok\"", body.Status)
	}
}

func TestCreateListGetItem(t *testing.T) {
	resetItems(t)

	t.Run("create", func(t *testing.T) {
		resp := postJSON(t, "/api/items", map[string]string{
			"name": "Kopi Torabika",
			"note": "untuk pengujian",
		})
		if resp.StatusCode != http.StatusCreated {
			t.Fatalf("status = %d, ingin 201", resp.StatusCode)
		}
		var it struct {
			ID   int64  `json:"id"`
			Name string `json:"name"`
			Note string `json:"note"`
		}
		if err := json.NewDecoder(resp.Body).Decode(&it); err != nil {
			t.Fatalf("decode item: %v", err)
		}
		if it.ID != 1 {
			t.Fatalf("id = %d, ingin 1 (tabel baru di-reset)", it.ID)
		}
		if it.Name != "Kopi Torabika" {
			t.Fatalf("name = %q, ingin \"Kopi Torabika\"", it.Name)
		}
	})

	t.Run("list", func(t *testing.T) {
		var items []struct {
			ID   int64  `json:"id"`
			Name string `json:"name"`
		}
		resp := getJSON(t, "/api/items", &items)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("status = %d, ingin 200", resp.StatusCode)
		}
		if len(items) != 1 || items[0].Name != "Kopi Torabika" {
			t.Fatalf("list = %+v, ingin satu item Kopi Torabika", items)
		}
	})

	t.Run("get", func(t *testing.T) {
		var it struct {
			ID   int64  `json:"id"`
			Name string `json:"name"`
			Note string `json:"note"`
		}
		resp := getJSON(t, "/api/items/1", &it)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("status = %d, ingin 200", resp.StatusCode)
		}
		if it.Name != "Kopi Torabika" || it.Note != "untuk pengujian" {
			t.Fatalf("item = %+v, name/note tidak sesuai", it)
		}
	})
}

func TestGetItemNotFound(t *testing.T) {
	resetItems(t)
	var body map[string]string
	resp := getJSON(t, "/api/items/999", &body)
	if resp.StatusCode != http.StatusNotFound {
		t.Fatalf("status = %d, ingin 404", resp.StatusCode)
	}
	if body["error"] == "" {
		t.Fatal("respons 404 seharusnya memuat pesan error")
	}
}

func TestCreateItemValidasi(t *testing.T) {
	resetItems(t)

	t.Run("nama kosong", func(t *testing.T) {
		resp := postJSON(t, "/api/items", map[string]string{
			"name": "",
			"note": "",
		})
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("status = %d, ingin 400", resp.StatusCode)
		}
	})

	t.Run("catatan terlalu panjang", func(t *testing.T) {
		resp := postJSON(t, "/api/items", map[string]string{
			"name": "Kopi Gayo",
			"note": strings.Repeat("x", 201),
		})
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("status = %d, ingin 400", resp.StatusCode)
		}
	})
}