Project: URL Shortener API
Time to put the course together. In this project you will build a URL shortener: a small web service that turns a long link such as https://pkg.go.dev/net/http into a short one such as http://localhost:8080/q0V, redirects visitors who open the short link, and counts the visits. Services like this are used for SMS campaigns, where every character costs money, and for printed posters, where nobody wants to type a long address.
You will use almost everything you have learned: modules, structs and methods, maps, a mutex, errors, JSON, Go 1.22 routing, middleware, context for graceful shutdown, and table-driven tests with httptest. The finished program uses only the standard library.
What we are building
| Method and path | What it does | Success status |
|---|---|---|
POST /api/links | Create a short link from {"url": "..."} | 201 Created |
GET /api/links | List all links with visit counts | 200 OK |
GET /api/links/{code} | Show one link's details | 200 OK |
DELETE /api/links/{code} | Delete a link | 204 No Content |
GET /{code} | Redirect the visitor to the long URL | 302 Found |
GET / | A short message saying the service runs | 200 OK |
The code is split into four files in one main package:
shortener/
go.mod
store.go the data: links in a map, protected by a mutex
handlers.go HTTP handlers, JSON helpers, validation, middleware
main.go server setup and graceful shutdown
app_test.go testsCreate the module first:
mkdir shortener
cd shortener
go mod init example.com/shortenerStep 1: the store
The store keeps links in a map from short code to Link. Every HTTP request runs in its own goroutine, so a sync.Mutex guards the map, exactly as in The sync Package.
Short codes come from a counter written in base 62: the digits 0-9, then a-z, then A-Z. Starting the counter at 100,000 gives three-character codes, and three base-62 characters cover over 238,000 numbers before a fourth character is needed.
// store.go
package main
import (
"cmp"
"slices"
"sync"
)
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
// encodeBase62 writes n using the 62 characters of alphabet.
func encodeBase62(n int) string {
if n == 0 {
return "0"
}
var buf []byte
for n > 0 {
buf = append(buf, alphabet[n%62])
n /= 62
}
slices.Reverse(buf)
return string(buf)
}
type Link struct {
ID int
Code string
URL string
Visits int
}
type Store struct {
mu sync.Mutex
nextID int
links map[string]Link
}
func NewStore() *Store {
return &Store{nextID: 100000, links: make(map[string]Link)}
}
func (s *Store) Add(rawURL string) Link {
s.mu.Lock()
defer s.mu.Unlock()
l := Link{ID: s.nextID, Code: encodeBase62(s.nextID), URL: rawURL}
s.nextID++
s.links[l.Code] = l
return l
}
func (s *Store) Get(code string) (Link, bool) {
s.mu.Lock()
defer s.mu.Unlock()
l, ok := s.links[code]
return l, ok
}
// Visit records one visit and returns the long URL.
func (s *Store) Visit(code string) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
l, ok := s.links[code]
if !ok {
return "", false
}
l.Visits++
s.links[code] = l
return l.URL, true
}
func (s *Store) Delete(code string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.links[code]
delete(s.links, code)
return ok
}
// List returns all links ordered by creation.
func (s *Store) List() []Link {
s.mu.Lock()
out := make([]Link, 0, len(s.links))
for _, l := range s.links {
out = append(out, l)
}
s.mu.Unlock()
slices.SortFunc(out, func(a, b Link) int { return cmp.Compare(a.ID, b.ID) })
return out
}encodeBase62 takes the remainder by 62 to get the last digit, divides, and repeats, so the digits come out backwards and slices.Reverse puts them in order. For 100,000 it returns q0U.
Three design choices worth noticing. The map stores Link values, and every method returns copies, so no caller can change a link without the lock. Visit reads, increments and writes back inside one critical section, so two simultaneous visits are never lost. List copies the links while holding the lock but sorts after releasing it, because sorting does not touch shared data. The map's random order never reaches the user.
Step 2: validation and JSON helpers
A shortener must refuse bad input: empty strings, text without http:// or https://, and dangerous schemes such as javascript:, which could run code in a visitor's browser after the redirect. The standard net/url package splits a URL into scheme, host and path for us.
// handlers.go (part 1)
package main
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"time"
)
func validateURL(raw string) error {
if len(raw) > 2048 {
return errors.New("url is too long")
}
u, err := url.Parse(raw)
if err != nil {
return errors.New("url could not be parsed")
}
if u.Scheme != "http" && u.Scheme != "https" {
return errors.New("url must start with http:// or https://")
}
if u.Host == "" {
return errors.New("url must include a host")
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Println("write response:", err)
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}The text go.dev parses with an empty scheme, so it fails the second check, and https:// alone has no host, so it fails the third. Each error message is written for the API user, because the handler sends it back in the JSON body.
Step 3: the handlers and routes
The App struct holds what the handlers share: the store and the base address used to build short URLs. The JSON shape the API returns, linkJSON, is separate from the stored Link, so the internal ID never leaks and the public format can stay stable if storage changes.
// handlers.go (part 2)
type App struct {
store *Store
baseURL string
}
type linkJSON struct {
Code string `json:"code"`
URL string `json:"url"`
ShortURL string `json:"short_url"`
Visits int `json:"visits"`
}
func (a *App) toJSON(l Link) linkJSON {
return linkJSON{Code: l.Code, URL: l.URL, ShortURL: a.baseURL + "/" + l.Code, Visits: l.Visits}
}
func (a *App) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", a.home)
mux.HandleFunc("GET /{code}", a.redirect)
mux.HandleFunc("POST /api/links", a.createLink)
mux.HandleFunc("GET /api/links", a.listLinks)
mux.HandleFunc("GET /api/links/{code}", a.getLink)
mux.HandleFunc("DELETE /api/links/{code}", a.deleteLink)
return mux
}
func (a *App) home(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "URL shortener is running. POST /api/links to create a link.")
}
func (a *App) createLink(w http.ResponseWriter, r *http.Request) {
var in struct {
URL string `json:"url"`
}
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "request body must be JSON with a url field")
return
}
if err := validateURL(in.URL); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
link := a.store.Add(in.URL)
w.Header().Set("Location", "/api/links/"+link.Code)
writeJSON(w, http.StatusCreated, a.toJSON(link))
}
func (a *App) listLinks(w http.ResponseWriter, r *http.Request) {
links := a.store.List()
out := make([]linkJSON, 0, len(links))
for _, l := range links {
out = append(out, a.toJSON(l))
}
writeJSON(w, http.StatusOK, out)
}
func (a *App) getLink(w http.ResponseWriter, r *http.Request) {
l, ok := a.store.Get(r.PathValue("code"))
if !ok {
writeError(w, http.StatusNotFound, "link not found")
return
}
writeJSON(w, http.StatusOK, a.toJSON(l))
}
func (a *App) deleteLink(w http.ResponseWriter, r *http.Request) {
if !a.store.Delete(r.PathValue("code")) {
writeError(w, http.StatusNotFound, "link not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (a *App) redirect(w http.ResponseWriter, r *http.Request) {
target, ok := a.store.Visit(r.PathValue("code"))
if !ok {
http.NotFound(w, r)
return
}
http.Redirect(w, r, target, http.StatusFound)
}Look at how the routes fit together. GET /{code} matches any one-segment path such as /q0U, while /api/links and /api/links/q0U have two and three segments, so they never collide with it. GET /{$} matches only the bare /. A POST to /q0U gets an automatic 405, as you saw in net/http & a REST API.
http.Redirect sets the Location header and the status. We use 302 Found rather than 301 Moved Permanently, because browsers cache 301 redirects and would skip our server on the next visit, so the visit counter would stop counting. listLinks builds a slice with make(..., 0, n) so an empty store returns [], not null.
Step 4: main with graceful shutdown
When you deploy a new version, the old process receives a signal to stop. Killing it instantly would cut off users mid-request. Graceful shutdown stops accepting new connections, lets running requests finish, and then exits. signal.NotifyContext turns Ctrl+C into a cancelled context, and srv.Shutdown does the rest:
// main.go
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"time"
)
func main() {
addr := ":8080"
if port, ok := os.LookupEnv("PORT"); ok {
addr = ":" + port
}
app := &App{store: NewStore(), baseURL: "http://localhost" + addr}
srv := &http.Server{
Addr: addr,
Handler: logRequests(app.routes()),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
go func() {
log.Printf("listening on %s", addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
<-ctx.Done() // wait for Ctrl+C
log.Println("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Println("shutdown:", err)
}
}The server runs in its own goroutine while main waits on ctx.Done(). After Shutdown is called, ListenAndServe returns http.ErrServerClosed, which is expected, so it is not treated as a failure. The shutdown gets 5 seconds; requests still running after that are cut off. The PORT environment variable lets hosting platforms choose the port.
Step 5: try it
Start the server with go run ., then in a second terminal (Git Bash, WSL, Linux or macOS) create two links, try a bad one, follow a short link and list everything:
curl -X POST localhost:8080/api/links -d '{"url":"https://go.dev/doc/"}'
curl -X POST localhost:8080/api/links -d '{"url":"https://pkg.go.dev/net/http"}'
curl -X POST localhost:8080/api/links -d '{"url":"go.dev"}'
curl -i localhost:8080/q0U
curl localhost:8080/api/links
curl localhost:8080/api/links/abc{"code":"q0U","url":"https://go.dev/doc/","short_url":"http://localhost:8080/q0U","visits":0}
{"code":"q0V","url":"https://pkg.go.dev/net/http","short_url":"http://localhost:8080/q0V","visits":0}
{"error":"url must start with http:// or https://"}
HTTP/1.1 302 Found
Location: https://go.dev/doc/
[{"code":"q0U","url":"https://go.dev/doc/","short_url":"http://localhost:8080/q0U","visits":1},{"code":"q0V","url":"https://pkg.go.dev/net/http","short_url":"http://localhost:8080/q0V","visits":0}]
{"error":"link not found"}(The redirect response also carries other headers and a tiny HTML body, trimmed here.) Open http://localhost:8080/q0U in a browser and you land on go.dev. Press Ctrl+C in the server terminal and you will see shutting down before it exits.
Step 6: tests
Tests live in app_test.go in the same package, so they can call unexported functions directly. A table covers the encoder and the validator, and an httptest test drives the whole flow through the real router:
// app_test.go
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestEncodeBase62(t *testing.T) {
tests := []struct {
n int
want string
}{
{0, "0"}, {61, "Z"}, {62, "10"}, {100000, "q0U"},
}
for _, tc := range tests {
if got := encodeBase62(tc.n); got != tc.want {
t.Errorf("encodeBase62(%d) = %q, want %q", tc.n, got, tc.want)
}
}
}
func TestValidateURL(t *testing.T) {
tests := []struct {
in string
valid bool
}{
{"https://go.dev", true},
{"http://example.in/menu?day=mon", true},
{"go.dev", false},
{"ftp://files.example.in", false},
{"javascript:alert(1)", false},
{"https://", false},
{"", false},
}
for _, tc := range tests {
err := validateURL(tc.in)
if (err == nil) != tc.valid {
t.Errorf("validateURL(%q) error = %v, want valid = %v", tc.in, err, tc.valid)
}
}
}
func TestCreateThenRedirect(t *testing.T) {
app := &App{store: NewStore(), baseURL: "http://short.test"}
h := app.routes()
rec := httptest.NewRecorder()
body := strings.NewReader(`{"url":"https://go.dev/doc/"}`)
h.ServeHTTP(rec, httptest.NewRequest("POST", "/api/links", body))
if rec.Code != http.StatusCreated {
t.Fatalf("create: status %d, body %s", rec.Code, rec.Body)
}
var created linkJSON
if err := json.NewDecoder(rec.Body).Decode(&created); err != nil {
t.Fatal(err)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/"+created.Code, nil))
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "https://go.dev/doc/" {
t.Fatalf("redirect: status %d, location %q", rec.Code, rec.Header().Get("Location"))
}
if l, _ := app.store.Get(created.Code); l.Visits != 1 {
t.Errorf("visits = %d, want 1", l.Visits)
}
}go test -race ./...ok example.com/shortener 1.021s
(err == nil) != tc.valid is a compact way to say "the function disagreed with the table". The timing in the output will differ on your machine, and -race needs a C compiler on Windows and Linux, as explained in Goroutines & WaitGroup; drop the flag if you have none. Build a release binary with go build -o shortener or cross-compile it for a Linux server with GOOS=linux GOARCH=amd64 go build.
Ideas to extend the project
- Persistence. Links vanish when the server stops. Save them to a JSON file with
encoding/jsonon every change, or use a database such as SQLite or PostgreSQL throughdatabase/sql. - Unguessable codes. Counter codes are easy to guess, so anyone can walk through all your links. Generate random codes with
crypto/randand retry on a collision. - Custom aliases, such as
/diwali-sale, as in the exercise below. - Rate limiting per IP address, so one script cannot create a million links.
- Expiry dates, stored as a
time.Time, after which a link returns 410 Gone.
Key points
- Keep shared state behind methods that lock a mutex and return copies.
- Validate every input; refuse schemes other than
httpandhttpsin redirect targets. - Separate the stored type from the JSON response type, and sort map data before sending it.
- Use an
http.Serverwith timeouts,signal.NotifyContextandShutdownfor a graceful stop. - Test the whole flow through the router with
httptest, plus table tests for small functions.
Exercise
Add custom aliases. Extend the request body to accept an optional "code" field. If it is present, it must be 3 to 30 characters of letters, digits or hyphens; otherwise return 400. If the alias is already taken, return 409 Conflict with {"error":"code already in use"}. Add a store method AddWithCode(code, url string) (Link, error) that returns a sentinel error ErrCodeTaken, and use errors.Is in the handler.
Show solution
// store.go
var ErrCodeTaken = errors.New("code already in use")
func (s *Store) AddWithCode(code, rawURL string) (Link, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.links[code]; exists {
return Link{}, ErrCodeTaken
}
l := Link{ID: s.nextID, Code: code, URL: rawURL}
s.nextID++
s.links[code] = l
return l, nil
}
// handlers.go
func validCode(code string) bool {
if len(code) < 3 || len(code) > 30 {
return false
}
for _, r := range code {
if !(r == '-' || r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z') {
return false
}
}
return true
}
// in createLink, with in.Code added to the input struct (`json:"code"`):
var link Link
if in.Code == "" {
link = a.store.Add(in.URL)
} else {
if !validCode(in.Code) {
writeError(w, http.StatusBadRequest, "code must be 3-30 letters, digits or hyphens")
return
}
var err error
link, err = a.store.AddWithCode(in.Code, in.URL)
if errors.Is(err, ErrCodeTaken) {
writeError(w, http.StatusConflict, err.Error())
return
}
}The existence check and the insert happen under one lock, so two users asking for /diwali-sale at the same moment cannot both win. Custom codes still take a fresh ID, so the list stays in creation order. (store.go now also imports errors.)
Quick Quiz
1. Why does the redirect use 302 Found instead of 301 Moved Permanently?
A cached permanent redirect never reaches the server again, which breaks visit counting.
2. Why does Visit increment the counter while holding the lock?
Read, increment and write back must happen in one critical section.
3. What does srv.Shutdown(ctx) do?
Shutdown is graceful: it lets in-flight requests finish, up to the context's deadline.
4. Why reject URLs with schemes other than http and https?
Validating redirect targets protects users from dangerous links.
Learn Go with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
