API Server

Build a REST API using only Go’s standard library. Go 1.22+ added pattern matching to http.NewServeMux, so you rarely need a framework for simple APIs.

Minimal Server

package main

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

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("ok"))
    })

    log.Println("listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}
go run main.go &
curl http://localhost:8080/health
# ok

Route Patterns (Go 1.22+)

The new ServeMux supports HTTP methods and path parameters:

mux := http.NewServeMux()

mux.HandleFunc("GET /users", listUsers)         // GET only
mux.HandleFunc("POST /users", createUser)        // POST only
mux.HandleFunc("GET /users/{id}", getUser)       // path param
mux.HandleFunc("PUT /users/{id}", updateUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)
mux.HandleFunc("GET /files/{path...}", serveFile) // wildcard (rest of path)

Extract path parameters:

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

Tip: Routes with methods are more specific and take priority. "GET /users" matches before "/users".

JSON API

Here is a complete CRUD API for a todo list:

package main

import (
    "encoding/json"
    "log/slog"
    "net/http"
    "sync"
)

type Todo struct {
    ID   string `json:"id"`
    Text string `json:"text"`
    Done bool   `json:"done"`
}

type TodoStore struct {
    mu    sync.RWMutex
    todos map[string]Todo
    next  int
}

func NewTodoStore() *TodoStore {
    return &TodoStore{todos: make(map[string]Todo)}
}

func main() {
    store := NewTodoStore()
    mux := http.NewServeMux()

    mux.HandleFunc("GET /todos", store.list)
    mux.HandleFunc("POST /todos", store.create)
    mux.HandleFunc("GET /todos/{id}", store.get)
    mux.HandleFunc("DELETE /todos/{id}", store.remove)

    slog.Info("server starting", "addr", ":8080")
    http.ListenAndServe(":8080", mux)
}

Handlers

func (s *TodoStore) list(w http.ResponseWriter, r *http.Request) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    todos := make([]Todo, 0, len(s.todos))
    for _, t := range s.todos {
        todos = append(todos, t)
    }
    writeJSON(w, http.StatusOK, todos)
}

func (s *TodoStore) create(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Text string `json:"text"`
    }
    if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
        writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
        return
    }
    if input.Text == "" {
        writeJSON(w, http.StatusBadRequest, map[string]string{"error": "text required"})
        return
    }

    s.mu.Lock()
    defer s.mu.Unlock()
    s.next++
    id := fmt.Sprintf("%d", s.next)
    todo := Todo{ID: id, Text: input.Text}
    s.todos[id] = todo

    writeJSON(w, http.StatusCreated, todo)
}

func (s *TodoStore) get(w http.ResponseWriter, r *http.Request) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    todo, ok := s.todos[r.PathValue("id")]
    if !ok {
        writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
        return
    }
    writeJSON(w, http.StatusOK, todo)
}

func (s *TodoStore) remove(w http.ResponseWriter, r *http.Request) {
    s.mu.Lock()
    defer s.mu.Unlock()

    id := r.PathValue("id")
    if _, ok := s.todos[id]; !ok {
        writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
        return
    }
    delete(s.todos, id)
    w.WriteHeader(http.StatusNoContent)
}

JSON Helper

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

Middleware

Middleware wraps handlers to add cross-cutting behavior. The pattern is a function that takes and returns http.Handler:

func logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        slog.Info("request",
            "method", r.Method,
            "path", r.URL.Path,
            "duration", time.Since(start),
        )
    })
}

func cors(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")
        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 middleware by wrapping the mux:

func main() {
    mux := http.NewServeMux()
    // ... register routes

    handler := logging(cors(mux))
    http.ListenAndServe(":8080", handler)
}

Testing the API

# Create
curl -X POST http://localhost:8080/todos \
  -H "Content-Type: application/json" \
  -d '{"text": "Learn Go"}'

# List
curl http://localhost:8080/todos

# Get one
curl http://localhost:8080/todos/1

# Delete
curl -X DELETE http://localhost:8080/todos/1

Or test programmatically with httptest:

func TestListTodos(t *testing.T) {
    store := NewTodoStore()
    store.todos["1"] = Todo{ID: "1", Text: "test"}

    req := httptest.NewRequest("GET", "/todos", nil)
    w := httptest.NewRecorder()

    store.list(w, req)

    if w.Code != http.StatusOK {
        t.Fatalf("expected 200, got %d", w.Code)
    }

    var todos []Todo
    json.NewDecoder(w.Body).Decode(&todos)
    if len(todos) != 1 {
        t.Fatalf("expected 1 todo, got %d", len(todos))
    }
}

Tip: httptest.NewServer creates a real HTTP server for integration tests. Use it when you need to test middleware chains or full request lifecycle.

Next: Setup | Testing | Ecosystem