From bfd4740d091008c23350f4a6f611d5975ff32773 Mon Sep 17 00:00:00 2001 From: Callum Yuille Date: Mon, 15 Jun 2026 14:44:22 +0100 Subject: [PATCH] Add hit tracking and /stats endpoint Track per-code resolution counts in the store and expose aggregate usage statistics via a new GET /stats endpoint. Resolve now records a hit on each successful lookup. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 1 + internal/shortener/handler.go | 13 ++++++++++ internal/shortener/service.go | 15 ++++++++++-- internal/shortener/service_test.go | 26 ++++++++++++++++++++ internal/storage/store.go | 38 +++++++++++++++++++++++++++++- internal/storage/store_test.go | 36 ++++++++++++++++++++++++++++ 6 files changed, 126 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6e98da6..8796c47 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ variable. | POST | `/shorten` | Shorten a URL. Body: `{"url": "https://…"}` | | GET | `/{code}` | Redirect to the original URL | | GET | `/healthz` | Health check | +| GET | `/stats` | Usage statistics (total URLs and hits) | Example: diff --git a/internal/shortener/handler.go b/internal/shortener/handler.go index 6f6693a..2bb1e55 100644 --- a/internal/shortener/handler.go +++ b/internal/shortener/handler.go @@ -24,9 +24,22 @@ func NewHandler(svc *Service) http.Handler { mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + mux.HandleFunc("GET /stats", handleStats(svc)) return mux } +func handleStats(svc *Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + stats, err := svc.Stats() + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(stats) + } +} + func handleShorten(svc *Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req shortenRequest diff --git a/internal/shortener/service.go b/internal/shortener/service.go index 9dbbfe1..e1030e9 100644 --- a/internal/shortener/service.go +++ b/internal/shortener/service.go @@ -40,9 +40,20 @@ func (s *Service) Shorten(rawURL string) (string, error) { return code, nil } -// Resolve returns the original URL for a short code. +// Resolve returns the original URL for a short code and records the hit. func (s *Service) Resolve(code string) (string, error) { - return s.store.Get(code) + url, err := s.store.Get(code) + if err != nil { + return "", err + } + // Best-effort hit tracking; a failure here should not fail the redirect. + _ = s.store.IncrementHits(code) + return url, nil +} + +// Stats returns aggregate usage statistics for the service. +func (s *Service) Stats() (storage.Stats, error) { + return s.store.Stats() } func generateCode(n int) (string, error) { diff --git a/internal/shortener/service_test.go b/internal/shortener/service_test.go index 11146b8..26579d1 100644 --- a/internal/shortener/service_test.go +++ b/internal/shortener/service_test.go @@ -43,3 +43,29 @@ func TestResolveUnknownCode(t *testing.T) { t.Error("expected error for unknown code, got nil") } } + +func TestStatsTracksResolves(t *testing.T) { + svc := NewService(storage.NewMemoryStore()) + + code, err := svc.Shorten("https://example.com") + if err != nil { + t.Fatalf("Shorten returned error: %v", err) + } + + for i := 0; i < 3; i++ { + if _, err := svc.Resolve(code); err != nil { + t.Fatalf("Resolve returned error: %v", err) + } + } + + stats, err := svc.Stats() + if err != nil { + t.Fatalf("Stats returned error: %v", err) + } + if stats.TotalURLs != 1 { + t.Errorf("TotalURLs = %d, want 1", stats.TotalURLs) + } + if stats.TotalHits != 3 { + t.Errorf("TotalHits = %d, want 3", stats.TotalHits) + } +} diff --git a/internal/storage/store.go b/internal/storage/store.go index 29ce63c..c9309d2 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -8,20 +8,32 @@ import ( // ErrNotFound is returned when a short code has no stored URL. var ErrNotFound = errors.New("not found") +// Stats summarizes the contents and usage of a Store. +type Stats struct { + TotalURLs int `json:"total_urls"` + TotalHits int `json:"total_hits"` +} + // Store persists short code to URL mappings. type Store interface { Save(code, url string) error Get(code string) (string, error) + IncrementHits(code string) error + Stats() (Stats, error) } // MemoryStore is a thread-safe in-memory Store implementation. type MemoryStore struct { mu sync.RWMutex urls map[string]string + hits map[string]int } func NewMemoryStore() *MemoryStore { - return &MemoryStore{urls: make(map[string]string)} + return &MemoryStore{ + urls: make(map[string]string), + hits: make(map[string]int), + } } func (s *MemoryStore) Save(code, url string) error { @@ -40,3 +52,27 @@ func (s *MemoryStore) Get(code string) (string, error) { } return url, nil } + +// IncrementHits records a resolution of the given code. It returns ErrNotFound +// if the code is unknown. +func (s *MemoryStore) IncrementHits(code string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.urls[code]; !ok { + return ErrNotFound + } + s.hits[code]++ + return nil +} + +// Stats returns the total number of stored URLs and the total number of hits +// across all codes. +func (s *MemoryStore) Stats() (Stats, error) { + s.mu.RLock() + defer s.mu.RUnlock() + total := 0 + for _, n := range s.hits { + total += n + } + return Stats{TotalURLs: len(s.urls), TotalHits: total}, nil +} diff --git a/internal/storage/store_test.go b/internal/storage/store_test.go index ccaa13f..f5a949b 100644 --- a/internal/storage/store_test.go +++ b/internal/storage/store_test.go @@ -29,3 +29,39 @@ func TestMemoryStoreGetMissing(t *testing.T) { t.Errorf("expected ErrNotFound, got %v", err) } } + +func TestMemoryStoreHitsAndStats(t *testing.T) { + s := NewMemoryStore() + if err := s.Save("abc1234", "https://example.com"); err != nil { + t.Fatalf("Save returned error: %v", err) + } + if err := s.Save("xyz7890", "https://example.org"); err != nil { + t.Fatalf("Save returned error: %v", err) + } + + if err := s.IncrementHits("abc1234"); err != nil { + t.Fatalf("IncrementHits returned error: %v", err) + } + if err := s.IncrementHits("abc1234"); err != nil { + t.Fatalf("IncrementHits returned error: %v", err) + } + + stats, err := s.Stats() + if err != nil { + t.Fatalf("Stats returned error: %v", err) + } + if stats.TotalURLs != 2 { + t.Errorf("TotalURLs = %d, want 2", stats.TotalURLs) + } + if stats.TotalHits != 2 { + t.Errorf("TotalHits = %d, want 2", stats.TotalHits) + } +} + +func TestMemoryStoreIncrementHitsMissing(t *testing.T) { + s := NewMemoryStore() + + if err := s.IncrementHits("missing"); !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +}