Skip to main content

HTTP & Web Development in Go

Go’s net/http package is powerful enough for production use without external dependencies. This chapter covers building robust web services from the ground up.

The net/http Package

Basic HTTP Server

package main

import (
    "fmt"
    "log"
    "net/http"
)

func main() {
    http.HandleFunc("/", homeHandler)
    http.HandleFunc("/api/users", usersHandler)
    
    log.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

func homeHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Welcome to the API!")
}

func usersHandler(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case http.MethodGet:
        getUsers(w, r)
    case http.MethodPost:
        createUser(w, r)
    default:
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }
}

http.Handler Interface

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}
Any type that implements ServeHTTP can handle HTTP requests:
type APIHandler struct {
    db *sql.DB
}

func (h *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // Handle request with access to h.db
}

func main() {
    handler := &APIHandler{db: connectDB()}
    http.Handle("/api/", handler)
    http.ListenAndServe(":8080", nil)
}

JSON APIs

Encoding JSON Responses

type User struct {
    ID        int       `json:"id"`
    Name      string    `json:"name"`
    Email     string    `json:"email"`
    CreatedAt time.Time `json:"created_at"`
}

type Response struct {
    Data    interface{} `json:"data,omitempty"`
    Error   string      `json:"error,omitempty"`
    Message string      `json:"message,omitempty"`
}

func respondJSON(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    
    if err := json.NewEncoder(w).Encode(data); err != nil {
        log.Printf("Error encoding response: %v", err)
    }
}

func respondError(w http.ResponseWriter, status int, message string) {
    respondJSON(w, status, Response{Error: message})
}

func getUserHandler(w http.ResponseWriter, r *http.Request) {
    user := User{
        ID:        1,
        Name:      "John Doe",
        Email:     "[email protected]",
        CreatedAt: time.Now(),
    }
    
    respondJSON(w, http.StatusOK, Response{Data: user})
}

Decoding JSON Requests

type CreateUserRequest struct {
    Name     string `json:"name" validate:"required,min=2,max=100"`
    Email    string `json:"email" validate:"required,email"`
    Password string `json:"password" validate:"required,min=8"`
}

func createUserHandler(w http.ResponseWriter, r *http.Request) {
    // Limit request body size
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB
    
    var req CreateUserRequest
    decoder := json.NewDecoder(r.Body)
    decoder.DisallowUnknownFields() // Strict parsing
    
    if err := decoder.Decode(&req); err != nil {
        respondError(w, http.StatusBadRequest, "Invalid JSON: "+err.Error())
        return
    }
    
    // Validate (using validator package)
    if err := validate.Struct(req); err != nil {
        respondError(w, http.StatusBadRequest, "Validation failed: "+err.Error())
        return
    }
    
    // Create user...
    user := createUser(req)
    respondJSON(w, http.StatusCreated, Response{Data: user})
}

Routing

Standard Library Router (Go 1.22+)

Go 1.22 introduced enhanced routing patterns:
func main() {
    mux := http.NewServeMux()
    
    // Method-specific routing (Go 1.22+)
    mux.HandleFunc("GET /api/users", listUsers)
    mux.HandleFunc("POST /api/users", createUser)
    mux.HandleFunc("GET /api/users/{id}", getUser)
    mux.HandleFunc("PUT /api/users/{id}", updateUser)
    mux.HandleFunc("DELETE /api/users/{id}", deleteUser)
    
    // Wildcard patterns
    mux.HandleFunc("GET /files/{path...}", serveFiles)
    
    http.ListenAndServe(":8080", mux)
}

func getUser(w http.ResponseWriter, r *http.Request) {
    // Extract path parameter (Go 1.22+)
    id := r.PathValue("id")
    
    // Or manually for older Go versions
    // id := strings.TrimPrefix(r.URL.Path, "/api/users/")
    
    user, err := findUser(id)
    if err != nil {
        respondError(w, http.StatusNotFound, "User not found")
        return
    }
    
    respondJSON(w, http.StatusOK, Response{Data: user})
}

Custom Router

type Router struct {
    routes map[string]map[string]http.HandlerFunc
}

func NewRouter() *Router {
    return &Router{
        routes: make(map[string]map[string]http.HandlerFunc),
    }
}

func (router *Router) Handle(method, path string, handler http.HandlerFunc) {
    if router.routes[path] == nil {
        router.routes[path] = make(map[string]http.HandlerFunc)
    }
    router.routes[path][method] = handler
}

func (router *Router) GET(path string, handler http.HandlerFunc) {
    router.Handle(http.MethodGet, path, handler)
}

func (router *Router) POST(path string, handler http.HandlerFunc) {
    router.Handle(http.MethodPost, path, handler)
}

func (router *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if methods, ok := router.routes[r.URL.Path]; ok {
        if handler, ok := methods[r.Method]; ok {
            handler(w, r)
            return
        }
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }
    http.NotFound(w, r)
}

Middleware

Middleware are functions that wrap handlers to add functionality.

Middleware Pattern

type Middleware func(http.Handler) http.Handler

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

Logging Middleware

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, statusCode: http.StatusOK}
        
        next.ServeHTTP(wrapped, r)
        
        log.Printf(
            "%s %s %d %s",
            r.Method,
            r.URL.Path,
            wrapped.statusCode,
            time.Since(start),
        )
    })
}

type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

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

Recovery Middleware

func RecoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("Panic recovered: %v\n%s", err, debug.Stack())
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }
        }()
        
        next.ServeHTTP(w, r)
    })
}

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.StatusOK)
            return
        }
        
        next.ServeHTTP(w, r)
    })
}

Authentication Middleware

func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if token == "" {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }
        
        // Validate token
        claims, err := validateToken(strings.TrimPrefix(token, "Bearer "))
        if err != nil {
            http.Error(w, "Invalid token", http.StatusUnauthorized)
            return
        }
        
        // Add claims to context
        ctx := context.WithValue(r.Context(), userClaimsKey{}, claims)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Rate Limiting Middleware

func RateLimitMiddleware(rps int) Middleware {
    limiter := rate.NewLimiter(rate.Limit(rps), rps)
    
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if !limiter.Allow() {
                http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

Using Middleware

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/users", usersHandler)
    mux.HandleFunc("/api/orders", ordersHandler)
    
    // Apply middleware chain
    handler := Chain(
        mux,
        RecoveryMiddleware,
        LoggingMiddleware,
        CORSMiddleware,
        RateLimitMiddleware(100),
    )
    
    http.ListenAndServe(":8080", handler)
}

HTTP Server Configuration

Production-Ready Server

func main() {
    mux := setupRoutes()
    
    srv := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
        MaxHeaderBytes: 1 << 20, // 1MB
    }
    
    // Graceful shutdown
    go func() {
        sigChan := make(chan os.Signal, 1)
        signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
        <-sigChan
        
        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer cancel()
        
        log.Println("Shutting down server...")
        if err := srv.Shutdown(ctx); err != nil {
            log.Printf("Server shutdown error: %v", err)
        }
    }()
    
    log.Printf("Server starting on %s", srv.Addr)
    if err := srv.ListenAndServe(); err != http.ErrServerClosed {
        log.Fatal(err)
    }
    
    log.Println("Server stopped")
}

TLS Configuration

func main() {
    tlsConfig := &tls.Config{
        MinVersion: tls.VersionTLS12,
        CipherSuites: []uint16{
            tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
            tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
        },
    }
    
    srv := &http.Server{
        Addr:      ":443",
        Handler:   mux,
        TLSConfig: tlsConfig,
    }
    
    log.Fatal(srv.ListenAndServeTLS("cert.pem", "key.pem"))
}

HTTP Client

Basic Client

func fetchUser(id string) (*User, error) {
    resp, err := http.Get("https://api.example.com/users/" + id)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    
    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
    }
    
    var user User
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
        return nil, err
    }
    
    return &user, nil
}

Production HTTP Client

type APIClient struct {
    baseURL    string
    httpClient *http.Client
    apiKey     string
}

func NewAPIClient(baseURL, apiKey string) *APIClient {
    return &APIClient{
        baseURL: baseURL,
        apiKey:  apiKey,
        httpClient: &http.Client{
            Timeout: 30 * time.Second,
            Transport: &http.Transport{
                MaxIdleConns:        100,
                MaxIdleConnsPerHost: 10,
                IdleConnTimeout:     90 * time.Second,
            },
        },
    }
}

func (c *APIClient) do(ctx context.Context, method, path string, body interface{}) (*http.Response, error) {
    var bodyReader io.Reader
    if body != nil {
        jsonBody, err := json.Marshal(body)
        if err != nil {
            return nil, err
        }
        bodyReader = bytes.NewReader(jsonBody)
    }
    
    req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader)
    if err != nil {
        return nil, err
    }
    
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer "+c.apiKey)
    req.Header.Set("User-Agent", "MyApp/1.0")
    
    return c.httpClient.Do(req)
}

func (c *APIClient) GetUser(ctx context.Context, id string) (*User, error) {
    resp, err := c.do(ctx, http.MethodGet, "/users/"+id, nil)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    
    if resp.StatusCode == http.StatusNotFound {
        return nil, ErrNotFound
    }
    
    if resp.StatusCode != http.StatusOK {
        body, _ := io.ReadAll(resp.Body)
        return nil, fmt.Errorf("API error: %d - %s", resp.StatusCode, string(body))
    }
    
    var user User
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
        return nil, err
    }
    
    return &user, nil
}

func (c *APIClient) CreateUser(ctx context.Context, user *CreateUserRequest) (*User, error) {
    resp, err := c.do(ctx, http.MethodPost, "/users", user)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    
    if resp.StatusCode != http.StatusCreated {
        return nil, parseAPIError(resp)
    }
    
    var created User
    if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
        return nil, err
    }
    
    return &created, nil
}

Retry with Exponential Backoff

func (c *APIClient) doWithRetry(ctx context.Context, method, path string, body interface{}) (*http.Response, error) {
    maxRetries := 3
    baseDelay := 100 * time.Millisecond
    
    var lastErr error
    for attempt := 0; attempt < maxRetries; attempt++ {
        if attempt > 0 {
            delay := baseDelay * time.Duration(1<<uint(attempt-1))
            select {
            case <-ctx.Done():
                return nil, ctx.Err()
            case <-time.After(delay):
            }
        }
        
        resp, err := c.do(ctx, method, path, body)
        if err != nil {
            lastErr = err
            continue
        }
        
        // Don't retry on client errors
        if resp.StatusCode >= 400 && resp.StatusCode < 500 {
            return resp, nil
        }
        
        // Retry on server errors
        if resp.StatusCode >= 500 {
            resp.Body.Close()
            lastErr = fmt.Errorf("server error: %d", resp.StatusCode)
            continue
        }
        
        return resp, nil
    }
    
    return nil, fmt.Errorf("max retries exceeded: %w", lastErr)
}

Chi Router

import "github.com/go-chi/chi/v5"

func main() {
    r := chi.NewRouter()
    
    // Built-in middleware
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Use(middleware.Timeout(60 * time.Second))
    
    // Routes
    r.Get("/", homeHandler)
    
    // Route groups
    r.Route("/api/v1", func(r chi.Router) {
        r.Use(AuthMiddleware)
        
        r.Route("/users", func(r chi.Router) {
            r.Get("/", listUsers)
            r.Post("/", createUser)
            r.Get("/{id}", getUser)
            r.Put("/{id}", updateUser)
            r.Delete("/{id}", deleteUser)
        })
    })
    
    http.ListenAndServe(":8080", r)
}

func getUser(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    // ...
}

Gin Framework

import "github.com/gin-gonic/gin"

func main() {
    r := gin.Default() // Includes logger and recovery
    
    // Middleware
    r.Use(CORSMiddleware())
    
    // Routes
    api := r.Group("/api/v1")
    {
        api.Use(AuthMiddleware())
        
        users := api.Group("/users")
        {
            users.GET("", listUsers)
            users.POST("", createUser)
            users.GET("/:id", getUser)
            users.PUT("/:id", updateUser)
            users.DELETE("/:id", deleteUser)
        }
    }
    
    r.Run(":8080")
}

func getUser(c *gin.Context) {
    id := c.Param("id")
    
    user, err := findUser(id)
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
        return
    }
    
    c.JSON(http.StatusOK, user)
}

func createUser(c *gin.Context) {
    var req CreateUserRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    
    user := createUserFromRequest(req)
    c.JSON(http.StatusCreated, user)
}

Echo Framework

import "github.com/labstack/echo/v4"

func main() {
    e := echo.New()
    
    // Middleware
    e.Use(middleware.Logger())
    e.Use(middleware.Recover())
    
    // Routes
    api := e.Group("/api/v1")
    api.Use(AuthMiddleware)
    
    api.GET("/users", listUsers)
    api.POST("/users", createUser)
    api.GET("/users/:id", getUser)
    
    e.Logger.Fatal(e.Start(":8080"))
}

func getUser(c echo.Context) error {
    id := c.Param("id")
    
    user, err := findUser(id)
    if err != nil {
        return c.JSON(http.StatusNotFound, map[string]string{"error": "not found"})
    }
    
    return c.JSON(http.StatusOK, user)
}

Interview Questions

  • http.Handle takes an http.Handler interface
  • http.HandleFunc takes a function with signature func(w http.ResponseWriter, r *http.Request)
HandleFunc is a convenience wrapper that converts the function to a HandlerFunc type which implements Handler.
Multiple approaches:
  1. Server-level: Set ReadTimeout, WriteTimeout on http.Server
  2. Request-level: Use context.WithTimeout with http.NewRequestWithContext
  3. Handler-level: Use http.TimeoutHandler wrapper
  4. Middleware: Create custom timeout middleware
HTTP responses must have their body closed to:
  • Release the connection back to the pool for reuse
  • Prevent resource leaks (file descriptors, memory)
  • Allow the transport to reuse connections (keep-alive)
  • Set appropriate server timeouts (ReadTimeout, WriteTimeout, IdleTimeout)
  • Use http.MaxBytesReader to limit request body size
  • Implement request deadlines with context
  • Use rate limiting middleware

Summary

TopicKey Points
net/httpBuilt-in, production-ready, Handler interface
RoutingGo 1.22+ patterns, or use Chi/Gin/Echo
MiddlewareChain pattern, logging, auth, recovery
JSONEncoder/Decoder, validation, proper error handling
ClientTimeouts, connection pooling, retry logic
SecurityTLS, rate limiting, input validation