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.
π― Learning Objectives
- 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
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.
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
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
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
// 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
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.
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
http.HandlerFunc?httptest.NewRecorder() return?server.Shutdown(ctx) do?{id} from a URL pattern?π Cheat Sheet
mux := http.NewServeMux()mux.HandleFunc("GET /path/{id}", h)r.PathValue("id")r.URL.Query().Get("key")json.NewDecoder(r.Body).Decode(&v)json.NewEncoder(w).Encode(v)w.WriteHeader(http.StatusCreated)w.Header().Set("Content-Type","application/json")func(http.Handler) http.Handlerctx := context.WithValue(r.Context(), key, val)server.Shutdown(ctx)httptest.NewRecorder(); httptest.NewRequest()