Setiap layanan yang sehat dimulai dari domain yang jernih, bukan dari endpoint. Sebelum menulis handler HTTP atau membuka koneksi database, kita harus bisa menjawab tiga pertanyaan: data apa yang disimpan layanan ini, operasi apa yang sah atas data tersebut, dan galat apa saja yang mungkin terjadi. Bagian ini merancang domain URL shortener — pemetaan kode pendek ke URL asli beserta statistik kliknya — dalam bentuk struct dan interface murni yang bebas dari detail transport maupun penyimpanan.

Entitas Inti: URLRecord #

Seluruh layanan berputar pada satu entitas. Pemetaan disimpan sebagai URLRecord di repository.go:

go
// URLRecord adalah pemetaan satu short code ke URL asli beserta
// statistik kliknya — inti domain layanan pemendek URL.
type URLRecord struct {
        Code        string    `json:"code"`
        OriginalURL string    `json:"original_url"`
        Clicks      int64     `json:"clicks"`
        CreatedAt   time.Time `json:"created_at"`
}

Mari kita telaah keputusan desain pada struct ini:

  1. Code menjadi pengenal publik layanan: tujuh karakter Base62 yang dicetak pada URL seperti https://short.example.com/b1Gk0D3.
  2. OriginalURL menyimpan tujuan asli pengalihan tanpa normalisasi tambahan — validasi skema terjadi di service, bukan di struct.
  3. Clicks bertipe int64 karena kolom database juga BIGINT: jumlah klik bisa tumbuh melewati int32 pada layanan yang ramai.
  4. CreatedAt memakai time.Time UTC dan hanya bersifat informasional; urutan pembuatan tidak memengaruhi perilaku layanan.

Galat Domain Sebagai Sentinel #

Domain mendeklarasikan dua galat acuan di repository.go:

go
// ErrNotFound menandai short code yang tidak ada di penyimpanan.
var ErrNotFound = errors.New("pemetaan tidak ditemukan")

// ErrCodeExists menandai short code yang sudah dipakai pemetaan lain.
var ErrCodeExists = errors.New("short code sudah dipakai")

Dua sentinel ini menjadi kosakata bersama lintas lapisan. Repository PostgreSQL menerjemahkan pelanggaran primary key menjadi ErrCodeExists; handler HTTP menerjemahkan ErrNotFound menjadi status 404. Karena keduanya dibandingkan dengan errors.Is menembus rantai pembungkusan, tidak ada lapisan yang perlu mengenal implementasi penyimpanan — pola yang sudah kita bahas tuntas pada chapter Sentinel Error .

Kontrak Penyimpanan: URLRepository #

Domain tidak peduli data tinggal di PostgreSQL, di memori, atau di berkas. Kontraknya diungkapkan sebagai interface:

go
type URLRepository interface {
        // Save menyimpan pemetaan baru; mengembalikan ErrCodeExists bila
        // kode sudah dipakai.
        Save(ctx context.Context, rec URLRecord) error
        // FindByCode mengambil satu pemetaan beserta statistiknya.
        FindByCode(ctx context.Context, code string) (URLRecord, error)
        // AddClick menaikkan klik satu kali dan mengembalikan pemetaan
        // terbaru dalam satu operasi penyimpanan.
        AddClick(ctx context.Context, code string) (URLRecord, error)
}

Rancangan interface ini sengaja sempit: tiga method, semua menerima context.Context agar setiap operasi penyimpanan dapat dibatalkan. AddClick mengembalikan URLRecord terbaru sehingga penambahan klik dan pembacaan statistik menjadi satu operasi atomik di sisi penyimpanan — detail yang penting agar dua redirect bersamaan tidak kehilangan hitungan. Interface kecil seperti ini adalah wujud prinsip pada chapter Kecilnya Interface : kontrak didefinisikan di sisi pemakai, yaitu service.

Abstraksi Pendukung: Clock dan CodeGenerator #

Pengujian deterministik menuntut dua sumber ketidakpastian disuntik dari luar: waktu dan keacakan. service.go mendefinisikan dua interface kecil untuk itu:

go
type Clock interface { Now() time.Time }
type CodeGenerator interface { NewCode() (string, error) }

Produksi menginjeksi RealClock yang membaca time.Now().UTC(), sementara unit test dan mode demo menginjeksi FixedClock dengan time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC) — pola beku yang menjamin output created_at identik antar eksekusi. CodeGenerator mengikuti pola yang sama: produksi memakai generator Base62 acak, test memakai stub berurutan sehingga kode yang dihasilkan dapat diprediksi. Kedua injeksi ini mengikuti prinsip Dependency Inversion tanpa framework apa pun.

Dengan domain selesai — entitas, galat, kontrak penyimpanan, dan dua abstraksi pendukung — fondasi layanan sudah berdiri dan dapat diuji tanpa satu baris pun kode HTTP atau SQL. Bagian selanjutnya mengisi salah satu abstraksi tersebut: algoritma pembuatan short code yang unik dan ringkas.

Referensi #

Berkas Sumber

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

compose.yaml

Unduh berkas
yaml
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: urls
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d urls"]
      interval: 5s
      timeout: 3s
      retries: 10
    volumes:
      - pgdata:/var/lib/postgresql/data

  api:
    build: .
    environment:
      APP_PORT: "8080"
      APP_BASE_URL: "http://localhost:8080"
      DATABASE_URL: postgres://user:pass@db:5432/urls?sslmode=disable
    ports:
      - "8080:8080"
    depends_on:
      db:
        condition: service_healthy

volumes:
  pgdata:

config_test.go

Unduh berkas
go
package main

import (
	"os"
	"strings"
	"testing"
)

const dsnUji = "postgres://user:pass@localhost:5432/urls?sslmode=disable"

// kosongkanEnv menghapus variabel lingkungan name selama test berjalan
// lalu memulihkan nilainya setelah test selesai. t.Setenv mendaftarkan
// pemulihannya; os.Unsetenv melakukan penghapusan sesungguhnya.
func kosongkanEnv(t *testing.T, name string) {
	t.Helper()
	t.Setenv(name, "")
	os.Unsetenv(name)
}

func TestLoadNilaiDefault(t *testing.T) {
	t.Setenv("DATABASE_URL", dsnUji)

	cfg, err := Load()
	if err != nil {
		t.Fatalf("Load() galat tak terduga: %v", err)
	}
	if cfg.Port != "8080" {
		t.Errorf("Port = %q, ingin default \"8080\"", cfg.Port)
	}
	if cfg.BaseURL != "http://localhost:8080" {
		t.Errorf("BaseURL = %q, ingin default http://localhost:8080", cfg.BaseURL)
	}
}

func TestLoadDatabaseURLKosong(t *testing.T) {
	kosongkanEnv(t, "DATABASE_URL")
	kosongkanEnv(t, "TEST_DATABASE_URL")

	cfg, err := Load()
	if err == nil {
		t.Fatal("Load() ingin galat saat DATABASE_URL kosong")
	}
	if cfg.DatabaseURL != "" {
		t.Errorf("DatabaseURL = %q, ingin kosong", cfg.DatabaseURL)
	}
	if !strings.Contains(err.Error(), "DATABASE_URL") {
		t.Errorf("pesan galat %q tidak menyebut DATABASE_URL", err.Error())
	}
}

func TestLoadBaseURLTidakValid(t *testing.T) {
	t.Setenv("DATABASE_URL", dsnUji)

	t.Setenv("APP_BASE_URL", "ftp://short.example.com")
	if _, err := Load(); err == nil {
		t.Error("APP_BASE_URL dengan skema ftp ingin galat, tetapi Load() sukses")
	}

	t.Setenv("APP_BASE_URL", "short.example.com")
	if _, err := Load(); err == nil {
		t.Error("APP_BASE_URL tanpa skema ingin galat, tetapi Load() sukses")
	}
}

func TestLoadUtamakanTestDatabaseURL(t *testing.T) {
	t.Setenv("DATABASE_URL", "postgres://user:pass@db-produksi/urls")
	t.Setenv("TEST_DATABASE_URL", "postgres://user:pass@db-uji/urls_test")

	cfg, err := Load()
	if err != nil {
		t.Fatalf("Load() galat tak terduga: %v", err)
	}
	if cfg.DatabaseURL != "postgres://user:pass@db-uji/urls_test" {
		t.Errorf("DatabaseURL = %q, ingin TEST_DATABASE_URL diutamakan", cfg.DatabaseURL)
	}
}

config.go

Unduh berkas
go
package main

import (
	"errors"
	"fmt"
	"net/url"
	"os"
	"strconv"
	"strings"
)

// Config menyimpan seluruh pengaturan layanan pemendek URL.
type Config struct {
	// Port adalah nomor port TCP tempat server HTTP mendengarkan.
	Port string
	// BaseURL adalah alamat publik layanan, misalnya https://short.example.com.
	// Short URL dibangun dari nilai ini.
	BaseURL string
	// DatabaseURL adalah connection string PostgreSQL.
	DatabaseURL string
}

// Nilai default ketika environment variable tidak disetel.
const (
	defaultPort    = "8080"
	defaultBaseURL = "http://localhost:8080"
)

// Load membaca environment variable, menerapkan default, lalu
// memvalidasi hasilnya secara fail-fast sebelum server dibangun.
func Load() (Config, error) {
	cfg := Config{
		Port:        envOr("APP_PORT", defaultPort),
		BaseURL:     strings.TrimSuffix(envOr("APP_BASE_URL", defaultBaseURL), "/"),
		DatabaseURL: firstNonEmpty(os.Getenv("TEST_DATABASE_URL"), os.Getenv("DATABASE_URL")),
	}

	if cfg.DatabaseURL == "" {
		return cfg, errors.New("DATABASE_URL kosong: setel misalnya DATABASE_URL=postgres://user:pass@localhost:5432/urls?sslmode=disable")
	}
	if err := cekPort(cfg.Port); err != nil {
		return cfg, err
	}
	if err := cekBaseURL(cfg.BaseURL); err != nil {
		return cfg, err
	}
	return cfg, nil
}

// envOr mengembalikan nilai environment variable name, atau fallback
// ketika variabel tersebut tidak disetel atau kosong.
func envOr(name, fallback string) string {
	if v := os.Getenv(name); v != "" {
		return v
	}
	return fallback
}

// firstNonEmpty mengembalikan string pertama yang tidak kosong.
// TEST_DATABASE_URL diutamakan agar integration test bisa mengarah
// ke database khusus tanpa mengubah DATABASE_URL produksi.
func firstNonEmpty(values ...string) string {
	for _, v := range values {
		if v != "" {
			return v
		}
	}
	return ""
}

// cekPort memastikan port berupa bilangan bulat antara 1 dan 65535.
func cekPort(port string) error {
	n, err := strconv.Atoi(port)
	if err != nil {
		return fmt.Errorf("APP_PORT tidak valid: %q bukan bilangan bulat", port)
	}
	if n < 1 || n > 65535 {
		return fmt.Errorf("APP_PORT tidak valid: %d di luar rentang 1-65535", n)
	}
	return nil
}

// cekBaseURL memastikan BaseURL bisa diparse dan memakai skema http
// atau https; short URL yang dibangun dari nilai lain akan rusak.
func cekBaseURL(base string) error {
	u, err := url.Parse(base)
	if err != nil {
		return fmt.Errorf("APP_BASE_URL tidak valid: %v", err)
	}
	if u.Scheme != "http" && u.Scheme != "https" {
		return fmt.Errorf("APP_BASE_URL tidak valid: skema harus http atau https, didapat %q", u.Scheme)
	}
	if u.Host == "" {
		return errors.New("APP_BASE_URL tidak valid: host kosong")
	}
	return nil
}

database.go

Unduh berkas
go
package main

import (
	"context"
	"database/sql"
	"embed"
	"fmt"
	"io/fs"
	"path/filepath"
	"strings"
	"time"

	// Blank import mendaftarkan driver "pgx" ke database/sql.
	_ "github.com/jackc/pgx/v5/stdlib"
)

// migrationFS menyimpan seluruh berkas SQL di folder migrations ke
// dalam binary, sehingga image Docker tidak perlu menyalin folder ini.
//
//go:embed migrations/*.sql
var migrationFS embed.FS

// OpenDatabase membuka pool koneksi PostgreSQL lalu memastikan
// database benar-benar merespons lewat PingContext. sql.Open bersifat
// malas: tanpa ping, galat koneksi baru muncul saat query pertama.
func OpenDatabase(ctx context.Context, dsn string) (*sql.DB, error) {
	db, err := sql.Open("pgx", dsn)
	if err != nil {
		return nil, fmt.Errorf("membuka koneksi: %w", err)
	}

	pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	if err := db.PingContext(pingCtx); err != nil {
		db.Close()
		return nil, fmt.Errorf("ping database: %w", err)
	}
	return db, nil
}

// RunMigrations menjalankan seluruh berkas migrations/*.sql yang belum
// diterapkan. Versi tercatat di tabel schema_migrations sehingga
// menjalankan migration dua kali aman (idempoten).
func RunMigrations(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)
	}

	files, err := fs.Glob(migrationFS, "migrations/*.sql")
	if err != nil {
		return fmt.Errorf("membaca daftar migration: %w", err)
	}

	for _, name := range files {
		version := strings.TrimSuffix(filepath.Base(name), ".sql")

		var sudah bool
		err := db.QueryRowContext(ctx,
			`SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`,
			version,
		).Scan(&sudah)
		if err != nil {
			return fmt.Errorf("memeriksa migration %s: %w", version, err)
		}
		if sudah {
			continue
		}

		body, err := migrationFS.ReadFile(name)
		if err != nil {
			return fmt.Errorf("membaca berkas %s: %w", name, err)
		}

		// Satu migration dijalankan dalam satu transaksi bersama
		// pencatatannya, sehingga skema dan daftar versi selalu
		// sejalan walau proses berhenti di tengah jalan.
		tx, err := db.BeginTx(ctx, nil)
		if err != nil {
			return fmt.Errorf("memulai transaksi migration %s: %w", version, err)
		}
		if _, err := tx.ExecContext(ctx, string(body)); err != nil {
			tx.Rollback()
			return fmt.Errorf("menjalankan migration %s: %w", version, err)
		}
		if _, err := tx.ExecContext(ctx,
			`INSERT INTO schema_migrations (version) VALUES ($1)`, version,
		); err != nil {
			tx.Rollback()
			return fmt.Errorf("mencatat migration %s: %w", version, err)
		}
		if err := tx.Commit(); err != nil {
			return fmt.Errorf("mengcommit migration %s: %w", version, err)
		}
	}
	return nil
}

demo.go

Unduh berkas
go
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"time"
)

// waktuDemo adalah waktu beku supaya output demo selalu identik.
var waktuDemo = time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC)

// genDemo mengeluarkan kode dari daftar tetap secara bergilir —
// deterministik untuk demo (produksi memakai Base62Generator acak).
type genDemo struct {
	daftar []string
	i      int
}

// NewCode memenuhi interface CodeGenerator.
func (g *genDemo) NewCode() (string, error) {
	code := g.daftar[g.i%len(g.daftar)]
	g.i++
	return code, nil
}

// demo menjalankan alur lengkap layanan di atas MemoryURLRepository
// tanpa PostgreSQL dan tanpa membuka port: shorten → redirect (dua
// kali) → stats → galat 404. Gunakan `go run . demo` untuk mencoba.
func demo() {
	gen := &genDemo{daftar: []string{"b1Gk0D3", "zP9xQ2w"}}
	svc := NewShortenService(NewMemoryURLRepository(), gen, FixedClock{Waktu: waktuDemo})
	router := NewRouter(svc, "http://short.test")

	fmt.Println("=== Demo URL shortener (mode memori, tanpa PostgreSQL) ===")
	kirimDemo(router, "POST", "/shorten", `{"url":"https://example.com/tutorial-go-yang-sangat-panjang"}`)
	kirimDemo(router, "POST", "/shorten", `{"url":"bukan-url-lengkap"}`)
	kirimDemo(router, "GET", "/b1Gk0D3", "")
	kirimDemo(router, "GET", "/b1Gk0D3", "")
	kirimDemo(router, "GET", "/stats/b1Gk0D3", "")
	kirimDemo(router, "GET", "/stats/takDikn", "")
}

// kirimDemo mengirim satu permintaan ke router lewat recorder, lalu
// mencetak status, header Location, dan body respons.
func kirimDemo(router http.Handler, metode, target, body string) {
	var rdr io.Reader
	if body != "" {
		rdr = strings.NewReader(body)
	}
	req := httptest.NewRequest(metode, target, rdr)
	if body != "" {
		req.Header.Set("Content-Type", "application/json")
	}
	rec := httptest.NewRecorder()
	router.ServeHTTP(rec, req)

	fmt.Printf("%s %s → %d %s\n", metode, target, rec.Code, http.StatusText(rec.Code))
	if loc := rec.Header().Get("Location"); loc != "" {
		fmt.Printf("  Location: %s\n", loc)
	}
	if b := strings.TrimSpace(rec.Body.String()); b != "" {
		fmt.Printf("  body: %s\n", b)
	}
}

handler_test.go

Unduh berkas
go
package main

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"
)

// klienTanpaIkut redirect membiarkan respons 302 sampai ke test
// alih-alih diikuti otomatis oleh http.Client.
var klienTanpaIkut = &http.Client{
	CheckRedirect: func(req *http.Request, via []*http.Request) error {
		return http.ErrUseLastResponse
	},
}

// serverUji membangun layanan lengkap di atas MemoryURLRepository
// dengan generator stub dan waktu beku, lalu menjalankannya di
// httptest.Server.
func serverUji() *httptest.Server {
	gen := &genStub{daftar: []string{"b1Gk0D3"}}
	svc := NewShortenService(NewMemoryURLRepository(), gen, FixedClock{Waktu: waktuUji})
	return httptest.NewServer(NewRouter(svc, "http://short.test/"))
}

func TestAlurShortenRedirectStats(t *testing.T) {
	ts := serverUji()
	defer ts.Close()

	resp, err := http.Post(ts.URL+"/shorten", "application/json",
		strings.NewReader(`{"url":"https://example.com/tutorial-go"}`))
	if err != nil {
		t.Fatalf("POST /shorten: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("POST /shorten status = %d, ingin 201", resp.StatusCode)
	}
	if loc := resp.Header.Get("Location"); loc != "http://short.test/b1Gk0D3" {
		t.Errorf("Location = %q, ingin short URL", loc)
	}
	var balas struct {
		Code     string `json:"code"`
		URL      string `json:"url"`
		ShortURL string `json:"short_url"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&balas); err != nil {
		t.Fatalf("mendekode body: %v", err)
	}
	if balas.Code != "b1Gk0D3" || balas.URL != "https://example.com/tutorial-go" {
		t.Errorf("balasan = %+v, tidak sesuai masukan", balas)
	}
	if balas.ShortURL != "http://short.test/b1Gk0D3" {
		t.Errorf("short_url = %q, ingin http://short.test/b1Gk0D3", balas.ShortURL)
	}

	// Dua redirect: masing-masing 302 + Location URL asli.
	for i := 1; i <= 2; i++ {
		resp2, err := klienTanpaIkut.Get(ts.URL + "/b1Gk0D3")
		if err != nil {
			t.Fatalf("GET /b1Gk0D3 ke-%d: %v", i, err)
		}
		resp2.Body.Close()
		if resp2.StatusCode != http.StatusFound {
			t.Fatalf("redirect ke-%d status = %d, ingin 302", i, resp2.StatusCode)
		}
		if loc := resp2.Header.Get("Location"); loc != "https://example.com/tutorial-go" {
			t.Errorf("redirect ke-%d Location = %q, ingin URL asli", i, loc)
		}
	}

	resp3, err := http.Get(ts.URL + "/stats/b1Gk0D3")
	if err != nil {
		t.Fatalf("GET /stats/b1Gk0D3: %v", err)
	}
	defer resp3.Body.Close()
	if resp3.StatusCode != http.StatusOK {
		t.Fatalf("GET /stats status = %d, ingin 200", resp3.StatusCode)
	}
	var stat struct {
		Code      string    `json:"code"`
		URL       string    `json:"url"`
		Clicks    int64     `json:"clicks"`
		CreatedAt time.Time `json:"created_at"`
	}
	if err := json.NewDecoder(resp3.Body).Decode(&stat); err != nil {
		t.Fatalf("mendekode body stats: %v", err)
	}
	if stat.Clicks != 2 {
		t.Errorf("klik = %d, ingin 2 setelah dua redirect", stat.Clicks)
	}
	if !stat.CreatedAt.Equal(waktuUji) {
		t.Errorf("created_at = %v, ingin %v (waktu beku)", stat.CreatedAt, waktuUji)
	}
}

func TestShortenBodyTidakValid(t *testing.T) {
	ts := serverUji()
	defer ts.Close()

	resp, err := http.Post(ts.URL+"/shorten", "text/plain", strings.NewReader(`{}`))
	if err != nil {
		t.Fatalf("POST text/plain: %v", err)
	}
	resp.Body.Close()
	if resp.StatusCode != http.StatusUnsupportedMediaType {
		t.Errorf("Content-Type salah status = %d, ingin 415", resp.StatusCode)
	}

	resp2, err := http.Post(ts.URL+"/shorten", "application/json", strings.NewReader(`bukan json`))
	if err != nil {
		t.Fatalf("POST JSON rusak: %v", err)
	}
	resp2.Body.Close()
	if resp2.StatusCode != http.StatusBadRequest {
		t.Errorf("JSON rusak status = %d, ingin 400", resp2.StatusCode)
	}

	resp3, err := http.Post(ts.URL+"/shorten", "application/json",
		strings.NewReader(`{"url":"example.com/tanpa-skema"}`))
	if err != nil {
		t.Fatalf("POST URL tanpa skema: %v", err)
	}
	defer resp3.Body.Close()
	if resp3.StatusCode != http.StatusBadRequest {
		t.Fatalf("URL tanpa skema status = %d, ingin 400", resp3.StatusCode)
	}
	var env struct {
		Error struct {
			Code   string            `json:"code"`
			Fields map[string]string `json:"fields"`
		} `json:"error"`
	}
	if err := json.NewDecoder(resp3.Body).Decode(&env); err != nil {
		t.Fatalf("mendekode body galat: %v", err)
	}
	if env.Error.Code != "VALIDATION_ERROR" {
		t.Errorf("kode galat = %q, ingin VALIDATION_ERROR", env.Error.Code)
	}
	if env.Error.Fields["url"] == "" {
		t.Error("fields[url] kosong, ingin pesan galat skema")
	}
}

func TestKodeTakDikenal(t *testing.T) {
	ts := serverUji()
	defer ts.Close()

	resp, err := klienTanpaIkut.Get(ts.URL + "/zzzzzzz")
	if err != nil {
		t.Fatalf("GET /zzzzzzz: %v", err)
	}
	resp.Body.Close()
	if resp.StatusCode != http.StatusNotFound {
		t.Errorf("GET kode tak dikenal status = %d, ingin 404", resp.StatusCode)
	}

	resp2, err := http.Get(ts.URL + "/stats/zzzzzzz")
	if err != nil {
		t.Fatalf("GET /stats/zzzzzzz: %v", err)
	}
	defer resp2.Body.Close()
	if resp2.StatusCode != http.StatusNotFound {
		t.Errorf("GET stats kode tak dikenal status = %d, ingin 404", resp2.StatusCode)
	}
}

handler.go

Unduh berkas
go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"mime"
	"net/http"
	"strings"
	"time"
)

// maxBodyBytes membatasi ukuran body JSON permintaan.
const maxBodyBytes = 1 << 20 // 1 MiB

// Kode galat yang stabil untuk konsumen API.
const (
	codeNotFound      = "NOT_FOUND"
	codeValidasi      = "VALIDATION_ERROR"
	codeJSONInvalid   = "INVALID_JSON"
	codeMediaType     = "UNSUPPORTED_MEDIA_TYPE"
	codeBodyKebesaran = "REQUEST_TOO_LARGE"
	codeInternal      = "INTERNAL_ERROR"
)

// errorEnvelope adalah bentuk body galat yang konsisten di seluruh
// endpoint: {"error":{"code":"...","message":"..."}}.
type errorEnvelope struct {
	Error errorBody `json:"error"`
}

type errorBody struct {
	Code    string            `json:"code"`
	Message string            `json:"message"`
	Fields  map[string]string `json:"fields,omitempty"`
}

// galatBody adalah galat transport HTTP yang sudah membawa status dan
// kode galatnya sendiri, misalnya body JSON yang rusak.
type galatBody struct {
	status  int
	code    string
	message string
}

func (e *galatBody) Error() string { return e.message }

// writeJSON menuliskan v sebagai respons JSON dengan status code yang
// diberikan; Content-Type diatur sebelum WriteHeader.
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

// writeError menerjemahkan error domain menjadi respons JSON
// konsisten: ValidationError menjadi 400 dengan peta fields,
// ErrNotFound menjadi 404, galat lain menjadi 500 tanpa detail.
func writeError(w http.ResponseWriter, err error) {
	var ve ValidationError
	if errors.As(err, &ve) {
		writeJSON(w, http.StatusBadRequest, errorEnvelope{Error: errorBody{
			Code:    codeValidasi,
			Message: "masukan tidak lolos validasi",
			Fields:  map[string]string{ve.Field: ve.Message},
		}})
		return
	}
	status, code, message := terjemahkanGalat(err)
	writeJSON(w, status, errorEnvelope{Error: errorBody{Code: code, Message: message}})
}

// terjemahkanGalat memetakan error domain ke status HTTP dan kode
// galat yang aman untuk klien.
func terjemahkanGalat(err error) (status int, code, message string) {
	if errors.Is(err, ErrNotFound) {
		return http.StatusNotFound, codeNotFound, err.Error()
	}
	return http.StatusInternalServerError, codeInternal, "galat internal server"
}

// tulisGalatBody menuliskan galat transport (galatBody) atau galat
// domain sebagai respons JSON.
func tulisGalatBody(w http.ResponseWriter, err error) {
	var gb *galatBody
	if errors.As(err, &gb) {
		writeJSON(w, gb.status, errorEnvelope{Error: errorBody{
			Code:    gb.code,
			Message: gb.message,
		}})
		return
	}
	writeError(w, err)
}

// ShortenHandler menerjemahkan permintaan HTTP menjadi pemanggilan
// ShortenService, lalu hasilnya kembali menjadi respons.
type ShortenHandler struct {
	svc     *ShortenService
	baseURL string
}

// NewRouter membangun ServeMux dengan seluruh pattern layanan.
// Pattern "GET /stats/{code}" lebih spesifik daripada "GET /{code}"
// sehingga ServeMux memilihnya untuk path dua segmen; "POST /shorten"
// tidak bertabrakan dengan "GET /{code}" karena method-nya berbeda.
func NewRouter(svc *ShortenService, baseURL string) http.Handler {
	h := &ShortenHandler{svc: svc, baseURL: strings.TrimSuffix(baseURL, "/")}
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", h.Healthz)
	mux.HandleFunc("POST /shorten", h.Shorten)
	mux.HandleFunc("GET /{code}", h.Redirect)
	mux.HandleFunc("GET /stats/{code}", h.Stats)
	return mux
}

// Healthz melaporkan proses hidup dan siap menerima permintaan.
func (h *ShortenHandler) Healthz(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

// shortenRequest adalah body permintaan POST /shorten.
type shortenRequest struct {
	URL string `json:"url"`
}

// shortenResponse adalah body respons POST /shorten.
type shortenResponse struct {
	Code     string `json:"code"`
	URL      string `json:"url"`
	ShortURL string `json:"short_url"`
}

// Shorten menangani POST /shorten: validasi URL, buat kode, lalu balas
// 201 Created dengan Location menuju short URL.
func (h *ShortenHandler) Shorten(w http.ResponseWriter, r *http.Request) {
	var req shortenRequest
	if err := bacaJSON(w, r, &req); err != nil {
		tulisGalatBody(w, err)
		return
	}
	rec, err := h.svc.Shorten(r.Context(), req.URL)
	if err != nil {
		writeError(w, err)
		return
	}
	shortURL := fmt.Sprintf("%s/%s", h.baseURL, rec.Code)
	w.Header().Set("Location", shortURL)
	writeJSON(w, http.StatusCreated, shortenResponse{
		Code:     rec.Code,
		URL:      rec.OriginalURL,
		ShortURL: shortURL,
	})
}

// Redirect menangani GET /{code}: klik dihitung, lalu klien dialihkan
// ke URL asli dengan 302 Found dan header Location.
func (h *ShortenHandler) Redirect(w http.ResponseWriter, r *http.Request) {
	asli, err := h.svc.Redirect(r.Context(), r.PathValue("code"))
	if err != nil {
		writeError(w, err)
		return
	}
	http.Redirect(w, r, asli, http.StatusFound)
}

// statsBody adalah body respons GET /stats/{code}.
type statsBody struct {
	Code      string    `json:"code"`
	URL       string    `json:"url"`
	Clicks    int64     `json:"clicks"`
	CreatedAt time.Time `json:"created_at"`
}

// Stats menangani GET /stats/{code} dan mengembalikan statistik klik
// tanpa menaikkannya.
func (h *ShortenHandler) Stats(w http.ResponseWriter, r *http.Request) {
	rec, err := h.svc.Stats(r.Context(), r.PathValue("code"))
	if err != nil {
		writeError(w, err)
		return
	}
	writeJSON(w, http.StatusOK, statsBody{
		Code:      rec.Code,
		URL:       rec.OriginalURL,
		Clicks:    rec.Clicks,
		CreatedAt: rec.CreatedAt,
	})
}

// bacaJSON memeriksa Content-Type, membatasi ukuran body, lalu
// mendekode JSON ke dst sambil menolak field yang tidak dikenal.
func bacaJSON(w http.ResponseWriter, r *http.Request, dst any) error {
	ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
	if err != nil || ct != "application/json" {
		return &galatBody{
			status:  http.StatusUnsupportedMediaType,
			code:    codeMediaType,
			message: "Content-Type harus application/json",
		}
	}
	r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	if err := dec.Decode(dst); err != nil {
		var maxErr *http.MaxBytesError
		switch {
		case errors.As(err, &maxErr):
			return &galatBody{
				status:  http.StatusRequestEntityTooLarge,
				code:    codeBodyKebesaran,
				message: fmt.Sprintf("body melebihi batas %d byte", maxErr.Limit),
			}
		case errors.Is(err, io.EOF):
			return &galatBody{
				status:  http.StatusBadRequest,
				code:    codeJSONInvalid,
				message: "body kosong; kirim JSON seperti {\"url\":\"https://example.com\"}",
			}
		default:
			return &galatBody{
				status:  http.StatusBadRequest,
				code:    codeJSONInvalid,
				message: "body bukan JSON valid: " + err.Error(),
			}
		}
	}
	return nil
}

integration_test.go

Unduh berkas
go
package main

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
	"testing"
)

// TestIntegrationShortenRedirectStats menjalankan alur shorten →
// redirect → stats melawan PostgreSQL sungguhan. Tanpa
// TEST_DATABASE_URL, test ini dilewati (skip) supaya `go test ./...`
// tetap lulus di lingkungan offline:
//
//	TEST_DATABASE_URL=postgres://user:pass@localhost:5432/urls?sslmode=disable \
//	  go test -v -run Integration ./...
func TestIntegrationShortenRedirectStats(t *testing.T) {
	dsn := os.Getenv("TEST_DATABASE_URL")
	if dsn == "" {
		t.Skip("TEST_DATABASE_URL kosong: integration test membutuhkan PostgreSQL aktif")
	}

	ctx := context.Background()
	db, err := OpenDatabase(ctx, dsn)
	if err != nil {
		t.Fatalf("membuka database: %v", err)
	}
	defer db.Close()

	if err := RunMigrations(ctx, db); err != nil {
		t.Fatalf("menjalankan migration: %v", err)
	}

	// Bersihkan data test sebelumnya supaya test dapat diulang.
	if _, err := db.ExecContext(ctx, `TRUNCATE urls`); err != nil {
		t.Fatalf("membersihkan tabel urls: %v", err)
	}

	gen := &genStub{daftar: []string{"b1Gk0D3"}}
	svc := NewShortenService(NewPostgresURLRepository(db), gen, RealClock{})
	ts := httptest.NewServer(NewRouter(svc, "http://short.test/"))
	defer ts.Close()

	klien := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
		return http.ErrUseLastResponse
	}}

	resp, err := http.Post(ts.URL+"/shorten", "application/json",
		strings.NewReader(`{"url":"https://example.com/integrasi"}`))
	if err != nil {
		t.Fatalf("POST /shorten: %v", err)
	}
	resp.Body.Close()
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("POST /shorten status = %d, ingin 201", resp.StatusCode)
	}

	for i := 1; i <= 2; i++ {
		resp2, err := klien.Get(ts.URL + "/b1Gk0D3")
		if err != nil {
			t.Fatalf("GET /b1Gk0D3 ke-%d: %v", i, err)
		}
		resp2.Body.Close()
		if resp2.StatusCode != http.StatusFound {
			t.Fatalf("redirect ke-%d status = %d, ingin 302", i, resp2.StatusCode)
		}
		if loc := resp2.Header.Get("Location"); loc != "https://example.com/integrasi" {
			t.Fatalf("redirect ke-%d Location = %q, ingin URL asli", i, loc)
		}
	}

	resp3, err := http.Get(ts.URL + "/stats/b1Gk0D3")
	if err != nil {
		t.Fatalf("GET /stats/b1Gk0D3: %v", err)
	}
	defer resp3.Body.Close()
	if resp3.StatusCode != http.StatusOK {
		t.Fatalf("GET /stats status = %d, ingin 200", resp3.StatusCode)
	}
	var stat struct {
		Clicks int64 `json:"clicks"`
	}
	if err := json.NewDecoder(resp3.Body).Decode(&stat); err != nil {
		t.Fatalf("mendekode body stats: %v", err)
	}
	if stat.Clicks != 2 {
		t.Errorf("klik = %d, ingin 2 setelah dua redirect (UPDATE atomik)", stat.Clicks)
	}
}

main.go

Unduh berkas
go
package main

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

func main() {
	if len(os.Args) > 1 {
		if os.Args[1] == "demo" {
			demo()
			return
		}
		fmt.Fprintln(os.Stderr, "perintah tidak dikenal:", os.Args[1])
		fmt.Fprintln(os.Stderr, "pemakaian: shortener [demo]")
		os.Exit(2)
	}
	if err := run(); err != nil {
		// Pesan ditulis tanpa stempel waktu supaya output tetap
		// deterministik dan mudah dibandingkan.
		fmt.Fprintln(os.Stderr, "galat:", err)
		os.Exit(1)
	}
}

// run merangkai seluruh dependensi: konfigurasi dibaca dan
// divalidasi, pool koneksi dibuka, migration dijalankan, lalu
// repository, generator, service, handler, dan server disusun.
func run() error {
	cfg, err := Load()
	if err != nil {
		return fmt.Errorf("memuat konfigurasi: %w", err)
	}

	ctx := context.Background()
	db, err := OpenDatabase(ctx, cfg.DatabaseURL)
	if err != nil {
		return fmt.Errorf("membuka database: %w", err)
	}
	defer db.Close()

	if err := RunMigrations(ctx, db); err != nil {
		return fmt.Errorf("menjalankan migration: %w", err)
	}

	gen, err := NewBase62Generator()
	if err != nil {
		return fmt.Errorf("menyiapkan generator kode: %w", err)
	}

	svc := NewShortenService(NewPostgresURLRepository(db), gen, RealClock{})
	srv := NewServer(cfg, svc)

	log.Printf("server mendengarkan di :%s (base URL %s)", cfg.Port, cfg.BaseURL)
	return ListenAndServe(srv)
}

// NewServer membangun http.Server tanpa memulai mendengarkan port.
// Pemisahan ini membuat server dapat diuji dengan httptest dan
// diganti-ganti pemanggilnya tanpa menyentuh logika shutdown.
func NewServer(cfg Config, svc *ShortenService) *http.Server {
	return &http.Server{
		Addr:              ":" + cfg.Port,
		Handler:           NewRouter(svc, cfg.BaseURL),
		ReadHeaderTimeout: 5 * time.Second,
	}
}

// ListenAndServe menjalankan server di goroutine, menunggu sinyal
// SIGINT atau SIGTERM, lalu mematikan server secara graceful dengan
// tenggat 10 detik agar permintaan yang sedang diproses selesai.
func ListenAndServe(srv *http.Server) error {
	errCh := make(chan error, 1)
	go func() {
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			errCh <- err
		}
	}()

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	select {
	case err := <-errCh:
		return fmt.Errorf("server gagal berjalan: %w", err)
	case <-ctx.Done():
		log.Println("sinyal berhenti diterima; mematikan server")
	}

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		return fmt.Errorf("graceful shutdown: %w", err)
	}
	return nil
}

migrations/001_create_urls.sql

Unduh berkas
sql
-- 001_create_urls.sql membuat tabel urls: pemetaan code → original_url.
CREATE TABLE IF NOT EXISTS urls (
    code         TEXT PRIMARY KEY,
    original_url TEXT NOT NULL,
    clicks       BIGINT NOT NULL DEFAULT 0 CHECK (clicks >= 0),
    created_at   TIMESTAMPTZ NOT NULL
);

repository.go

Unduh berkas
go
package main

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"sync"
	"time"

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

// ErrNotFound menandai short code yang tidak ada di penyimpanan.
var ErrNotFound = errors.New("pemetaan tidak ditemukan")

// ErrCodeExists menandai short code yang sudah dipakai pemetaan lain.
var ErrCodeExists = errors.New("short code sudah dipakai")

// URLRecord adalah pemetaan satu short code ke URL asli beserta
// statistik kliknya — inti domain layanan pemendek URL.
type URLRecord struct {
	Code        string    `json:"code"`
	OriginalURL string    `json:"original_url"`
	Clicks      int64     `json:"clicks"`
	CreatedAt   time.Time `json:"created_at"`
}

// URLRepository adalah kontrak penyimpanan pemetaan URL. Implementasi
// PostgreSQL dipakai di produksi; implementasi memori dipakai untuk
// unit test dan mode demo.
type URLRepository interface {
	// Save menyimpan pemetaan baru; mengembalikan ErrCodeExists bila
	// kode sudah dipakai.
	Save(ctx context.Context, rec URLRecord) error
	// FindByCode mengambil satu pemetaan beserta statistiknya.
	FindByCode(ctx context.Context, code string) (URLRecord, error)
	// AddClick menaikkan klik satu kali dan mengembalikan pemetaan
	// terbaru dalam satu operasi penyimpanan.
	AddClick(ctx context.Context, code string) (URLRecord, error)
}

// PostgresURLRepository menyimpan pemetaan URL di PostgreSQL.
type PostgresURLRepository struct {
	db *sql.DB
}

// NewPostgresURLRepository membangun repository di atas pool *sql.DB.
func NewPostgresURLRepository(db *sql.DB) *PostgresURLRepository {
	return &PostgresURLRepository{db: db}
}

// Save memasukkan pemetaan baru. Pelanggaran primary key
// (unique_violation, kode SQL 23505) diterjemahkan menjadi
// ErrCodeExists sehingga service cukup memeriksa sentinel error.
func (r *PostgresURLRepository) Save(ctx context.Context, rec URLRecord) error {
	_, err := r.db.ExecContext(ctx,
		`INSERT INTO urls (code, original_url, created_at) VALUES ($1, $2, $3)`,
		rec.Code, rec.OriginalURL, rec.CreatedAt)
	if err != nil {
		var pgErr *pgconn.PgError
		if errors.As(err, &pgErr) && pgErr.Code == "23505" {
			return fmt.Errorf("kode %s: %w", rec.Code, ErrCodeExists)
		}
		return fmt.Errorf("menyimpan urls: %w", err)
	}
	return nil
}

// FindByCode membaca satu pemetaan; baris kosong menjadi ErrNotFound.
func (r *PostgresURLRepository) FindByCode(ctx context.Context, code string) (URLRecord, error) {
	var rec URLRecord
	err := r.db.QueryRowContext(ctx,
		`SELECT code, original_url, clicks, created_at FROM urls WHERE code = $1`, code,
	).Scan(&rec.Code, &rec.OriginalURL, &rec.Clicks, &rec.CreatedAt)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return URLRecord{}, fmt.Errorf("kode %s: %w", code, ErrNotFound)
		}
		return URLRecord{}, fmt.Errorf("membaca urls: %w", err)
	}
	return rec, nil
}

// AddClick menaikkan klik dan mengembalikan baris terbaru dalam satu
// round trip lewat RETURNING. UPDATE berjalan atomik di database,
// sehingga dua klik bersamaan tidak bisa saling menimpa.
func (r *PostgresURLRepository) AddClick(ctx context.Context, code string) (URLRecord, error) {
	var rec URLRecord
	err := r.db.QueryRowContext(ctx,
		`UPDATE urls SET clicks = clicks + 1 WHERE code = $1
		RETURNING code, original_url, clicks, created_at`, code,
	).Scan(&rec.Code, &rec.OriginalURL, &rec.Clicks, &rec.CreatedAt)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return URLRecord{}, fmt.Errorf("kode %s: %w", code, ErrNotFound)
		}
		return URLRecord{}, fmt.Errorf("menaikkan klik: %w", err)
	}
	return rec, nil
}

// MemoryURLRepository menyimpan pemetaan di map dengan mutex untuk
// unit test dan mode demo. Perilakunya meniru implementasi
// PostgreSQL, termasuk ErrCodeExists dan ErrNotFound.
type MemoryURLRepository struct {
	mu   sync.Mutex
	urls map[string]URLRecord
}

// NewMemoryURLRepository membuat penyimpanan kosong.
func NewMemoryURLRepository() *MemoryURLRepository {
	return &MemoryURLRepository{urls: make(map[string]URLRecord)}
}

func (r *MemoryURLRepository) Save(ctx context.Context, rec URLRecord) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ada := r.urls[rec.Code]; ada {
		return fmt.Errorf("kode %s: %w", rec.Code, ErrCodeExists)
	}
	r.urls[rec.Code] = rec
	return nil
}

func (r *MemoryURLRepository) FindByCode(ctx context.Context, code string) (URLRecord, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	rec, ada := r.urls[code]
	if !ada {
		return URLRecord{}, fmt.Errorf("kode %s: %w", code, ErrNotFound)
	}
	return rec, nil
}

// AddClick mengunci map, menaikkan klik, lalu mengembalikan salinan —
// padanan atomik dari UPDATE ... RETURNING versi SQL.
func (r *MemoryURLRepository) AddClick(ctx context.Context, code string) (URLRecord, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	rec, ada := r.urls[code]
	if !ada {
		return URLRecord{}, fmt.Errorf("kode %s: %w", code, ErrNotFound)
	}
	rec.Clicks++
	r.urls[code] = rec
	return rec, nil
}

service_test.go

Unduh berkas
go
package main

import (
	"context"
	"errors"
	"testing"
	"time"
)

// waktuUji adalah waktu beku untuk seluruh unit test.
var waktuUji = time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC)

// genStub mengeluarkan kode dari daftar tetap secara bergilir.
type genStub struct {
	daftar []string
	i      int
}

// NewCode memenuhi interface CodeGenerator.
func (g *genStub) NewCode() (string, error) {
	code := g.daftar[g.i%len(g.daftar)]
	g.i++
	return code, nil
}

// genGagal selalu mengembalikan galat dari generator.
type genGagal struct{}

// NewCode selalu gagal — untuk menguji jalur galat generator.
func (genGagal) NewCode() (string, error) {
	return "", errors.New("entropi habis")
}

func TestValidasiURL(t *testing.T) {
	uji := []struct {
		nama string
		url  string
		ok   bool
	}{
		{"url kosong", "", false},
		{"tanpa skema", "example.com/halaman", false},
		{"skema ftp", "ftp://example.com/berkas", false},
		{"host kosong", "http:///path", false},
		{"http valid", "http://example.com/halaman?q=1", true},
		{"https valid", "https://go.dev/doc/", true},
	}
	for _, tt := range uji {
		t.Run(tt.nama, func(t *testing.T) {
			err := ValidasiURL(tt.url)
			if tt.ok && err != nil {
				t.Fatalf("ValidasiURL(%q) galat tak terduga: %v", tt.url, err)
			}
			if !tt.ok {
				var ve ValidationError
				if !errors.As(err, &ve) {
					t.Fatalf("ValidasiURL(%q) ingin ValidationError, dapat %v", tt.url, err)
				}
				if ve.Field != "url" {
					t.Errorf("field galat = %q, ingin \"url\"", ve.Field)
				}
			}
		})
	}
}

func TestShortenKonflikRetry(t *testing.T) {
	repo := NewMemoryURLRepository()
	ctx := context.Background()

	// Kode pertama dari generator sudah dipakai pemetaan lain.
	err := repo.Save(ctx, URLRecord{Code: "aaaaaaa", OriginalURL: "https://lama.example.com", CreatedAt: waktuUji})
	if err != nil {
		t.Fatalf("menyiapkan data: %v", err)
	}

	gen := &genStub{daftar: []string{"aaaaaaa", "aaaaaaa", "bbbbbbb"}}
	svc := NewShortenService(repo, gen, FixedClock{Waktu: waktuUji})

	rec, err := svc.Shorten(ctx, "https://baru.example.com/halaman")
	if err != nil {
		t.Fatalf("Shorten() galat tak terduga: %v", err)
	}
	if rec.Code != "bbbbbbb" {
		t.Errorf("kode = %q, ingin \"bbbbbbb\" setelah dua kali bentrok", rec.Code)
	}
	if !rec.CreatedAt.Equal(waktuUji) {
		t.Errorf("created_at = %v, ingin %v (waktu beku)", rec.CreatedAt, waktuUji)
	}
}

func TestShortenKodeHabis(t *testing.T) {
	repo := NewMemoryURLRepository()
	ctx := context.Background()

	_ = repo.Save(ctx, URLRecord{Code: "aaaaaaa", OriginalURL: "https://lama.example.com", CreatedAt: waktuUji})
	gen := &genStub{daftar: []string{"aaaaaaa"}} // selalu bentrok
	svc := NewShortenService(repo, gen, FixedClock{Waktu: waktuUji})

	_, err := svc.Shorten(ctx, "https://baru.example.com")
	if !errors.Is(err, ErrCodeExists) {
		t.Fatalf("Shorten() ingin ErrCodeExists setelah percobaan habis, dapat %v", err)
	}
}

func TestShortenGeneratorGagal(t *testing.T) {
	svc := NewShortenService(NewMemoryURLRepository(), genGagal{}, FixedClock{Waktu: waktuUji})
	_, err := svc.Shorten(context.Background(), "https://example.com")
	if err == nil {
		t.Fatal("Shorten() ingin galat ketika generator gagal")
	}
}

func TestRedirectMenaikkanKlik(t *testing.T) {
	repo := NewMemoryURLRepository()
	ctx := context.Background()

	gen := &genStub{daftar: []string{"b1Gk0D3"}}
	svc := NewShortenService(repo, gen, FixedClock{Waktu: waktuUji})

	rec, err := svc.Shorten(ctx, "https://example.com/tutorial")
	if err != nil {
		t.Fatalf("Shorten(): %v", err)
	}

	for i := 1; i <= 2; i++ {
		asli, err := svc.Redirect(ctx, rec.Code)
		if err != nil {
			t.Fatalf("Redirect() ke-%d: %v", i, err)
		}
		if asli != "https://example.com/tutorial" {
			t.Errorf("Redirect() ke-%d = %q, ingin URL asli", i, asli)
		}
	}

	stat, err := svc.Stats(ctx, rec.Code)
	if err != nil {
		t.Fatalf("Stats(): %v", err)
	}
	if stat.Clicks != 2 {
		t.Errorf("klik = %d, ingin 2 setelah dua redirect", stat.Clicks)
	}
}

func TestRedirectKodeTakDikenal(t *testing.T) {
	svc := NewShortenService(NewMemoryURLRepository(), &genStub{daftar: []string{"b1Gk0D3"}}, FixedClock{Waktu: waktuUji})
	_, err := svc.Redirect(context.Background(), "takDikn")
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("Redirect() ingin ErrNotFound, dapat %v", err)
	}
}

service.go

Unduh berkas
go
package main

import (
	"context"
	cryptorand "crypto/rand"
	"encoding/binary"
	"errors"
	"fmt"
	"math/rand/v2"
	"net/url"
	"sync"
	"time"
)

// Aturan domain layanan pemendek URL.
const (
	// panjangKode adalah jumlah karakter short code produksi.
	panjangKode = 7
	// maxPercobaanKode membatasi retry pembuatan kode saat bentrok.
	maxPercobaanKode = 5
	// maxPanjangURL membatasi URL asli yang diterima.
	maxPanjangURL = 2048
)

// ValidationError menjelaskan satu field masukan yang melanggar
// aturan domain.
type ValidationError struct {
	Field   string
	Message string
}

// Error memenuhi kontrak interface error.
func (e ValidationError) Error() string {
	return fmt.Sprintf("field %s: %s", e.Field, e.Message)
}

// Clock memisahkan sumber waktu dari logika bisnis. Produksi memakai
// RealClock; demo dan unit test memakai FixedClock sehingga nilai
// created_at selalu deterministik.
type Clock interface {
	Now() time.Time
}

// RealClock mengembalikan waktu sistem dalam zona UTC.
type RealClock struct{}

// Now memenuhi interface Clock.
func (RealClock) Now() time.Time { return time.Now().UTC() }

// FixedClock selalu mengembalikan Waktu yang sama.
type FixedClock struct {
	Waktu time.Time
}

// Now memenuhi interface Clock.
func (c FixedClock) Now() time.Time { return c.Waktu }

// CodeGenerator memproduksi kandidat short code baru. Produksi
// memakai Base62Generator; test dan demo memakai generator stub
// yang mengembalikan urutan tetap.
type CodeGenerator interface {
	NewCode() (string, error)
}

// ShortenService menampung aturan pemendekan URL: validasi masukan,
// pembuatan kode unik dengan retry, penghitungan klik, dan
// statistik — seluruh akses penyimpanan didelegasikan ke
// URLRepository yang diinjeksi.
type ShortenService struct {
	repo  URLRepository
	gen   CodeGenerator
	clock Clock
}

// NewShortenService membangun service dengan repository, generator
// kode, dan clock yang diinjeksi pemanggil.
func NewShortenService(repo URLRepository, gen CodeGenerator, clock Clock) *ShortenService {
	return &ShortenService{repo: repo, gen: gen, clock: clock}
}

// Shorten memvalidasi URL, lalu mencoba menyimpan pemetaan dengan
// kode dari generator. Bila kode bentrok (ErrCodeExists), kode baru
// dibuat lagi sampai maxPercobaanKode kali.
func (s *ShortenService) Shorten(ctx context.Context, rawURL string) (URLRecord, error) {
	if err := ValidasiURL(rawURL); err != nil {
		return URLRecord{}, err
	}

	for percobaan := 1; percobaan <= maxPercobaanKode; percobaan++ {
		code, err := s.gen.NewCode()
		if err != nil {
			return URLRecord{}, fmt.Errorf("membuat short code: %w", err)
		}
		rec := URLRecord{
			Code:        code,
			OriginalURL: rawURL,
			CreatedAt:   s.clock.Now(),
		}

		err = s.repo.Save(ctx, rec)
		switch {
		case err == nil:
			return rec, nil
		case errors.Is(err, ErrCodeExists):
			continue // kode sudah dipakai: coba kode berikutnya
		default:
			return URLRecord{}, err
		}
	}
	return URLRecord{}, fmt.Errorf("kode unik tidak didapat setelah %d percobaan: %w", maxPercobaanKode, ErrCodeExists)
}

// Redirect menaikkan jumlah klik satu kali lalu mengembalikan URL
// asli yang menjadi tujuan pengalihan.
func (s *ShortenService) Redirect(ctx context.Context, code string) (string, error) {
	rec, err := s.repo.AddClick(ctx, code)
	if err != nil {
		return "", err
	}
	return rec.OriginalURL, nil
}

// Stats mengambil statistik satu short code tanpa menaikkan klik.
func (s *ShortenService) Stats(ctx context.Context, code string) (URLRecord, error) {
	return s.repo.FindByCode(ctx, code)
}

// ValidasiURL memeriksa URL asli dengan net/url.Parse: wajib berisi
// skema http/https dan host yang tidak kosong.
func ValidasiURL(raw string) error {
	if raw == "" {
		return ValidationError{Field: "url", Message: "wajib diisi"}
	}
	if len(raw) > maxPanjangURL {
		return ValidationError{Field: "url", Message: "melebihi 2048 karakter"}
	}
	u, err := url.Parse(raw)
	if err != nil {
		return ValidationError{Field: "url", Message: "format tidak valid"}
	}
	if u.Scheme != "http" && u.Scheme != "https" {
		return ValidationError{Field: "url", Message: "skema harus http atau https"}
	}
	if u.Host == "" {
		return ValidationError{Field: "url", Message: "host kosong"}
	}
	return nil
}

// alfabetBase62 berisi 62 karakter (a-z, A-Z, 0-9) yang aman
// dimasukkan ke URL tanpa perlu di-escape.
const alfabetBase62 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

// Base62Generator membuat short code acak sepanjang panjangKode dari
// alfabet base62. Sumber acaknya *rand.Rand (math/rand/v2) yang
// di-seed 16 byte crypto/rand, sehingga kode tidak bisa diprediksi
// dari kode-kode sebelumnya. Mutex diperlukan karena *rand.Rand
// tidak aman dipakai konkuren.
type Base62Generator struct {
	mu  sync.Mutex
	rng *rand.Rand
}

// NewBase62Generator membaca seed acak dari crypto/rand lalu membangun
// generator PCG.
func NewBase62Generator() (*Base62Generator, error) {
	seed := make([]byte, 16)
	if _, err := cryptorand.Read(seed); err != nil {
		return nil, fmt.Errorf("membaca seed crypto/rand: %w", err)
	}
	rng := rand.New(rand.NewPCG(
		binary.LittleEndian.Uint64(seed[:8]),
		binary.LittleEndian.Uint64(seed[8:]),
	))
	return &Base62Generator{rng: rng}, nil
}

// NewCode menghasilkan string acak dari alfabetBase62.
func (g *Base62Generator) NewCode() (string, error) {
	g.mu.Lock()
	defer g.mu.Unlock()
	buf := make([]byte, panjangKode)
	for i := range buf {
		buf[i] = alfabetBase62[g.rng.IntN(len(alfabetBase62))]
	}
	return string(buf), nil
}