Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
13 changes: 13 additions & 0 deletions internal/shortener/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions internal/shortener/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
26 changes: 26 additions & 0 deletions internal/shortener/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
38 changes: 37 additions & 1 deletion internal/storage/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
36 changes: 36 additions & 0 deletions internal/storage/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading