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:
- Shorten a URL (
POST /shorten): - Request Body:
{"url": "https://example.com/very/long/path"} - Response:
{"short_code": "a1b2c", "short_url": "http://localhost:8080/a1b2c"} - Behavior: Generate a unique short code, save the mapping, and return a HTTP
201 Createdstatus. - Redirect to Original URL (
GET /{short_code}): - Behavior: Look up the short code. If found, increment its hit counter and redirect using HTTP status
307 Temporary Redirect. If not found, return HTTP404 Not Found. - Get Metrics (
GET /metrics/{short_code}): - 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¶
- Look at store.go which defines the
URLStoreinterface, its metrics struct, and the in-memory map implementation skeleton. - Look at handlers.go which contains the HTTP handler skeleton.
- Look at handlers_test.go which contains API tests using Go's
httptestpackage. - Run the tests:
- 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.
}