Blog

Una API REST JSON en Go solo con la biblioteca estándar

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-Type debe ser application/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/json no tiene un tipo de error para este caso, así que el helper compara el prefijo del mensaje.
  • Solo se permite un valor JSON. Decode lee un valor y se detiene, así que un segundo Decode tiene que llegar a io.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.

withRequestID da un ID a la petición y fija X-Request-Id logRequests anota una línea al terminar lo interno recoverPanics convierte panic en 500 jsonRouteErrors envía 404 y 405 del mux como JSON ServeMux y luego un handler como createTask

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 con http.MaxBytesReader (413), llama a DisallowUnknownFields y permite un solo valor JSON (400).
  • Responde 422 con mensajes por campo para una entrada bien formada que rompe una regla, 201 con Location al 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 con slog a través de un ResponseWriter envuelto.
  • 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.

¿Qué tan útil te resultó este post?

¡Haz clic en un corazón para calificar!

Calificación promedio 0 / 5. Total de votos: 0

Todavía no hay votos. Sé el primero en calificar este post.