Skip to content

Day 055 — Project: URL Shortener

Month 2 · Week 4 · ⬅ Day 054 · Day 056 ➡ · Journal index

🎯 Learning Objective

Ship the Week 4 capstone: a concurrency-safe URL shortener using only the standard library (net/http, net/url, sync) — base62 short codes, an RWMutex-guarded store, explicit method checks, and 302 redirects — backed by tests.

📚 Topics

  • net/http server: ServeMux, HandleFunc, method dispatch, http.Redirect
  • Concurrency-safe state with sync.RWMutex; de-duplicating identical URLs
  • Base62 encoding of a counter; input validation with net/url

📖 Reading / Sources

📝 Notes

  • Endpoints: POST /shorten (form url=<long>) returns a short URL; GET /{code} issues a 302 redirect to the long URL; GET /healthz returns ok. The "/" pattern is the catch-all that handles /{code}. → links to [[http-handlers]] and [[concurrency]].
  • Method checks are explicit in net/http's classic mux — guard with if r.Method != http.MethodPost, and on rejection set the Allow header before http.Error(w, ..., http.StatusMethodNotAllowed). (Go 1.22's method-aware patterns like "POST /shorten" can do this for you; doing it by hand shows the mechanism.)
  • Validate input with url.ParseRequestURI and require scheme == http|https — rejecting javascript: and relative junk so the redirect target is safe. A bad URL is a 400.
  • Shared state needs a lock. The store is two maps (code→long, long→code) plus a counter, guarded by a sync.RWMutex: writers take Lock (Save), readers take RLock (Resolve). Maps are not safe for concurrent write — without the mutex go test -race screams and you risk a fatal "concurrent map writes".
  • De-duplicate: the reverse long→code index means saving the same URL twice returns the same code instead of minting a new one — idempotent writes.
  • Short codes are base62 (0-9A-Za-z) of a monotonic counter: compact and URL-safe. encode(0) == "0"; build digits least-significant-first, then reverse in place.
  • Build the absolute short URL from r.Host so it works regardless of the bound address. Prove the locking with go test -race; the httptest skills from [[testing]] (Week 3) cover the handlers without a real port.

💻 Code Examples

// Concurrency-safe save: writers hold the full Lock; identical URLs de-dup.
func (s *store) save(long string) string {
    s.mu.Lock()
    defer s.mu.Unlock()
    if code, ok := s.toCode[long]; ok { // already shortened -> same code
        return code
    }
    s.seq++
    code := encode(s.seq) // base62 of the counter
    s.toLong[code] = long
    s.toCode[long] = code
    return code
}

Full example: examples/month-02/urlshortener/ · Run: go run ./examples/month-02/urlshortener then curl -s -X POST --data-urlencode 'url=https://go.dev/doc/' localhost:8080/shorten.

🏋️ Exercises / Practice

Exercise Status Link
base62.Encode/Decode with a round-trip property test exercises/month-02/week-4/base62
normalizeurl.Normalize (scheme/host/port/fragment canonicalization) exercises/month-02/week-4/normalizeurl
urlstore.Store RWMutex map + de-dup, tested with -race exercises/month-02/week-4/urlstore

🐛 Mistakes Made

  • First version had no mutex; go test -race ./urlstore reported a data race and the concurrent test occasionally hit "fatal error: concurrent map writes." Added sync.RWMutex.
  • Used url.Parse (accepts relative refs) instead of url.ParseRequestURI, so "notaurl" slipped through. Switched and also required an http/https scheme.

❓ Open Questions

  • Where would I add persistence and rate-limiting? (Swap the map store behind an interface for a DB; wrap handlers with a limiter middleware — both fit the same http.Handler shape, deferred to Month 3.)

🧠 Active Recall (answer without looking)

  1. Q: Why does the store need a mutex, and why RWMutex specifically?
    A

Built-in maps aren't safe for concurrent writes (the runtime may abort with "concurrent map writes"), and the HTTP server handles requests on many goroutines. RWMutex lets multiple Resolve readers proceed in parallel under RLock while Save takes an exclusive Lock. 2. Q: Which status code redirects a short code to its long URL, and how do you send it?

A

302 Found (http.StatusFound), via http.Redirect(w, r, longURL, http.StatusFound), which sets the Location header and writes the status.

🪶 Feynman Reflection

A URL shortener is a two-way dictionary behind an HTTP door. POST a long URL and it files it under a short base62 code (reusing the code if it's seen the URL before); GET that code and it sends your browser a 302 "go over there" pointing at the original. Because many visitors knock at once, the dictionary sits behind a read/write lock so lookups run concurrently but writes are exclusive — no torn maps.

🕳️ Knowledge Gaps

  • Collision-resistant codes (random/hashids) vs sequential counters, and graceful shutdown (http.Server.Shutdown) — note for the Month 3 service work.

✅ Summary

I built a stdlib-only URL shortener: base62 codes, an RWMutex store with de-dup, validated input, explicit method handling with Allow, and 302 redirects — all proven with race-enabled tests.

⏭️ Next Steps / Prep for Tomorrow

  • Day 056: Month 2 review, closed-book recall, write the week + month reviews, and tag v0.2.0.

Time spent Difficulty Confidence
90 min 🟦🟦🟦⬜⬜ 🟦🟦🟦⬜⬜

Suggested commit: feat(examples): stdlib URL shortener capstone (day 055)