Go Web Frameworks: net/http, Chi, Gin, Echo and Fiber · Building Production APIs in Go
Capstone: Build a Bookmarks API
Build a complete REST API for saving bookmarks with the framework of your choice: validation, auth, pagination, a database, tests and graceful shutdown. Then review it against the rubric.
The brief
Build "Linkbox", an API where signed-in users save and organise bookmarks:
POST /api/v1/bookmarkswith{url, title, tags[]}: validate the URL (http/https only) and the title length; 422 with field errors when invalid; 409 if the user already saved that URL.GET /api/v1/bookmarks?tag=go&cursor=…&limit=20: the user's bookmarks, newest first, cursor pagination, optional tag filter.GET /api/v1/bookmarks/{id},PATCH /api/v1/bookmarks/{id},DELETE /api/v1/bookmarks/{id}: only the owner may access them (404 for other users' bookmarks, not 403, so IDs don't leak).- Token auth middleware, request IDs, structured access logs, recovery.
- SQL storage through a repository interface; a fake for unit tests.
/healthz, server timeouts, graceful shutdown.
Use any framework from this course. Keep handlers thin: parse, call a BookmarkService, write the response.
Starter: cursor pagination
Cursor pagination is the part most people get wrong. A cursor encodes "the last item I saw" so new bookmarks don't shift pages.
package main
import (
"encoding/base64"
"fmt"
"strconv"
)
type Bookmark struct {
ID int
Title string
}
// All bookmarks for one user, newest first (higher ID = newer).
var store = []Bookmark{{9, "Go blog"}, {8, "Chi docs"}, {7, "Gin docs"}, {6, "Echo docs"}, {5, "Fiber docs"}, {4, "slog"}, {3, "sqlc"}}
func encode(id int) string { return base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(id))) }
func decode(c string) (int, error) {
b, err := base64.RawURLEncoding.DecodeString(c)
if err != nil {
return 0, err
}
return strconv.Atoi(string(b))
}
// page returns up to limit bookmarks older than the cursor, plus the next cursor ("" when done).
func page(cursor string, limit int) ([]Bookmark, string) {
before := int(^uint(0) >> 1) // no cursor: start from the newest
if cursor != "" {
id, err := decode(cursor)
if err != nil {
return nil, ""
}
before = id
}
var out []Bookmark
for _, b := range store { // in SQL: WHERE user_id = ? AND id < ? ORDER BY id DESC LIMIT ?
if b.ID < before && len(out) < limit {
out = append(out, b)
}
}
if len(out) < limit {
return out, ""
}
return out, encode(out[len(out)-1].ID)
}
func main() {
cursor := ""
for n := 1; ; n++ {
items, next := page(cursor, 3)
fmt.Printf("page %d: %v next=%q\n", n, items, next)
if next == "" {
break
}
if n == 1 {
store = append([]Bookmark{{10, "NEW: added while paging"}}, store...) // doesn't disturb later pages
}
cursor = next
}
}
Output
page 1: [{9 Go blog} {8 Chi docs} {7 Gin docs}] next="Nw"
page 2: [{6 Echo docs} {5 Fiber docs} {4 slog}] next="NA"
page 3: [{3 sqlc}] next=""Run this on your computer — see “Get Started” for setup.
Review your work
Open each question for the model answer and rubric.
Deliverables
Structure and handlers: what does a strong solution look like?
Model answer
Thin handlers that parse input, call a BookmarkService and write output; the service holds the rules (ownership, duplicate URLs) and depends on a BookmarkRepo interface; a single error type mapped to 400/404/409/422/500; middleware for recovery, request IDs, logging and auth in that order.
Rubric
Rubric: no SQL or framework types in the service; consistent JSON errors; 404 (not 403) for other users' bookmarks; routes grouped under /api/v1. Weak: SQL inside handlers, ad-hoc error formats.
Data and pagination: what does a strong solution look like?
Model answer
A bookmarks table with a unique (user_id, url) constraint (so duplicates return 409 even under races), an index on (user_id, id DESC), parameterised queries with context timeouts, and cursor pagination (WHERE id < cursor ORDER BY id DESC LIMIT n+1 to know if there's a next page).
Rubric
Rubric: the database enforces uniqueness (not just a pre-check); the cursor is opaque (base64) and validated; tag filtering uses a join table or array column with an index. Weak: OFFSET pagination on large tables, string-built SQL.
Tests and operations: what does a strong solution look like?
Model answer
Table-driven httptest tests covering success, validation errors, auth failures, other users' bookmarks and pagination, using a fake repository; a few integration tests against a real database; go test -race in CI; slog JSON logs; server timeouts; /healthz; graceful shutdown on SIGTERM.
Rubric
Rubric: tests don't need a running server; the race detector passes; shutdown drains in-flight requests. Bonus: rate limiting per user and an OpenAPI description.
Open in the app to mark lessons complete, take notes and track your progress.