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.NewServercreates a real HTTP server for integration tests. Use it when you need to test middleware chains or full request lifecycle.