🌐 Chapter 19 of 20

Building a REST API

Go's standard net/http package is powerful enough to build production REST APIs without any framework. This chapter walks you through a complete API: routing, middleware, JSON handling, authentication, and graceful shutdown.

⏱ β€”πŸ“Š Production

🎯 Learning Objectives

βœ… After this chapter you will:
  • Build HTTP handlers using net/http
  • Use http.ServeMux (Go 1.22+ enhanced patterns)
  • Write reusable middleware (logging, auth, CORS)
  • Decode and encode JSON request/response bodies
  • Use context for request-scoped values and timeouts
  • Implement graceful shutdown
  • Write HTTP handler tests with httptest

πŸš€ Basic HTTP Server

go
package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "time"
)

// Response helpers
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(v)
}

func writeError(w http.ResponseWriter, status int, msg string) {
    writeJSON(w, status, map[string]string{"error": msg})
}

type HealthResponse struct {
    Status    string    `json:"status"`
    Timestamp time.Time `json:"timestamp"`
    Version   string    `json:"version"`
}

func healthHandler(w http.ResponseWriter, r *http.Request) {
    writeJSON(w, http.StatusOK, HealthResponse{
        Status:    "healthy",
        Timestamp: time.Now().UTC(),
        Version:   "1.0.0",
    })
}

// Go 1.22+ enhanced ServeMux with method and path parameters
func setupRoutes() *http.ServeMux {
    mux := http.NewServeMux()

    // Pattern: "METHOD /path" (Go 1.22+)
    mux.HandleFunc("GET /health", healthHandler)
    mux.HandleFunc("GET /api/users", listUsers)
    mux.HandleFunc("POST /api/users", createUser)
    mux.HandleFunc("GET /api/users/{id}", getUser)   // {id} is a path param
    mux.HandleFunc("PUT /api/users/{id}", updateUser)
    mux.HandleFunc("DELETE /api/users/{id}", deleteUser)

    return mux
}

func main() {
    mux := setupRoutes()

    // Wrap with middleware
    handler := loggingMiddleware(authMiddleware(mux))

    server := &http.Server{
        Addr:         ":8080",
        Handler:      handler,
        ReadTimeout:  15 * time.Second,
        WriteTimeout: 15 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    log.Printf("Server starting on %s", server.Addr)
    if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatalf("Server error: %v", err)
    }
}

func listUsers(w http.ResponseWriter, r *http.Request)   { fmt.Fprintln(w, "list users") }
func createUser(w http.ResponseWriter, r *http.Request)  { fmt.Fprintln(w, "create user") }
func getUser(w http.ResponseWriter, r *http.Request)     { fmt.Fprintln(w, "get user") }
func updateUser(w http.ResponseWriter, r *http.Request)  { fmt.Fprintln(w, "update user") }
func deleteUser(w http.ResponseWriter, r *http.Request)  { fmt.Fprintln(w, "delete user") }
func loggingMiddleware(next http.Handler) http.Handler   { return next }
func authMiddleware(next http.Handler) http.Handler      { return next }

πŸ”— Middleware

Middleware wraps an http.Handler, running code before and/or after the next handler. The pattern is func(http.Handler) http.Handler.

go
package main

import (
    "context"
    "log"
    "net/http"
    "strings"
    "time"
)

// Logging middleware β€” logs each request
func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        // Wrap ResponseWriter to capture status code
        wrapped := &responseWriter{ResponseWriter: w, status: 200}
        next.ServeHTTP(wrapped, r)
        log.Printf("%s %s %d %v", r.Method, r.URL.Path, wrapped.status, time.Since(start))
    })
}

type responseWriter struct {
    http.ResponseWriter
    status int
}

func (rw *responseWriter) WriteHeader(status int) {
    rw.status = status
    rw.ResponseWriter.WriteHeader(status)
}

// Auth middleware β€” validates Bearer token
type contextKey string
const userIDKey contextKey = "userID"

func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        auth := r.Header.Get("Authorization")
        if !strings.HasPrefix(auth, "Bearer ") {
            http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
            return
        }
        token := strings.TrimPrefix(auth, "Bearer ")
        userID, err := validateToken(token)
        if err != nil {
            http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
            return
        }
        // Store user ID in request context
        ctx := context.WithValue(r.Context(), userIDKey, userID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// CORS middleware
func CORSMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
        if r.Method == http.MethodOptions {
            w.WriteHeader(http.StatusNoContent)
            return
        }
        next.ServeHTTP(w, r)
    })
}

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

func validateToken(token string) (int, error) {
    if token == "valid-token" { return 42, nil }
    return 0, http.ErrNoCookie
}

πŸ“‹ Complete CRUD Handler Example

go
package main

import (
    "encoding/json"
    "net/http"
    "strconv"
    "sync"
)

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

type UserHandler struct {
    mu    sync.RWMutex
    users map[int]User
    next  int
}

func NewUserHandler() *UserHandler {
    return &UserHandler{users: make(map[int]User), next: 1}
}

func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) {
    h.mu.RLock()
    defer h.mu.RUnlock()

    users := make([]User, 0, len(h.users))
    for _, u := range h.users { users = append(users, u) }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(users)
}

func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
    var req struct {
        Name  string `json:"name"`
        Email string `json:"email"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
        return
    }
    if req.Name == "" || req.Email == "" {
        http.Error(w, `{"error":"name and email required"}`, http.StatusBadRequest)
        return
    }

    h.mu.Lock()
    user := User{ID: h.next, Name: req.Name, Email: req.Email}
    h.users[h.next] = user
    h.next++
    h.mu.Unlock()

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(user)
}

func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
    idStr := r.PathValue("id")  // Go 1.22+ path parameter
    id, err := strconv.Atoi(idStr)
    if err != nil {
        http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest)
        return
    }

    h.mu.RLock()
    user, ok := h.users[id]
    h.mu.RUnlock()

    if !ok {
        http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

func main() {
    uh := NewUserHandler()
    mux := http.NewServeMux()
    mux.HandleFunc("GET /api/users", uh.List)
    mux.HandleFunc("POST /api/users", uh.Create)
    mux.HandleFunc("GET /api/users/{id}", uh.Get)

    handler := Chain(mux, LoggingMiddleware, CORSMiddleware)
    http.ListenAndServe(":8080", handler)
}

πŸ›‘ Graceful Shutdown

go
package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(2 * time.Second)  // simulate slow handler
        w.Write([]byte("done"))
    })

    server := &http.Server{
        Addr:    ":8080",
        Handler: mux,
    }

    // Start server in a goroutine so we can listen for signals
    go func() {
        log.Println("Server starting on :8080")
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("ListenAndServe: %v", err)
        }
    }()

    // Wait for interrupt signal (Ctrl+C or SIGTERM from Kubernetes)
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    sig := <-quit
    log.Printf("Signal %v received β€” shutting down gracefully...", sig)

    // Give in-flight requests 30 seconds to complete
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := server.Shutdown(ctx); err != nil {
        log.Fatalf("Forced shutdown: %v", err)
    }

    log.Println("Server exited cleanly")
}

πŸ§ͺ Testing HTTP Handlers

go
// handler_test.go
package main

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestCreateUser(t *testing.T) {
    uh := NewUserHandler()

    tests := []struct {
        name     string
        body     map[string]string
        wantCode int
    }{
        {"valid user", map[string]string{"name": "Alice", "email": "a@b.com"}, 201},
        {"missing name", map[string]string{"email": "a@b.com"}, 400},
        {"missing email", map[string]string{"name": "Alice"}, 400},
        {"invalid JSON", nil, 400},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            var body bytes.Buffer
            if tc.body != nil {
                json.NewEncoder(&body).Encode(tc.body)
            } else {
                body.WriteString("not json")
            }

            req := httptest.NewRequest("POST", "/api/users", &body)
            req.Header.Set("Content-Type", "application/json")
            w := httptest.NewRecorder()

            uh.Create(w, req)

            if w.Code != tc.wantCode {
                t.Errorf("status = %d, want %d", w.Code, tc.wantCode)
            }
        })
    }
}

func TestGetUser(t *testing.T) {
    uh := NewUserHandler()
    // Pre-populate
    uh.users[1] = User{ID: 1, Name: "Alice", Email: "a@b.com"}
    uh.next = 2

    req := httptest.NewRequest("GET", "/api/users/1", nil)
    // Simulate Go 1.22 path parameter (httptest.NewRequest doesn't do this automatically)
    // In real code, use httptest.Server with the actual ServeMux
    w := httptest.NewRecorder()
    uh.Get(w, req)

    // For a proper path param test, set up a real mux
    mux := http.NewServeMux()
    mux.HandleFunc("GET /api/users/{id}", uh.Get)
    ts := httptest.NewServer(mux)
    defer ts.Close()
    _ = ts
}

🎀 Interview Questions

1
What is middleware in Go's net/http? How do you chain middleware?
β–Ά
Detailed Answer

Middleware is a function that wraps an http.Handler and returns a new http.Handler. Signature: func(http.Handler) http.Handler. It runs code before and/or after the next handler, enabling cross-cutting concerns (logging, auth, CORS, rate limiting, tracing) to be separated from business logic.

Chaining: apply middleware in reverse order so they execute in the correct sequence. A Chain helper iterates the middleware slice in reverse, wrapping the handler step by step. This ensures the first middleware in the list runs first.

The middleware pattern works because http.Handler is an interface β€” any type implementing ServeHTTP(ResponseWriter, *Request) can be wrapped and passed around. http.HandlerFunc is an adapter that lets plain functions satisfy the interface.

2
How do you implement graceful shutdown in a Go HTTP server?
β–Ά
Detailed Answer

Pattern: (1) Start the server in a goroutine with go server.ListenAndServe(); (2) Wait for an OS signal (SIGINT/SIGTERM) on a channel using signal.Notify; (3) Call server.Shutdown(ctx) with a timeout context β€” this stops accepting new connections and waits for in-flight requests to complete; (4) ListenAndServe returns http.ErrServerClosed which you should NOT treat as an error.

This is critical in production: without graceful shutdown, connections are abruptly terminated when the server is killed (e.g., during a Kubernetes rolling update). In-flight requests fail with connection reset errors. With graceful shutdown, Kubernetes sends SIGTERM, your server stops accepting, drains in-flight requests, and exits cleanly.

πŸ§ͺ Quiz

🧩
REST API
4 questions
1. What is http.HandlerFunc?
2. In handler tests, what does httptest.NewRecorder() return?
3. What does server.Shutdown(ctx) do?
4. In Go 1.22+, how do you read the path parameter {id} from a URL pattern?
β€”

πŸ“Œ Cheat Sheet

πŸ“Œ REST API β€” Quick Reference
Mux
mux := http.NewServeMux()
Route (1.22)
mux.HandleFunc("GET /path/{id}", h)
Path param
r.PathValue("id")
Query param
r.URL.Query().Get("key")
Decode JSON
json.NewDecoder(r.Body).Decode(&v)
Encode JSON
json.NewEncoder(w).Encode(v)
Status
w.WriteHeader(http.StatusCreated)
Content-Type
w.Header().Set("Content-Type","application/json")
Middleware
func(http.Handler) http.Handler
Context value
ctx := context.WithValue(r.Context(), key, val)
Graceful stop
server.Shutdown(ctx)
Test
httptest.NewRecorder(); httptest.NewRequest()