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:
// 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:
Codemenjadi pengenal publik layanan: tujuh karakter Base62 yang dicetak pada URL sepertihttps://short.example.com/b1Gk0D3.OriginalURLmenyimpan tujuan asli pengalihan tanpa normalisasi tambahan — validasi skema terjadi di service, bukan di struct.Clicksbertipeint64karena kolom database jugaBIGINT: jumlah klik bisa tumbuh melewatiint32pada layanan yang ramai.CreatedAtmemakaitime.TimeUTC dan hanya bersifat informasional; urutan pembuatan tidak memengaruhi perilaku layanan.
Galat Domain Sebagai Sentinel #
Domain mendeklarasikan dua galat acuan di repository.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:
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:
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 #
- Dokumentasi proyek: repository.go — berkas sumber lengkap tersedia di bagian bawah halaman.
- Kecilnya Interface — dasar rancangan kontrak tiga method.
- Sentinel Error — pola galat acuan lintas lapisan.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
compose.yaml
Unduh berkasservices:
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 berkaspackage 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 berkaspackage 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 berkaspackage 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 berkaspackage 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 berkaspackage 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 berkaspackage 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 berkaspackage 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 berkaspackage 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-- 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 berkaspackage 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 berkaspackage 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 berkaspackage 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
}