Handling HTTP Requests and Routing
Handling HTTP Requests and Routing
Overview
Routing maps method + path to a handler. Handlers parse input, call your domain/store, and write responses. This chapter builds Bookstore REST endpoints with net/http, shows middleware, and notes when Gorilla Mux still appears in older material.
ServeMux (Go 1.22+)
mux := http.NewServeMux()
mux.HandleFunc("GET /api/books", s.listBooks)
mux.HandleFunc("POST /api/books", s.createBook)
mux.HandleFunc("GET /api/books/{id}", s.getBook)Path wildcards: {id} is available as r.PathValue("id").
Handler shape
type Server struct {
books store.BookRepository
}
func NewServer(books store.BookRepository) *Server {
return &Server{books: books}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/books", s.listBooks)
mux.HandleFunc("GET /api/books/{id}", s.getBook)
mux.HandleFunc("POST /api/books", s.createBook)
return withRecover(withRequestLog(mux))
}List and get (JSON)
func (s *Server) listBooks(w http.ResponseWriter, r *http.Request) {
items, err := s.books.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "store_error", "could not list books")
return
}
writeJSON(w, http.StatusOK, items)
}
func (s *Server) getBook(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
book, err := s.books.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "book not found")
return
}
writeJSON(w, http.StatusOK, book)
}Create with validation
func (s *Server) createBook(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB
var in domain.Book
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", "invalid JSON body")
return
}
if err := in.Validate(); err != nil {
writeError(w, http.StatusBadRequest, "validation", err.Error())
return
}
out, err := s.books.Create(r.Context(), in)
if err != nil {
writeError(w, http.StatusConflict, "create_failed", err.Error())
return
}
writeJSON(w, http.StatusCreated, out)
}Habits that prevent production pain:
- Limit body size (
MaxBytesReader) DisallowUnknownFieldswhen you want strict clients- Use
r.Context()for cancel on client disconnect
Response helpers
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}Always set Content-Type before WriteHeader.
Middleware
Middleware wraps http.Handler—logging, auth, CORS, panic recovery.
func withRequestLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
slog.Info("http",
"method", r.Method,
"path", r.URL.Path,
"ms", time.Since(start).Milliseconds(),
)
})
}
func withRecover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic", "err", rec)
writeError(w, http.StatusInternalServerError, "panic", "internal error")
}
}()
next.ServeHTTP(w, r)
})
}Order matters: recover outermost so panics in logging still convert to 500s.
request → recover → log → auth → mux → handler
response ◄──────────────────────────────────
Query parameters and headers
q := r.URL.Query().Get("q") // search term
auth := r.Header.Get("Authorization")For Bookstore search:
// GET /api/books?q=gopher
func (s *Server) listBooks(w http.ResponseWriter, r *http.Request) {
items, err := s.books.List(r.Context())
// ... filter by title containing q ...
}Gorilla Mux (when you see it)
Many tutorials and older codebases use Gorilla Mux:
r := mux.NewRouter()
r.HandleFunc("/api/books/{id}", getBook).Methods(http.MethodGet)
// id := mux.Vars(r)["id"]Today: prefer stdlib ServeMux method patterns for new Bookstore code. Reach for Mux (or another router) only if you need features you truly miss (subrouters with custom matchers, complex host rules). Concepts transfer: method restriction, path vars, middleware.
Method not allowed vs not found
With Go 1.22 patterns, a path that exists for GET but not POST can yield 405 when another method is registered for that path. Unknown paths stay 404. Test both; clients depend on them.
REST habits for Bookstore
| Method | Path | Action |
|---|---|---|
| GET | /api/books |
list / search |
| GET | /api/books/{id} |
detail |
| POST | /api/books |
create |
| PUT/PATCH | /api/books/{id} |
update |
| DELETE | /api/books/{id} |
remove |
Keep collections plural. Use nouns, not verbs (/api/books not /api/getBooks).
Rules of thumb
| Do | Don’t |
|---|---|
| Parse → validate → act → respond | Stream huge bodies into memory blindly |
Propagate r.Context() |
Ignore cancellation mid-query |
| Centralize JSON write/error helpers | Copy-paste header/status in every handler |
| Prefer stdlib mux for new work | Add a router dependency with no gain |
Try next
- Implement
DELETE /api/books/{id}returning 204. - Add middleware that rejects non-JSON
Content-Typeon POST. - Write a table of curl commands for every route as a smoke checklist.