Skip to content

Level 3: RESTful URL Shortener API (Web Servers & Database Design)

Welcome to the third level! You will now build a fully functioning backend REST API.

In Python, you might use Flask or FastAPI. In Go, the standard library net/http package is extremely powerful, fast, and feature-rich. In Go 1.22+, net/http.ServeMux got a major upgrade, adding path parameter matching (like /{code}) and HTTP method restrictions directly in the routing patterns!

API Specification

You must implement the following endpoints:

  1. Shorten a URL (POST /shorten):
  2. Request Body: {"url": "https://example.com/very/long/path"}
  3. Response: {"short_code": "a1b2c", "short_url": "http://localhost:8080/a1b2c"}
  4. Behavior: Generate a unique short code, save the mapping, and return a HTTP 201 Created status.
  5. Redirect to Original URL (GET /{short_code}):
  6. Behavior: Look up the short code. If found, increment its hit counter and redirect using HTTP status 307 Temporary Redirect. If not found, return HTTP 404 Not Found.
  7. Get Metrics (GET /metrics/{short_code}):
  8. Response: {"short_code": "a1b2c", "original_url": "https://example.com/very/long/path", "hits": 12}

Clean Architecture & Dependency Injection

One of the key patterns you will learn in this project is clean architecture using interfaces. We will decouple our HTTP layer from our storage engine by using an interface URLStore.

graph TD
    HTTP["HTTP Handler"] -->|Uses| StoreInterface["URLStore Interface"]
    StoreInterface -->|Implemented by| MemoryStore["In-Memory Map Store"]
    StoreInterface -->|Implemented by| SQLStore["SQLite / PostgreSQL Store"]

This allows us to: * Test our HTTP handlers easily by passing in a dummy/mock store. * Change our database backend (from In-Memory to Postgres/SQLite) without altering a single line of HTTP controller logic.


Getting Started

  1. Look at store.go which defines the URLStore interface, its metrics struct, and the in-memory map implementation skeleton.
  2. Look at handlers.go which contains the HTTP handler skeleton.
  3. Look at handlers_test.go which contains API tests using Go's httptest package.
  4. Run the tests:
    /usr/local/go/bin/go test -v ./level3_url_shortener/...
    
  5. Implement the store and handlers.

Thread-Safe Store (store.go)

package urlshortener

import (
    "errors"
    "sync"
)

var ErrNotFound = errors.New("url not found")

// URLMetrics holds data about how many times a short URL was hit.
type URLMetrics struct {
    ShortCode   string `json:"short_code"`
    OriginalURL string `json:"original_url"`
    Hits        int    `json:"hits"`
}

// URLStore defines the actions required to store and retrieve URL mappings.
type URLStore interface {
    Save(shortCode, originalURL string) error
    Get(shortCode string) (string, error)
    IncrementHits(shortCode string) error
    GetMetrics(shortCode string) (URLMetrics, error)
}

// MemoryStore is an in-memory implementation of URLStore.
// Note: In Go, read/write access to maps from multiple goroutines is not thread-safe.
// We must use sync.RWMutex to ensure our web server doesn't crash on concurrent traffic!
type MemoryStore struct {
    mu   sync.RWMutex
    urls map[string]string
    hits map[string]int
}

// NewMemoryStore initializes a new MemoryStore.
func NewMemoryStore() *MemoryStore {
    return &MemoryStore{
        urls: make(map[string]string),
        hits: make(map[string]int),
    }
}

// Save stores the link mapping.
func (m *MemoryStore) Save(shortCode, originalURL string) error {
    // TODO: Safely save mapping to the map. Use m.mu.Lock() / defer m.mu.Unlock().
    m.mu.Lock()
    defer m.mu.Unlock()
    m.urls[shortCode] = originalURL
    return nil
}

// Get retrieves the original URL. If not found, it must return ErrNotFound.
func (m *MemoryStore) Get(shortCode string) (string, error) {
    m.mu.RLock()
    defer m.mu.RUnlock()
    val, ok := m.urls[shortCode]
    if !ok {
        return "", ErrNotFound
    }
    return val, nil
    // TODO: Safely read from map. Use m.mu.RLock() / defer m.mu.RUnlock().
}

// IncrementHits increases the hit counter by 1. If code not found, return ErrNotFound.
func (m *MemoryStore) IncrementHits(shortCode string) error {
    // TODO: Safely increment hits.
    m.mu.Lock()
    defer m.mu.Unlock()
    m.hits[shortCode]++
    return nil
}

// GetMetrics returns the redirection metrics for a short code.
func (m *MemoryStore) GetMetrics(shortCode string) (URLMetrics, error) {
    m.mu.RLock()
    defer m.mu.RUnlock()
    val, ok := m.urls[shortCode]
    if !ok {
        return URLMetrics{}, ErrNotFound
    }
    return URLMetrics{ShortCode: shortCode, OriginalURL: val, Hits: m.hits[shortCode]}, nil
    // TODO: Safely retrieve and compile URLMetrics.
}

HTTP Handlers (handlers.go)

package urlshortener

import (
    "net/http"
    "encoding/json"
    "math/rand"
)

// Server holds dependencies for our HTTP server.
type Server struct {
    Store URLStore
}

// NewServer creates a new Server instance.
func NewServer(store URLStore) *Server {
    return &Server{
        Store: store,
    }
}

// RegisterRoutes sets up HTTP routing.
// Note: In Go 1.22+, you can define methods and variables directly in the routing pattern:
// e.g. mux.HandleFunc("POST /shorten", s.HandleShorten)
// e.g. mux.HandleFunc("GET /{short_code}", s.HandleRedirect)
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
    mux.HandleFunc("POST /shorten", s.HandleShorten)
    mux.HandleFunc("GET /{short_code}", s.HandleRedirect)
    mux.HandleFunc("GET /metrics/{short_code}", s.HandleMetrics)
    // TODO: Register your route handlers using the patterns above.
}

type ShortenRequest struct {
    URL string `json:"url"`
}

type ShortenResponse struct {
    ShortCode string `json:"short_code"`
    ShortURL  string `json:"short_url"`
}

func generateShortCode() string {
    const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"
    b := make([]byte, 5)
    for i := range b {
        b[i] = charset[rand.Intn(len(charset))]
    }
    return string(b)
}

// HandleShorten processes a request to shorten a URL.
func (s *Server) HandleShorten(w http.ResponseWriter, r *http.Request) {
    var data ShortenRequest
    err := json.NewDecoder(r.Body).Decode(&data)
    if err != nil {
        http.Error(w, "Invalid request body", http.StatusBadRequest)
        return
    }
    shortCode := generateShortCode()
    err = s.Store.Save(shortCode, data.URL)
    if err != nil {
        http.Error(w, "Failed to save URL", http.StatusInternalServerError)
        return
    }
    var resp ShortenResponse
    resp.ShortCode = shortCode
    resp.ShortURL = "http://localhost:8080/" + shortCode

    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(resp)

    // TODO: Implement.
    // 1. Decode the JSON body into ShortenRequest. If invalid, write http.StatusBadRequest (400).
    // 2. Generate a random short code (e.g. 5 random alphabetic characters).
    // 3. Save the mapping using s.Store.Save.
    // 4. Send back a JSON response with the short code and the full short URL (e.g. http://localhost:8080/{short_code}).
    //    Write http.StatusCreated (201).
}

// HandleRedirect handles requests redirecting the client to their destination.
func (s *Server) HandleRedirect(w http.ResponseWriter, r *http.Request) {
    shortCode := r.PathValue("short_code")
    originalURL, err := s.Store.Get(shortCode)
    if err != nil {
        http.Error(w, "URL not found", http.StatusNotFound)
        return
    }
    err = s.Store.IncrementHits(shortCode)
    if err != nil {
        http.Error(w, "Failed to increment hits", http.StatusInternalServerError)
        return
    }
    http.Redirect(w, r, originalURL, http.StatusTemporaryRedirect)
    // TODO: Implement.
    // 1. Extract the "short_code" path value (Hint: use r.PathValue("short_code")).
    // 2. Lookup the original URL using s.Store.Get.
    // 3. If missing, return http.StatusNotFound (404).
    // 4. Increment the metrics hit counter using s.Store.IncrementHits.
    // 5. Redirect the client to the original URL using http.Redirect with http.StatusTemporaryRedirect (307).
}

// HandleMetrics returns hit statistics for a shortened URL.
func (s *Server) HandleMetrics(w http.ResponseWriter, r *http.Request) {
    shortCode := r.PathValue("short_code")
    metrics, err := s.Store.GetMetrics(shortCode)
    if err != nil {
        http.Error(w, "URL not found", http.StatusNotFound)
        return
    }
    json.NewEncoder(w).Encode(metrics)
    // TODO: Implement.
    // 1. Extract the "short_code" path value.
    // 2. Load metrics using s.Store.GetMetrics.
    // 3. If missing, return http.StatusNotFound (404).
    // 4. Encode and return the metrics struct as JSON.
}