Service yang baik tetap tak bernilai tanpa pintu masuk. Bagian ini membangun wajah HTTP URL shortener: routing dengan pattern method-path milik ServeMux Go 1.22, handler yang tetap tipis dengan mendelegasikan seluruh logika ke service, pembacaan body JSON dengan pembatas ukuran, serta format galat yang konsisten agar konsumen API dapat mengandalkan struktur yang sama di setiap endpoint.
Routing Empat Endpoint #
Seluruh rute dideklarasikan dalam satu fungsi NewRouter di handler.go:
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
}Tiga hal patut dicermati dari routing ini. Pertama, pattern "POST /shorten" dan "GET /{code}" tidak bertabrakan karena method-nya berbeda — permintaan POST ke /{code} ditolak 405 secara otomatis oleh mux. Kedua, "GET /stats/{code}" lebih spesifik daripada "GET /{code}", sehingga path dua segmen /stats/b1Gk0D3 jatuh ke handler statistik, bukan redirect dengan kode "stats" — aturan prioritas pattern yang dibahas pada chapter Routing
. Ketiga, kode ditangkap dengan r.PathValue("code"), bukan dipotong manual dari path.
Handler Shorten: Tipis dan Terukur #
Handler Shorten hanya memindahkan data: body request masuk, hasil service keluar:
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,
})
}Alurnya murni transport: bacaJSON membatasi ukuran body 1 MiB dengan http.MaxBytesReader, memeriksa Content-Type application/json, lalu men-decode; galat transport menjadi galatBody yang membawa statusnya sendiri. Pemanggilan h.svc.Shorten(r.Context(), ...) meneruskan context request sehingga pembatalan klien menular sampai query database. Respons sukses memakai 201 Created dengan header Location berisi short URL — kombinasi status dan header yang tepat untuk pembuatan resource, sesuai tabel pada chapter Status Code
.
Redirect: 302 dan Penghitungan Klik #
Endpoint pengalihan justru handler terpendek:
// 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)
}http.Redirect dari standard library menulis header Location sekaligus body HTML kecil sebagai cadangan untuk klien lama. Status 302 Found dipilih bukan 301: pengalihan permanen akan di-cache browser selamanya dan membuat statistik klik berhenti terhitung — pilihan sadar yang dibahas juga di chapter Response Format
. Klik dinaikkan oleh service di dalam Redirect, sehingga setiap pengalihan yang sukses terhitung tepat satu kali secara atomik.
Format Galat yang Konsisten #
Seluruh galat keluar dalam satu bentuk amplop:
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"`
}Fungsi writeError menerjemahkan domain ke transport: ValidationError menjadi 400 dengan peta fields per nama field, ErrNotFound menjadi 404, dan galat tak dikenal menjadi 500 dengan pesan generik “galat internal server” tanpa membocorkan detail — kehati-hatian yang diwajibkan chapter Desain Error API
. Kode galat berupa string stabil seperti VALIDATION_ERROR dan NOT_FOUND sehingga klien dapat bercabang logika tanpa mengurai teks bebas.
Bukti Nyata: Mode Demo #
Seluruh alur ini dapat disaksikan tanpa PostgreSQL melalui go run . demo, yang menjalankan router di atas MemoryURLRepository dan httptest:
go run . demoOutput yang Dihasilkan (potongan):
POST /shorten → 201 Created
Location: http://short.test/b1Gk0D3
body: {"code":"b1Gk0D3","url":"https://example.com/tutorial-go-yang-sangat-panjang","short_url":"http://short.test/b1Gk0D3"}
POST /shorten → 400 Bad Request
body: {"error":{"code":"VALIDATION_ERROR","message":"masukan tidak lolos validasi","fields":{"url":"skema harus http atau https"}}}
GET /b1Gk0D3 → 302 Found
Location: https://example.com/tutorial-go-yang-sangat-panjang
GET /stats/b1Gk0D3 → 200 OK
body: {"code":"b1Gk0D3","url":"https://example.com/tutorial-go-yang-sangat-panjang","clicks":2,"created_at":"2026-01-15T08:00:00Z"}
GET /stats/takDikn → 404 Not Found
body: {"error":{"code":"NOT_FOUND","message":"kode takDikn: pemetaan tidak ditemukan"}}Kelima skenario — sukses, validasi, redirect, statistik, dan kode hilang — memperlihatkan status, header, dan body yang persis seperti dirancang. Handler sudah utuh; tugas terakhir membungkus semuanya dengan konfigurasi, pengujian integrasi, dan container — isi Bagian 5.
Referensi #
- Dokumentasi proyek: handler.go — berkas sumber lengkap di bagian bawah halaman.
- Routing — pattern method-path dan prioritas ServeMux.
- Desain Error API — amplop galat dan kode stabil.
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
}