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.

Create a free account to save this page, take notes and track your progress. Sign in

The brief

Build "Linkbox", an API where signed-in users save and organise bookmarks:

  • POST /api/v1/bookmarks with {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.

Cursor pagination over newest-first results
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.

Was this page helpful?