Day 45 — net/http server

Updated

July 30, 2026

Day 45 — net/http server

Stage V · ~3h
Goal: Build a small JSON HTTP API with Go 1.22+ ServeMux method/path patterns, a middleware chain, and clean handler structure—stdlib only.

HTTP middleware wrap chain

HTTP middleware chain

Why this day exists

net/http is the default way Go speaks to the world. Frameworks come and go; Handler stays. After Day 44’s sockets, today you operate one layer up:

  • http.Handler / HandlerFunc
  • Routing with modern ServeMux
  • Middleware as func(http.Handler) http.Handler
  • JSON request/response discipline

Theory 1 — The Handler interface

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

Everything is a Handler: muxes, middleware, your API.

ResponseWriter basics

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) // first write of status; once only effectively
_, _ = w.Write(body)

Headers must be set before WriteHeader / first Write.


Theory 2 — ServeMux patterns (Go 1.22+)

Modern patterns support methods and wildcards:

mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", health)
mux.HandleFunc("GET /items/{id}", getItem)
mux.HandleFunc("POST /items", createItem)
mux.HandleFunc("GET /files/{path...}", getFile) // trailing wildcard

Extract path values:

id := r.PathValue("id")

Matching notes

  • More specific patterns win over less specific.
  • "GET /x" does not match POST /x.
  • Host-specific patterns exist ("GET example.com/x")—rarely needed in labs.
  • Legacy patterns without methods still work; prefer method-aware forms for APIs.
Important

If your local Go is older than 1.22, upgrade to the volume baseline 1.26.x. Method patterns are required for today’s labs.


Theory 3 — Middleware chain

type Middleware func(http.Handler) http.Handler

func Chain(h http.Handler, mws ...Middleware) http.Handler {
    for i := len(mws) - 1; i >= 0; i-- {
        h = mws[i](h)
    }
    return h
}

func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-ID")
        if id == "" {
            id = fmt.Sprintf("%d", time.Now().UnixNano())
        }
        w.Header().Set("X-Request-ID", id)
        next.ServeHTTP(w, r)
    })
}

func Recover(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                http.Error(w, "internal error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Order matters: recover outermost, then logging, then auth (later days).


Theory 4 — Server struct

srv := &http.Server{
    Addr:              ":8080",
    Handler:           handler,
    ReadHeaderTimeout: 5 * time.Second, // mitigate slowloris
    ReadTimeout:       10 * time.Second,
    WriteTimeout:      20 * time.Second,
    IdleTimeout:       60 * time.Second,
}
err := srv.ListenAndServe()

Always set ReadHeaderTimeout at minimum in production-shaped code.

Graceful shutdown (deepened on Day 50):

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)

Theory 5 — JSON 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)
}

func readJSON(r *http.Request, dst any) error {
    defer r.Body.Close()
    dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
    dec.DisallowUnknownFields()
    if err := dec.Decode(dst); err != nil {
        return err
    }
    // optional: reject trailing junk
    if err := dec.Decode(&struct{}{}); err != io.EOF {
        return fmt.Errorf("trailing data")
    }
    return nil
}

Worked example — items API (in-memory)

package api

import (
    "net/http"
    "sync"
)

type Item struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

type Server struct {
    mu    sync.RWMutex
    items map[string]Item
    mux   *http.ServeMux
}

func New() *Server {
    s := &Server{items: make(map[string]Item), mux: http.NewServeMux()}
    s.routes()
    return s
}

func (s *Server) Handler() http.Handler {
    return Chain(s.mux, Recover, RequestID, AccessLog)
}

func (s *Server) routes() {
    s.mux.HandleFunc("GET /healthz", s.health)
    s.mux.HandleFunc("GET /items", s.listItems)
    s.mux.HandleFunc("GET /items/{id}", s.getItem)
    s.mux.HandleFunc("POST /items", s.createItem)
}

func (s *Server) health(w http.ResponseWriter, r *http.Request) {
    writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

func (s *Server) getItem(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    s.mu.RLock()
    item, ok := s.items[id]
    s.mu.RUnlock()
    if !ok {
        writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
        return
    }
    writeJSON(w, http.StatusOK, item)
}

// listItems, createItem: similar; generate ID with crypto/rand or strconv

Implement AccessLog middleware logging method, path, status (needs a wrapping ResponseWriter that records status code—classic pattern).

type statusWriter struct {
    http.ResponseWriter
    code int
}

func (w *statusWriter) WriteHeader(code int) {
    w.code = code
    w.ResponseWriter.WriteHeader(code)
}

Lab 1 — Scaffold

mkdir -p ~/lab/90daysofx/01-go/day45
cd ~/lab/90daysofx/01-go/day45
go mod init example.com/day45

Packages: api/ handlers, cmd/itemsd/main.go server process.


Lab 2 — Routes + middleware

Requirements:

  1. GET /healthz{"status":"ok"}
  2. POST /items JSON body {"name":"..."}201 + item with id
  3. GET /items/{id} → item or 404
  4. GET /items → array
  5. Middleware: request ID + recover + basic log

Manual smoke:

go run ./cmd/itemsd
curl -s localhost:8080/healthz
curl -s -X POST localhost:8080/items -d '{"name":"widget"}' -H 'Content-Type: application/json'

Lab 3 — Validation

Reject empty name with 400 and JSON error body. Limit body size. Unknown JSON fields → 400.


Lab 4 — Method mismatch

POST /healthz should 405 (ServeMux behavior with method patterns). Confirm with curl; note status.


Lab 5 — Wrap for later tests

Export New().Handler() so Day 49 can use httptest without listening. Keep state behind mutex for concurrent requests.


Common gotchas

Gotcha Fix
WriteHeader after Write Set status first path carefully
Forgetting Content-Type Set on JSON responses
Shared map without mutex sync.RWMutex
Middleware order inverted Apply in reverse in Chain
No ReadHeaderTimeout Set on http.Server
Using old mux only /items/ slash tricks Prefer 1.22 patterns + PathValue
Returning plaintext errors inconsistently Always JSON error objects for API

Checkpoint

  • Method-aware routes work
  • PathValue extracts {id}
  • Middleware chain applied
  • JSON create/get/list
  • Body limit + basic validation
  • Server timeouts set

Commit

git add .
git commit -m "day45: http server ServeMux 1.22+ JSON API middleware"

Write three personal gotchas before continuing.


Tomorrow

Day 46 — net/http client: timeouts at every layer, transports, context cancellation, and safe body handling.