Handling HTTP Requests and Routing

Updated

September 8, 2026

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)
  • DisallowUnknownFields when 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

  1. Implement DELETE /api/books/{id} returning 204.
  2. Add middleware that rejects non-JSON Content-Type on POST.
  3. Write a table of curl commands for every route as a smoke checklist.