Skip to content
AgileLogics
9 min read

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.WriteHeader twice — the second status is ignored and Go logs "superfluous response.WriteHeader call".
  • Forgetting return after writing an error, so the handler keeps going and writes a second response.
  • Reading an unbounded request body. http.MaxBytesReader caps 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)