Una API REST de lista de tareas en Go sin paquetes de terceros. Construye el store, las rutas, la decodificación segura de JSON, la validación, los códigos de estado, el middleware y los logs con slog, capa por capa.
Una API REST JSON es un conjunto de URLs que los programas llaman para leer y cambiar datos, con JSON en los cuerpos de la petición y de la respuesta. La biblioteca estándar de Go trae todo lo que necesitas para construir una: net/http para el ruteo, encoding/json para los cuerpos y log/slog para los logs.
Este post construye una pequeña API de lista de tareas como un módulo real, capa por capa. El código que ves sale directo de los archivos del módulo, que pasan go vet y go test -race. Cada programa de abajo se ejecutó en Go 1.26, y su salida está copiada de esa ejecución.
Qué hace la API
La API guarda una lista de tareas, y cada tarea tiene un ID, un título y un indicador de completada. Cinco rutas cubren leerlas, crearlas, reemplazarlas y borrarlas:
| Petición | Qué hace | Éxito | Errores del cliente |
|---|---|---|---|
GET /tasks |
lista todas las tareas | 200 | |
POST /tasks |
crea una tarea | 201 con Location |
400, 413, 415, 422 |
GET /tasks/{id} |
trae una tarea | 200 | 404 |
PUT /tasks/{id} |
reemplaza una tarea | 200 | 400, 404, 413, 415, 422 |
DELETE /tasks/{id} |
borra una tarea | 204 | 404 |
Cualquier ruta también puede responder 500. Una ruta conocida con el método equivocado recibe 405, y una ruta desconocida recibe 404.
El módulo tiene dos paquetes bajo internal/ y un comando:
19-tasks-api/
├── go.mod
├── cmd/
│ └── tasksd/
│ └── main.go
└── internal/
├── task/
│ ├── task.go
│ └── memstore.go
└── api/
├── api.go
├── handlers.go
├── json.go
├── validate.go
└── middleware.go
task no sabe nada de HTTP, y api no sabe nada de dónde se guardan las tareas. cmd/tasksd elige un store y arranca un servidor. Esta parte deja fuera los tests, porque la siguiente parte, sobre testing y despliegue, trata de ellos.
El store: una interfaz delante de un map
En esta API, un store de tareas es cualquier cosa con cinco métodos, así que los handlers dependen de una interfaz, no de un map. Aquí está task.go, el primero de los dos archivos del paquete:
// Package task defines a task and the storage behind it.
package task
import (
"context"
"errors"
)
// Task is one item on the list.
type Task struct {
ID int64 `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
// ErrNotFound is returned when no task has the requested ID.
var ErrNotFound = errors.New("task not found")
// Store is everything the API needs from storage.
type Store interface {
List(ctx context.Context) ([]Task, error)
Get(ctx context.Context, id int64) (Task, error)
Create(ctx context.Context, t Task) (Task, error)
Update(ctx context.Context, t Task) (Task, error)
Delete(ctx context.Context, id int64) error
}
Los tags JSON les dan a los campos nombres en minúscula en la red. ErrNotFound es un error centinela, como io.EOF en la parte sobre errores. Cada método recibe primero un context.Context. El store en memoria lo ignora, pero un store con base de datos se lo pasaría a cada consulta, así que una petición cancelada también detiene su consulta.
MemStore es la implementación que usa este post:
// The build fails here if MemStore stops satisfying Store.
var _ Store = (*MemStore)(nil)
// MemStore keeps tasks in memory. It's safe for concurrent use.
type MemStore struct {
mu sync.RWMutex
lastID int64
tasks map[int64]Task
}
// NewMemStore returns an empty store. The first task gets ID 1.
func NewMemStore() *MemStore {
return &MemStore{tasks: map[int64]Task{}}
}
// List returns every task, ordered by ID. It never returns nil.
func (s *MemStore) List(_ context.Context) ([]Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
list := make([]Task, 0, len(s.tasks))
for _, id := range slices.Sorted(maps.Keys(s.tasks)) {
list = append(list, s.tasks[id])
}
return list, nil
}
// Get returns the task with the given ID, or ErrNotFound.
func (s *MemStore) Get(_ context.Context, id int64) (Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
t, ok := s.tasks[id]
if !ok {
return Task{}, ErrNotFound
}
return t, nil
}
// Create stores t under the next ID and returns it with the ID set.
// Any ID already in t is ignored.
func (s *MemStore) Create(_ context.Context, t Task) (Task, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.lastID++
t.ID = s.lastID
s.tasks[t.ID] = t
return t, nil
}
Update y Delete siguen la misma forma, con Lock. Cada petición corre en su propia goroutine, así que sin el mutex dos peticiones POST podrían incrementar lastID a la vez y recibir el mismo ID. Las lecturas toman el lock de lectura, así que muchas peticiones pueden listar tareas al mismo tiempo. La línea var _ Store es la verificación en tiempo de compilación de la parte sobre interfaces.
List arma su resultado con make, no con var list []Task, y el comentario dice que nunca devuelve nil. Eso importa cuando el slice se convierte en JSON:
package main
import (
"encoding/json"
"fmt"
)
type Task struct {
ID int64 `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
func main() {
var none []Task
empty := []Task{}
one := []Task{{ID: 1, Title: "Buy milk"}}
for _, tasks := range [][]Task{none, empty, one} {
b, err := json.Marshal(tasks)
fmt.Println(string(b), err)
}
}
Imprime:
null <nil>
[] <nil>
[{"id":1,"title":"Buy milk","done":false}] <nil>
Un slice nil se codifica como null. Uno vacío se codifica como []. Un cliente JavaScript que recorre null lanza un error, así que una lista de tareas vacía tiene que ser [].
El constructor: rutas y dependencias
El paquete api expone una sola función, New, que construye toda la API como un único http.Handler. Registra las rutas con los patrones de método y ruta que llegaron en Go 1.22, que la parte anterior sobre servidores net/http explica en detalle:
// server holds the dependencies every handler needs.
type server struct {
store task.Store
logger *slog.Logger
}
// New returns the API as an http.Handler. It keeps no global state:
// every call builds a fresh handler, with its own request ID counter.
func New(store task.Store, logger *slog.Logger) http.Handler {
s := &server{store: store, logger: logger}
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks", s.listTasks)
mux.HandleFunc("POST /tasks", s.createTask)
mux.HandleFunc("GET /tasks/{id}", s.getTask)
mux.HandleFunc("PUT /tasks/{id}", s.updateTask)
mux.HandleFunc("DELETE /tasks/{id}", s.deleteTask)
var h http.Handler = s.jsonRouteErrors(mux)
h = s.recoverPanics(h)
h = s.logRequests(h)
h = withRequestID(h)
return h
}
New recibe el store y el logger como argumentos y los guarda en un struct server. No hay variables a nivel de paquete ni función init, así que cada llamada construye un handler separado. Un test puede construir uno con un store nuevo y un logger que escribe en un buffer. Las cuatro líneas después de las rutas envuelven el mux en middleware, que veremos más adelante en este post.
Handlers: leer la petición, llamar al store, escribir la respuesta
Cada handler de la API lee lo que necesita de la petición, llama al store y convierte el resultado en un código de estado y un cuerpo. createTask es el más largo:
func (s *server) createTask(w http.ResponseWriter, r *http.Request) {
var in taskInput
if err := decodeJSON(w, r, &in); err != nil {
s.clientError(w, err)
return
}
if fields := in.validate(); len(fields) > 0 {
s.writeError(w, http.StatusUnprocessableEntity, "validation failed", fields)
return
}
t, err := s.store.Create(r.Context(), task.Task{Title: in.Title, Done: in.Done})
if err != nil {
s.internalError(w, r, err)
return
}
w.Header().Set("Location", "/tasks/"+strconv.FormatInt(t.ID, 10))
s.writeJSON(w, http.StatusCreated, t)
}
Cada paso que puede fallar escribe su propia respuesta y retorna, así que el handler nunca escribe dos veces. Después de crear con éxito, pone en Location la URL de la tarea nueva y responde 201 Created con la tarea en el cuerpo, así que el cliente conoce el ID sin una segunda petición.
getTask muestra cómo los errores del store se convierten en códigos de estado:
func (s *server) getTask(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(r)
if !ok {
s.notFound(w)
return
}
t, err := s.store.Get(r.Context(), id)
if errors.Is(err, task.ErrNotFound) {
s.notFound(w)
return
}
if err != nil {
s.internalError(w, r, err)
return
}
s.writeJSON(w, http.StatusOK, t)
}
La verificación usa errors.Is, no ==, así que sigue funcionando si un store con base de datos envuelve ErrNotFound con %w. Cualquier otro error es culpa del servidor, así que se convierte en un 500.
El ID sale de la ruta a través de un pequeño helper:
// parseID reads the {id} wildcard. Only positive integers name a task.
func parseID(r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
return id, err == nil && id > 0
}
/tasks/abc y /tasks/-3 responden 404, igual que /tasks/999, porque no hay ninguna tarea en esas URLs.
Por qué PUT y no PATCH
La API reemplaza tareas con PUT y no ofrece PATCH. Con PUT, el cliente envía la tarea completa, y enviarla dos veces da el mismo resultado, así que un cliente puede reintentar sin riesgo después de un timeout.
PATCH significa “cambia solo los campos que envío”, así que el handler tendría que distinguir un done ausente de "done": false. En Go eso necesita campos puntero como *bool. Una tarea tiene dos campos, así que enviar ambos casi no cuesta nada. El precio es que omitir done lo pone en false. Con veinte campos, PATCH sí justificaría su código extra.
Leer JSON sin confiar en él
El cuerpo de una petición viene de quien sea que la envió, así que la API lo decodifica con un solo helper que pone límites antes de leer un solo byte. Aquí está decodeJSON:
// decodeJSON reads exactly one JSON value from the body into dst.
// Every error it returns is a *requestError.
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
if mediaType != "application/json" {
return &requestError{http.StatusUnsupportedMediaType, "Content-Type must be application/json"}
}
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
var tooBig *http.MaxBytesError
switch {
case errors.As(err, &tooBig):
msg := fmt.Sprintf("request body must not be larger than %d bytes", tooBig.Limit)
return &requestError{http.StatusRequestEntityTooLarge, msg}
case errors.Is(err, io.EOF):
return &requestError{http.StatusBadRequest, "request body must not be empty"}
case errors.Is(err, io.ErrUnexpectedEOF):
return &requestError{http.StatusBadRequest, "request body ends in the middle of the JSON"}
case errors.As(err, &syntaxErr):
msg := fmt.Sprintf("malformed JSON at byte %d", syntaxErr.Offset)
return &requestError{http.StatusBadRequest, msg}
case errors.As(err, &typeErr) && typeErr.Field == "":
return &requestError{http.StatusBadRequest, "request body must be a JSON object"}
case errors.As(err, &typeErr):
msg := fmt.Sprintf("field %q has the wrong type", typeErr.Field)
return &requestError{http.StatusBadRequest, msg}
case strings.HasPrefix(err.Error(), "json: unknown field "):
field := strings.TrimPrefix(err.Error(), "json: unknown field ")
return &requestError{http.StatusBadRequest, "unknown field " + field}
default:
return &requestError{http.StatusBadRequest, "malformed JSON"}
}
}
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return &requestError{http.StatusBadRequest, "request body must hold a single JSON value"}
}
return nil
}
Toma cuatro decisiones, y cada fallo recibe un mensaje con el que el cliente puede actuar:
- El
Content-Typedebe serapplication/json, con o sin; charset=utf-8. Cualquier otro recibe 415 Unsupported Media Type, no 400, porque el cuerpo podría estar bien y lo que está mal es la etiqueta. El estado le dice al cliente qué corregir. - El cuerpo tiene un tope de 1 MiB con
http.MaxBytesReader. Pasado el límite, las lecturas fallan con*http.MaxBytesError, y el helper responde 413. Sin un tope, un solo cliente podría hacer que el decodificador guarde gigabytes. - Los campos desconocidos se rechazan con
DisallowUnknownFields, así que un error de tipeo como"titel"es un error en vez de ignorarse en silencio.encoding/jsonno tiene un tipo de error para este caso, así que el helper compara el prefijo del mensaje. - Solo se permite un valor JSON.
Decodelee un valor y se detiene, así que un segundoDecodetiene que llegar aio.EOF.
Este programa pone el mismo decodeJSON, copiado sin cambios, frente a once cuerpos de petición:
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/http/httptest"
"strings"
)
// maxBodyBytes caps a request body at 1 MiB.
const maxBodyBytes = 1 << 20
// requestError is a problem with the request that the client can fix.
type requestError struct {
status int
message string
}
func (e *requestError) Error() string { return e.message }
// decodeJSON reads exactly one JSON value from the body into dst.
// Every error it returns is a *requestError.
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
if mediaType != "application/json" {
return &requestError{http.StatusUnsupportedMediaType, "Content-Type must be application/json"}
}
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
var tooBig *http.MaxBytesError
switch {
case errors.As(err, &tooBig):
msg := fmt.Sprintf("request body must not be larger than %d bytes", tooBig.Limit)
return &requestError{http.StatusRequestEntityTooLarge, msg}
case errors.Is(err, io.EOF):
return &requestError{http.StatusBadRequest, "request body must not be empty"}
case errors.Is(err, io.ErrUnexpectedEOF):
return &requestError{http.StatusBadRequest, "request body ends in the middle of the JSON"}
case errors.As(err, &syntaxErr):
msg := fmt.Sprintf("malformed JSON at byte %d", syntaxErr.Offset)
return &requestError{http.StatusBadRequest, msg}
case errors.As(err, &typeErr) && typeErr.Field == "":
return &requestError{http.StatusBadRequest, "request body must be a JSON object"}
case errors.As(err, &typeErr):
msg := fmt.Sprintf("field %q has the wrong type", typeErr.Field)
return &requestError{http.StatusBadRequest, msg}
case strings.HasPrefix(err.Error(), "json: unknown field "):
field := strings.TrimPrefix(err.Error(), "json: unknown field ")
return &requestError{http.StatusBadRequest, "unknown field " + field}
default:
return &requestError{http.StatusBadRequest, "malformed JSON"}
}
}
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return &requestError{http.StatusBadRequest, "request body must hold a single JSON value"}
}
return nil
}
type taskInput struct {
Title string `json:"title"`
Done bool `json:"done"`
}
func try(name, contentType, body string) {
req := httptest.NewRequest("POST", "/tasks", strings.NewReader(body))
req.Header.Set("Content-Type", contentType)
var in taskInput
err := decodeJSON(httptest.NewRecorder(), req, &in)
var re *requestError
if errors.As(err, &re) {
fmt.Printf("%-13s %d %s\n", name, re.status, re.message)
return
}
fmt.Printf("%-13s ok %+v\n", name, in)
}
func main() {
const ct = "application/json"
try("valid", ct, `{"title":"Buy milk"}`)
try("charset", ct+"; charset=utf-8", `{"title":"Buy milk","done":true}`)
try("form", "application/x-www-form-urlencoded", "title=Buy+milk")
try("empty", ct, "")
try("broken", ct, `{"title" "Buy milk"}`)
try("cut off", ct, `{"title":"Buy`)
try("unknown", ct, `{"title":"Buy milk","id":7}`)
try("wrong type", ct, `{"title":"Buy milk","done":"yes"}`)
try("array", ct, `["Buy milk"]`)
try("two values", ct, `{"title":"a"}{"title":"b"}`)
try("too big", ct, `{"title":"`+strings.Repeat("a", 2<<20)+`"}`)
}
Imprime:
valid ok {Title:Buy milk Done:false}
charset ok {Title:Buy milk Done:true}
form 415 Content-Type must be application/json
empty 400 request body must not be empty
broken 400 malformed JSON at byte 10
cut off 400 request body ends in the middle of the JSON
unknown 400 unknown field "id"
wrong type 400 field "done" has the wrong type
array 400 request body must be a JSON object
two values 400 request body must hold a single JSON value
too big 413 request body must not be larger than 1048576 bytes
Mira la línea unknown. El tipo de entrada no tiene campo ID, así que un cliente no puede elegir su propio ID enviando "id": 7. El store elige los IDs, y la URL los nombra.
encoding/json/v2 existe en el código fuente de Go 1.26, pero solo detrás de GOEXPERIMENT=jsonv2, así que este módulo se queda con encoding/json.
Validación, y por qué es 422
La validación revisa los valores de una petición que se decodificó sin problemas, y reporta los problemas por campo. Aquí está validate.go completo:
// maxTitleLen is the longest title allowed, counted in characters.
const maxTitleLen = 200
// taskInput is what a client sends for POST and PUT. It has no ID field:
// the ID comes from the store or the URL, never from the body.
type taskInput struct {
Title string `json:"title"`
Done bool `json:"done"`
}
// validate trims the title in place, then returns one message per
// invalid field, or nil if the input is fine.
func (in *taskInput) validate() map[string]string {
fields := map[string]string{}
in.Title = strings.TrimSpace(in.Title)
switch {
case in.Title == "":
fields["title"] = "must not be empty"
case utf8.RuneCountInString(in.Title) > maxTitleLen:
fields["title"] = "must be at most 200 characters"
}
if len(fields) == 0 {
return nil
}
return fields
}
La longitud se cuenta con utf8.RuneCountInString, no con len, que cuenta bytes. Un título en chino también tiene 200 caracteres.
Cuando validate devuelve campos, el handler responde 422 Unprocessable Content. Un 400 de decodeJSON significa “no pude leer tu petición”. Un 422 significa “la leí bien, y los valores rompen una regla”. Un cliente puede mostrar los mensajes por campo junto a sus cajas de texto, y tratar un 400 como un bug propio. La constante de Go es http.StatusUnprocessableEntity, y su texto de estado todavía dice “Unprocessable Entity”, el nombre anterior del mismo código.
Una sola forma JSON para cada error
Cada error que envía la API, de 400 a 500, es un objeto JSON con un mensaje error y, para la validación, un objeto fields. Un cliente necesita un solo fragmento de código para leer cualquiera de ellos:
// errorBody is the one shape every error response uses.
type errorBody struct {
Error string `json:"error"`
Fields map[string]string `json:"fields,omitempty"`
}
// writeJSON encodes v before writing anything, so an encoding failure
// can still become a clean 500.
func (s *server) writeJSON(w http.ResponseWriter, status int, v any) {
body, err := json.Marshal(v)
if err != nil {
s.logger.Error("encode response", "err", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
io.WriteString(w, `{"error":"internal server error"}`+"\n")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write(append(body, '\n'))
}
omitempty quita fields cuando no hay ninguno, así que un 404 es solo {"error":"task not found"}.
writeJSON llama a json.Marshal antes de escribir nada. Una vez que corre WriteHeader, el estado ya no se puede deshacer, así que un encoder que falla a la mitad le dejaría al cliente un 200 con medio cuerpo. Serializar primero significa que un fallo todavía puede convertirse en un 500 limpio.
Un 500 nunca le dice al cliente por qué:
// internalError logs the real error and tells the client nothing about it.
func (s *server) internalError(w http.ResponseWriter, r *http.Request, err error) {
s.logger.ErrorContext(r.Context(), "internal error", "err", err, "request_id", requestIDFrom(r.Context()))
s.writeError(w, http.StatusInternalServerError, "internal server error", nil)
}
El error real, que podría nombrar un host de base de datos o una ruta de archivo, va al log. El cliente recibe internal server error y un ID de petición en el header X-Request-Id, así que un reporte de bug puede apuntar a la línea del log.
Códigos de estado, de principio a fin
Un código de estado es lo primero que lee un cliente, así que cada respuesta de la API elige uno a propósito. Un programa independiente no puede importar los paquetes internal/ del módulo, así que este programa es una copia recortada de la API: un map como store y un paso de decodificación corto, con los mismos estados y la misma forma de error. Corre sobre httptest.NewServer, un servidor HTTP real en un puerto local aleatorio:
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
)
type Task struct {
ID int64 `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
// A trimmed version of the module's API: a map for a store, and a short
// decode step in place of decodeJSON.
type api struct {
mu sync.Mutex
lastID int64
tasks map[int64]Task
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
type errorBody struct {
Error string `json:"error"`
Fields map[string]string `json:"fields,omitempty"`
}
func writeError(w http.ResponseWriter, status int, msg string, fields map[string]string) {
writeJSON(w, status, errorBody{Error: msg, Fields: fields})
}
func (a *api) decode(w http.ResponseWriter, r *http.Request) (Task, bool) {
var in struct {
Title string `json:"title"`
Done bool `json:"done"`
}
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "malformed JSON", nil)
return Task{}, false
}
if strings.TrimSpace(in.Title) == "" {
writeError(w, http.StatusUnprocessableEntity, "validation failed",
map[string]string{"title": "must not be empty"})
return Task{}, false
}
return Task{Title: in.Title, Done: in.Done}, true
}
func (a *api) create(w http.ResponseWriter, r *http.Request) {
t, ok := a.decode(w, r)
if !ok {
return
}
a.mu.Lock()
a.lastID++
t.ID = a.lastID
a.tasks[t.ID] = t
a.mu.Unlock()
w.Header().Set("Location", "/tasks/"+strconv.FormatInt(t.ID, 10))
writeJSON(w, http.StatusCreated, t)
}
func (a *api) get(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
a.mu.Lock()
t, ok := a.tasks[id]
a.mu.Unlock()
if !ok {
writeError(w, http.StatusNotFound, "task not found", nil)
return
}
writeJSON(w, http.StatusOK, t)
}
func (a *api) update(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
t, ok := a.decode(w, r)
if !ok {
return
}
t.ID = id
a.mu.Lock()
_, found := a.tasks[id]
if found {
a.tasks[id] = t
}
a.mu.Unlock()
if !found {
writeError(w, http.StatusNotFound, "task not found", nil)
return
}
writeJSON(w, http.StatusOK, t)
}
func (a *api) remove(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
a.mu.Lock()
_, found := a.tasks[id]
delete(a.tasks, id)
a.mu.Unlock()
if !found {
writeError(w, http.StatusNotFound, "task not found", nil)
return
}
w.WriteHeader(http.StatusNoContent)
}
func main() {
a := &api{tasks: map[int64]Task{}}
mux := http.NewServeMux()
mux.HandleFunc("POST /tasks", a.create)
mux.HandleFunc("GET /tasks/{id}", a.get)
mux.HandleFunc("PUT /tasks/{id}", a.update)
mux.HandleFunc("DELETE /tasks/{id}", a.remove)
srv := httptest.NewServer(mux)
defer srv.Close()
send := func(method, path, body string) {
req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
fmt.Println(method, path, "->", resp.Status)
if loc := resp.Header.Get("Location"); loc != "" {
fmt.Println(" Location:", loc)
}
if len(out) > 0 {
fmt.Print(" ", string(out))
}
}
send("POST", "/tasks", `{"title":"Buy milk"}`)
send("GET", "/tasks/1", "")
send("PUT", "/tasks/1", `{"title":"Buy milk","done":true}`)
send("POST", "/tasks", `{"title":" "}`)
send("DELETE", "/tasks/1", "")
send("GET", "/tasks/1", "")
send("PATCH", "/tasks/1", `{"done":false}`)
}
Imprime:
POST /tasks -> 201 Created
Location: /tasks/1
{"id":1,"title":"Buy milk","done":false}
GET /tasks/1 -> 200 OK
{"id":1,"title":"Buy milk","done":false}
PUT /tasks/1 -> 200 OK
{"id":1,"title":"Buy milk","done":true}
POST /tasks -> 422 Unprocessable Entity
{"error":"validation failed","fields":{"title":"must not be empty"}}
DELETE /tasks/1 -> 204 No Content
GET /tasks/1 -> 404 Not Found
{"error":"task not found"}
PATCH /tasks/1 -> 405 Method Not Allowed
Method Not Allowed
Esa es la vida de una tarea. DELETE responde 204 No Content, que no debe tener cuerpo, así que el handler llama a WriteHeader y no escribe nada. Después de eso, la tarea es un 404.
La última respuesta es texto plano, Method Not Allowed, porque la copia recortada le deja el 405 al mux. El módulo real lo corrige con un middleware en la siguiente sección.
Middleware: un handler que envuelve un handler
En Go, un middleware es una función de tipo func(http.Handler) http.Handler: recibe un handler y devuelve uno nuevo que hace trabajo antes o después de llamarlo. Este programa envuelve un handler en tres capas que imprimen a medida que pasa la petición:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
// layer returns a middleware that prints when a request passes in and out.
func layer(name, indent string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println(indent + name + ": in")
next.ServeHTTP(w, r)
fmt.Println(indent + name + ": out")
})
}
}
func main() {
var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println(" handler")
})
h = layer("recover", " ")(h)
h = layer("log", " ")(h)
h = layer("request id", "")(h)
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/tasks", nil))
}
Imprime:
request id: in
log: in
recover: in
handler
recover: out
log: out
request id: out
La última capa aplicada, request id, es la más externa, así que corre primero. El código después de next.ServeHTTP corre de salida, en orden inverso.
Explicado como si tuvieras diez años
Imagina que mandas una carta a una oficina grande. En la puerta de entrada, alguien le estampa un número al sobre. Luego, un empleado anota la hora en que llegó. Después pasa por un ayudante de primeros auxilios, que está ahí por si algo sale mal. Solo entonces llega a la persona que escribe la respuesta.
La respuesta sale por el mismo camino, al revés. El ayudante de primeros auxilios la deja pasar. El empleado anota “carta 7: respondida, sí”. La puerta de entrada la despacha.
Cada persona solo sabe a quién pasarle la carta después.
La versión precisa
Un middleware devuelve un http.HandlerFunc que captura next en un closure. a(b(c(h))) arma una cadena en la que el handler de a llama al de b, y así hasta h. El código antes de next.ServeHTTP corre de afuera hacia adentro. El código después, incluidas las funciones diferidas, corre de adentro hacia afuera. Por eso un recover diferido en una capa atrapa un panic de cualquier capa que tenga adentro.
Una capa puede responder por su cuenta y nunca llamar a next, o cambiar la petición a la entrada con r.WithContext. No puede leer la respuesta a la salida a menos que envuelva el http.ResponseWriter.
Dónde falla la analogía: la respuesta no es una segunda carta que vuelve pasando por todos. El handler escribe directo en el http.ResponseWriter, y los bytes pueden llegar al cliente antes de que corra el código “de después” de las capas externas. Por eso la capa de logging de más abajo envuelve el writer para averiguar el código de estado.
El orden en que New las construye. Una petición pasa por cada capa a la entrada, y el código de cada capa después de next.ServeHTTP corre a la salida, empezando por la más interna.
El primer middleware del módulo le da un ID a cada petición:
// requestIDKey is the context key for the request ID. It's unexported,
// so no other package can read or overwrite the value by accident.
type requestIDKey struct{}
// withRequestID gives every request an ID from a counter, stores it in the
// request's context and sends it back in the X-Request-Id header.
func withRequestID(next http.Handler) http.Handler {
var counter atomic.Uint64
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := "req-" + strconv.FormatUint(counter.Add(1), 10)
w.Header().Set("X-Request-Id", id)
ctx := context.WithValue(r.Context(), requestIDKey{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// requestIDFrom returns the request ID stored by withRequestID, or "".
func requestIDFrom(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey{}).(string)
return id
}
El contador es local a withRequestID, así que cada llamada a New vuelve a empezar en req-1, lo que mantiene los IDs predecibles en los tests. Es un atomic.Uint64 porque las peticiones corren en paralelo. El ID viaja en el context bajo un tipo de clave no exportado, el patrón de la parte sobre context.
El middleware más interno hace que los errores propios del mux sigan la forma de error de la API:
// jsonRouteErrors lets the mux decide 404 and 405 for requests that match no
// pattern, including the Allow header, but sends the body as JSON.
func (s *server) jsonRouteErrors(mux *http.ServeMux) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h, pattern := mux.Handler(r)
if pattern != "" {
mux.ServeHTTP(w, r)
return
}
// No pattern matched. h is the mux's own 404 or 405 handler.
// Run it against a writer that keeps the status and drops the text.
probe := &statusOnly{header: w.Header()}
h.ServeHTTP(probe, r)
s.writeError(w, probe.status, strings.ToLower(http.StatusText(probe.status)), nil)
})
}
// statusOnly is a ResponseWriter that shares the real headers but keeps
// only the status code, discarding the body.
type statusOnly struct {
header http.Header
status int
}
func (p *statusOnly) Header() http.Header { return p.header }
func (p *statusOnly) WriteHeader(status int) { p.status = status }
func (p *statusOnly) Write(b []byte) (int, error) { return len(b), nil }
mux.Handler(r) le pregunta al mux qué patrón manejaría la petición, sin ejecutarla. Un patrón vacío significa que ninguna ruta coincidió, y el handler devuelto es el que usa el mux para responder 404 o 405. Ejecutarlo contra statusOnly conserva el estado y el header Allow y descarta el texto. El cliente recibe {"error":"method not allowed"}.
Logging con log/slog
El paquete log/slog escribe logs estructurados: un mensaje más pares clave-valor en los que una máquina puede buscar. Sus handlers de JSON y de texto agregan la hora actual a cada línea. Eso cambiaría la salida aquí en cada ejecución, así que los programas de este post usan ReplaceAttr para quitar la clave time:
package main
import (
"errors"
"log/slog"
"os"
"time"
)
func main() {
opts := &slog.HandlerOptions{
// Drop the time so the output is the same on every run.
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey && len(groups) == 0 {
return slog.Attr{}
}
return a
},
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
logger.Info("request", "method", "GET", "status", 200, "duration", 1500*time.Microsecond)
reqLogger := logger.With("request_id", "req-7")
reqLogger.Error("internal error", "err", errors.New("disk full"))
text := slog.New(slog.NewTextHandler(os.Stdout, opts))
text.Info("request", "method", "GET", "status", 200, "duration", 1500*time.Microsecond)
}
Imprime:
{"level":"INFO","msg":"request","method":"GET","status":200,"duration":1500000}
{"level":"ERROR","msg":"internal error","request_id":"req-7","err":"disk full"}
level=INFO msg=request method=GET status=200 duration=1.5ms
ReplaceAttr ve cada atributo antes de que se escriba, y devolver un slog.Attr vacío lo elimina. La verificación len(groups) == 0 conserva un campo time dentro de un grupo. logger.With devuelve un logger que agrega request_id a cada línea.
Revisa los valores de duration. El handler JSON escribe un time.Duration como un número entero de nanosegundos, 1500000, mientras que el handler de texto escribe 1.5ms. Cualquier cosa que busque en tus logs necesita saber cuál de los dos está leyendo.
El middleware de logging necesita el código de estado, que el handler escribe en el ResponseWriter. Así que le pasa al handler un wrapper que lo recuerda:
// statusRecorder remembers the status code a handler wrote.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (rec *statusRecorder) WriteHeader(status int) {
if rec.status == 0 {
rec.status = status
}
rec.ResponseWriter.WriteHeader(status)
}
func (rec *statusRecorder) Write(b []byte) (int, error) {
if rec.status == 0 {
rec.status = http.StatusOK
}
return rec.ResponseWriter.Write(b)
}
// Unwrap lets http.ResponseController reach the real ResponseWriter.
func (rec *statusRecorder) Unwrap() http.ResponseWriter {
return rec.ResponseWriter
}
// logRequests writes one log line per request, after the handler returns.
func (s *server) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
s.logger.LogAttrs(r.Context(), slog.LevelInfo, "request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", rec.status),
slog.Duration("duration", time.Since(start)),
slog.String("request_id", requestIDFrom(r.Context())),
)
})
}
statusRecorder incrusta http.ResponseWriter y sobrescribe dos métodos. Un Write sin WriteHeader envía un 200, así que Write también lo registra. Unwrap le permite a http.NewResponseController llegar al writer original, para funciones como el flush que el wrapper no tiene.
Recuperarse de un panic
Un panic en un handler no tumba un servidor HTTP de Go, pero el cliente no recibe respuesta. net/http lo recupera, escribe un stack trace en el log y cierra la conexión. Cuando lo probé, el http.Get del cliente devolvió un error EOF y ningún código de estado. El middleware del módulo lo convierte en un 500 como corresponde:
// recoverPanics turns a panic in a handler into a 500 response. The panic
// value and stack go to the log, never to the client.
func (s *server) recoverPanics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
v := recover()
if v == nil {
return
}
if v == http.ErrAbortHandler {
panic(v)
}
s.logger.LogAttrs(r.Context(), slog.LevelError, "panic",
slog.Any("value", v),
slog.String("stack", string(debug.Stack())),
slog.String("request_id", requestIDFrom(r.Context())),
)
s.writeError(w, http.StatusInternalServerError, "internal server error", nil)
}()
next.ServeHTTP(w, r)
})
}
El valor del panic y el stack trace van al log, y el cliente recibe el mismo cuerpo 500 genérico que cualquier otro error interno. http.ErrAbortHandler se vuelve a lanzar con panic a propósito: un handler hace panic con él para abortar una respuesta de forma deliberada, y net/http lo maneja sin ruido.
Este programa copia los tres middlewares sin cambios y los envuelve alrededor de un handler que indexa un slice sin revisar los límites. El handler de log también quita duration y stack, que cambian en cada ejecución:
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"runtime/debug"
"strconv"
"sync/atomic"
"time"
)
type server struct {
logger *slog.Logger
}
func (s *server) writeError(w http.ResponseWriter, status int, msg string, fields map[string]string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
fmt.Fprintf(w, "{\"error\":%q}\n", msg)
}
// The middleware below is copied unchanged from internal/api/middleware.go.
// requestIDKey is the context key for the request ID. It's unexported,
// so no other package can read or overwrite the value by accident.
type requestIDKey struct{}
// withRequestID gives every request an ID from a counter, stores it in the
// request's context and sends it back in the X-Request-Id header.
func withRequestID(next http.Handler) http.Handler {
var counter atomic.Uint64
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := "req-" + strconv.FormatUint(counter.Add(1), 10)
w.Header().Set("X-Request-Id", id)
ctx := context.WithValue(r.Context(), requestIDKey{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// requestIDFrom returns the request ID stored by withRequestID, or "".
func requestIDFrom(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey{}).(string)
return id
}
// statusRecorder remembers the status code a handler wrote.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (rec *statusRecorder) WriteHeader(status int) {
if rec.status == 0 {
rec.status = status
}
rec.ResponseWriter.WriteHeader(status)
}
func (rec *statusRecorder) Write(b []byte) (int, error) {
if rec.status == 0 {
rec.status = http.StatusOK
}
return rec.ResponseWriter.Write(b)
}
// Unwrap lets http.ResponseController reach the real ResponseWriter.
func (rec *statusRecorder) Unwrap() http.ResponseWriter {
return rec.ResponseWriter
}
// logRequests writes one log line per request, after the handler returns.
func (s *server) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
s.logger.LogAttrs(r.Context(), slog.LevelInfo, "request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", rec.status),
slog.Duration("duration", time.Since(start)),
slog.String("request_id", requestIDFrom(r.Context())),
)
})
}
// recoverPanics turns a panic in a handler into a 500 response. The panic
// value and stack go to the log, never to the client.
func (s *server) recoverPanics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
v := recover()
if v == nil {
return
}
if v == http.ErrAbortHandler {
panic(v)
}
s.logger.LogAttrs(r.Context(), slog.LevelError, "panic",
slog.Any("value", v),
slog.String("stack", string(debug.Stack())),
slog.String("request_id", requestIDFrom(r.Context())),
)
s.writeError(w, http.StatusInternalServerError, "internal server error", nil)
}()
next.ServeHTTP(w, r)
})
}
func main() {
// Drop the attributes that change on every run: time, duration, stack.
opts := &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
switch a.Key {
case slog.TimeKey, "duration", "stack":
return slog.Attr{}
}
return a
},
}
s := &server{logger: slog.New(slog.NewJSONHandler(os.Stdout, opts))}
titles := []string{"Buy milk", "Walk the dog"}
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
fmt.Fprintln(w, titles[id-1]) // a bug: no bounds check
})
var h http.Handler = s.recoverPanics(mux)
h = s.logRequests(h)
h = withRequestID(h)
for _, path := range []string{"/tasks/2", "/tasks/5"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
fmt.Printf("client got %d %s: %s", rec.Code, rec.Header().Get("X-Request-Id"), rec.Body)
}
}
Imprime:
{"level":"INFO","msg":"request","method":"GET","path":"/tasks/2","status":200,"request_id":"req-1"}
client got 200 req-1: Walk the dog
{"level":"ERROR","msg":"panic","value":"runtime error: index out of range [4] with length 2","request_id":"req-2"}
{"level":"INFO","msg":"request","method":"GET","path":"/tasks/5","status":500,"request_id":"req-2"}
client got 500 req-2: {"error":"internal server error"}
La segunda petición hace panic. recoverPanics escribe el panic en el log con req-2, escribe el 500 y retorna normalmente, así que logRequests igual registra status: 500 con el mismo ID. El cuerpo que recibe el cliente no dice nada de slices. Eso depende del orden en New: con recoverPanics por fuera de logRequests, el panic se saltaría la línea del log.
Un límite: si un handler ya envió su estado cuando hace panic, es demasiado tarde para un 500.
Ejecutar el servidor
El comando en cmd/tasksd arma las piezas y arranca un http.Server. main lee un flag -addr, crea un logger JSON sobre la salida de error estándar y llama a run, que abre el listener y se lo pasa a serve:
func run(addr string, logger *slog.Logger) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
return serve(ctx, ln, api.New(task.NewMemStore(), logger), logger)
}
// serve answers requests on ln until ctx is cancelled, then shuts down
// gracefully: no new connections, and requests in flight get to finish.
func serve(ctx context.Context, ln net.Listener, handler http.Handler, logger *slog.Logger) error {
srv := &http.Server{
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
}
errc := make(chan error, 1)
go func() {
logger.Info("listening", "addr", ln.Addr().String())
errc <- srv.Serve(ln)
}()
select {
case err := <-errc:
return err
case <-ctx.Done():
}
logger.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return err
}
if err := <-errc; !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
Este es el único lugar que elige MemStore. serve recibe un listener y un handler en vez de una dirección, así que un test puede ejecutarlo en un puerto libre con un handler propio. Los timeouts son los de la parte anterior, y ReadHeaderTimeout es el que nunca debes omitir. signal.NotifyContext cancela ctx con Ctrl+C, y luego srv.Shutdown espera a que terminen las peticiones en curso. La siguiente parte explica en detalle los timeouts y el apagado.
Con go run ./cmd/tasksd corriendo en una terminal, envié peticiones desde otra:
$ curl -s -X POST localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title":"Buy milk"}'
{"id":1,"title":"Buy milk","done":false}
$ curl -s -X PUT localhost:8080/tasks/1 -H 'Content-Type: application/json' -d '{"title":"Buy milk","done":true}'
{"id":1,"title":"Buy milk","done":true}
$ curl -s localhost:8080/tasks
[{"id":1,"title":"Buy milk","done":true}]
$ curl -s -X PATCH localhost:8080/tasks/1 -w '%{http_code}\n'
{"error":"method not allowed"}
405
$ curl -s -X POST localhost:8080/tasks -d 'title=Walk the dog' -w '%{http_code}\n'
{"error":"Content-Type must be application/json"}
415
Esta vez el 405 es JSON, gracias a jsonRouteErrors. La última petición envía un cuerpo de formulario, que es lo que curl -d envía por defecto, y recibe 415. La terminal del servidor mostró una línea de log JSON por petición. No las pegué, porque la hora y la duración cambian en cada ejecución.
Dónde iría una base de datos
Una lista de tareas real tiene que sobrevivir a un reinicio, y la interfaz Store es donde se conecta una base de datos sin que cambien los handlers. database/sql está en la biblioteca estándar, pero cada driver de base de datos es un módulo de terceros, así que esta serie se queda en la forma. El Get de un store SQL se vería así:
// SQLStore keeps tasks in a SQL database. With the other four methods
// written the same way, it satisfies Store just as MemStore does.
type SQLStore struct {
db *sql.DB
}
func (s *SQLStore) Get(ctx context.Context, id int64) (Task, error) {
var t Task
err := s.db.QueryRowContext(ctx,
"SELECT id, title, done FROM tasks WHERE id = $1", id,
).Scan(&t.ID, &t.Title, &t.Done)
if errors.Is(err, sql.ErrNoRows) {
return Task{}, ErrNotFound
}
if err != nil {
return Task{}, fmt.Errorf("get task %d: %w", id, err)
}
return t, nil
}
QueryRowContext recibe el context de la petición, así que un cliente que se desconecta cancela su consulta. sql.ErrNoRows se convierte en ErrNotFound, así que getTask sigue respondiendo 404, y los demás errores se envuelven y se convierten en un 500. El placeholder $1 es el estilo de PostgreSQL; los drivers de MySQL y SQLite usan ?. En run, el store SQL reemplazaría a task.NewMemStore(), y nada en internal/api cambiaría.
Qué recordar
- Pon el almacenamiento detrás de una interfaz pequeña, y devuelve errores centinela que los handlers revisen con
errors.Is. - Decodifica los cuerpos con un solo helper: revisa
Content-Type(415), limita el tamaño conhttp.MaxBytesReader(413), llama aDisallowUnknownFieldsy permite un solo valor JSON (400). - Responde 422 con mensajes por campo para una entrada bien formada que rompe una regla, 201 con
Locational crear, y 204 sin cuerpo al borrar. - Envía cada error con una sola forma JSON, serializa antes de escribir el estado, y escribe los errores internos en el log en vez de devolverlos.
- Un middleware es
func(http.Handler) http.Handler. El último que se aplica corre primero, así que pon los IDs de petición por fuera del logging, y el logging por fuera de la recuperación de panics. Escribe los logs consloga través de unResponseWriterenvuelto. - Construye el handler con un constructor que reciba sus dependencias y no use variables globales.
Elige cada código de estado a propósito, y envía cada error con la misma forma.