Building a REST API
Prerequisites: Working with databases: database/sql
With Go 1.22's routing patterns, net/http alone is enough for a clean JSON
API. This lesson builds a small one and pulls the repetitive parts into
helpers.
JSON helpers
Every handler writes JSON and many read it. Two small helpers keep handlers short and behaviour consistent:
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("encode response", "err", err)
}
}
func readJSON(w http.ResponseWriter, r *http.Request, dst any) error {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB limit
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return fmt.Errorf("invalid JSON: %w", err)
}
return nil
}
Headers must be set before WriteHeader; after it, changes are ignored.
The full service
package main
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
type Store struct {
mu sync.Mutex
nextID int
todos map[int]Todo
}
func NewStore() *Store {
return &Store{nextID: 1, todos: make(map[int]Todo)}
}
type API struct {
store *Store
}
func (a *API) createTodo(w http.ResponseWriter, r *http.Request) {
var in struct {
Title string `json:"title"`
}
if err := readJSON(w, r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
in.Title = strings.TrimSpace(in.Title)
if in.Title == "" || len(in.Title) > 200 {
writeError(w, http.StatusUnprocessableEntity, "title must be 1-200 characters")
return
}
a.store.mu.Lock()
t := Todo{ID: a.store.nextID, Title: in.Title}
a.store.todos[t.ID] = t
a.store.nextID++
a.store.mu.Unlock()
w.Header().Set("Location", fmt.Sprintf("/todos/%d", t.ID))
writeJSON(w, http.StatusCreated, t)
}
func (a *API) getTodo(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "id must be an integer")
return
}
a.store.mu.Lock()
t, ok := a.store.todos[id]
a.store.mu.Unlock()
if !ok {
writeError(w, http.StatusNotFound, "todo not found")
return
}
writeJSON(w, http.StatusOK, t)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("encode response", "err", err)
}
}
func readJSON(w http.ResponseWriter, r *http.Request, dst any) error {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return fmt.Errorf("invalid JSON: %w", err)
}
return nil
}
// Middleware: a function that wraps one handler in another.
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 recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
slog.Error("panic", "err", err)
writeError(w, http.StatusInternalServerError, "internal error")
}
}()
next.ServeHTTP(w, r)
})
}
func main() {
api := &API{store: NewStore()}
mux := http.NewServeMux()
mux.HandleFunc("POST /todos", api.createTodo)
mux.HandleFunc("GET /todos/{id}", api.getTodo)
handler := recoverer(logging(mux))
slog.Info("listening", "addr", ":8080")
if err := http.ListenAndServe(":8080", handler); err != nil {
slog.Error("server stopped", "err", err)
}
}
Try it:
$ curl -i -X POST localhost:8080/todos -d '{"title":"write tests"}'
HTTP/1.1 201 Created
Content-Type: application/json
Location: /todos/1
...
{"id":1,"title":"write tests","done":false}
$ curl localhost:8080/todos/1
{"id":1,"title":"write tests","done":false}
$ curl localhost:8080/todos/99
{"error":"todo not found"}
$ curl -X POST localhost:8080/todos -d '{"title":""}'
{"error":"title must be 1-200 characters"}
The mux also answers 405 Method Not Allowed automatically for, say,
DELETE /todos/1, because a pattern exists for that path with other
methods. (This demo uses http.ListenAndServe for brevity; the Security
lesson explains why production servers need timeouts.)
Status codes worth getting right
| Situation | Code |
|---|---|
| Read succeeded | 200 OK |
| Resource created | 201 Created (+ Location header) |
| Success, no body (e.g. delete) | 204 No Content |
| Malformed JSON, bad path parameter | 400 Bad Request |
| Not authenticated / not allowed | 401 Unauthorized / 403 Forbidden |
| Resource doesn't exist | 404 Not Found |
| Well-formed but fails validation | 422 Unprocessable Entity |
| Unexpected server failure | 500 Internal Server Error |
Never leak internal error details (SQL errors, stack traces) in a 500
response body — log them, return a generic message.
Middleware
Middleware has the signature func(http.Handler) http.Handler. It can run
code before and after the wrapped handler, short-circuit (for example, reject
unauthenticated requests), or add values to the request context. Order
matters: in recoverer(logging(mux)) the recoverer is outermost, so it also
catches panics from the logger.
Common mistakes
- Calling
w.WriteHeadertwice — the second status is ignored and Go logs "superfluous response.WriteHeader call". - Forgetting
returnafter writing an error, so the handler keeps going and writes a second response. - Reading an unbounded request body.
http.MaxBytesReadercaps it. - Sharing a map between handlers without a mutex — every request runs on its own goroutine.
Practice
Add DELETE /todos/{id} to the service. Respond 204 No Content on success
and 404 with the JSON error body if the todo doesn't exist.
Show solution
func (a *API) deleteTodo(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "id must be an integer")
return
}
a.store.mu.Lock()
_, ok := a.store.todos[id]
delete(a.store.todos, id)
a.store.mu.Unlock()
if !ok {
writeError(w, http.StatusNotFound, "todo not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
// In main:
// mux.HandleFunc("DELETE /todos/{id}", api.deleteTodo)