REST API con net/http usando el nuevo routing de Go 1.22

1. Descripción general

Antes de Go 1.22, http.ServeMux solo coincidía según la ruta. No distinguía entre GET y POST y no podía leer parámetros de ruta como /orders/{id}. Por eso muchos proyectos tenían que usar gorilla/mux o chi además.

A partir de Go 1.22, ServeMux soporta coincidencia por método, wildcard y reglas de prioridad claras. Este artículo te guía para construir una REST API que gestiona Order solo con la biblioteca estándar. También escribirás pruebas con httptest y conocerás los errores comunes con la nueva sintaxis.

2. Preparar el proyecto

Necesitas Go 1.22 o superior. La línea go en go.mod también debe ser 1.22 o superior:

mkdir order-api && cd order-api
go mod init example.com/order-api

El archivo go.mod después de crearlo:

module example.com/order-api

go 1.22

Nota: El comportamiento de enrutamiento nuevo depende de la línea go en go.mod, no depende de la versión de toolchain que instales. Si go.mod especifica go 1.21, ServeMux seguirá usando el estilo antiguo. En ese caso, el patrón "GET /orders/{id}" se interpretará como una ruta con espacio, y tu solicitud recibirá 404 sin ningún mensaje de error.

Este artículo no requiere dependencias externas, así que no hay comando go get.

3. Nueva sintaxis de patrón

3.1. Coincidencia por método y parámetro de ruta

Ejemplo mínimo en main.go:

package main

import (
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /hello/{name}", func(w http.ResponseWriter, r *http.Request) {
		name := r.PathValue("name")
		w.Write([]byte("Hola, " + name + "\n"))
	})

	log.Fatal(http.ListenAndServe(":8080", mux))
}

El código anterior hace lo siguiente:

  • "GET /hello/{name}": la parte antes del espacio es el método, la parte después es la ruta. {name} es un wildcard, coincide con exactamente un segmento.
  • r.PathValue("name") devuelve el valor coincidente, siempre como string. Si el nombre del wildcard no existe, la función devuelve una cadena vacía.

Pruébalo:

go run .
curl -i localhost:8080/hello/Lan
curl -i -X POST localhost:8080/hello/Lan
HTTP/1.1 200 OK
...
Hola, Lan

HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
...
Method Not Allowed

Cuando la ruta coincide pero el método no, ServeMux devuelve automáticamente 405 junto con el encabezado Allow. El patrón GET también coincide automáticamente con HEAD.

3.2. Wildcard de múltiples segmentos y coincidencia exacta

Otras dos sintaxis especiales:

mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Archivo: " + r.PathValue("path") + "\n"))
})

mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Inicio\n"))
})
  • {path...} debe estar al final del patrón. Coincide con la parte restante de la ruta, incluyendo la barra diagonal. Para /files/docs/2024/report.pdf, el valor es docs/2024/report.pdf.
  • {$} solo coincide con rutas que terminan exactamente con /. Sin él, el patrón "/" coincide con cualquier ruta. Esta es la razón común por la que rutas desconocidas no devuelven 404.

3.3. Reglas de prioridad

Cuando múltiples patrones coinciden con una solicitud, el patrón más específico gana:

mux.HandleFunc("GET /orders/{id}", getOrder)
mux.HandleFunc("GET /orders/latest", getLatestOrder) // gana con /orders/latest

/orders/latest es más específico que /orders/{id}, porque cada solicitud que coincide con el segundo patrón también coincide con el primero, pero lo opuesto no es verdad. El orden de registro no importa.

Si dos patrones se superponen y ninguno es más específico, Go hará panic justo al registrar:

mux.HandleFunc("GET /orders/{id}", getOrder)
mux.HandleFunc("GET /{resource}/latest", getLatest) // panic: conflicts with ...

El error aparece cuando se inicia el programa, no cuando se procesa una solicitud. Así detectas errores de enrutamiento temprano.

4. Construir Order API

4.1. Modelo y almacén en memoria

Crea el archivo store.go:

package main

import (
	"sort"
	"sync"
)

type Order struct {
	ID       int     `json:"id"`
	Customer string  `json:"customer"`
	Total    float64 `json:"total"`
}

type OrderStore struct {
	mu     sync.RWMutex
	orders map[int]Order
	nextID int
}

func NewOrderStore() *OrderStore {
	return &OrderStore{orders: make(map[int]Order), nextID: 1}
}

func (s *OrderStore) Create(o Order) Order {
	s.mu.Lock()
	defer s.mu.Unlock()
	o.ID = s.nextID
	s.nextID++
	s.orders[o.ID] = o
	return o
}

func (s *OrderStore) Get(id int) (Order, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	o, ok := s.orders[id]
	return o, ok
}

func (s *OrderStore) List() []Order {
	s.mu.RLock()
	defer s.mu.RUnlock()
	result := make([]Order, 0, len(s.orders))
	for _, o := range s.orders {
		result = append(result, o)
	}
	sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
	return result
}

func (s *OrderStore) Delete(id int) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.orders[id]; !ok {
		return false
	}
	delete(s.orders, id)
	return true
}

Cada solicitud se procesa en su propia goroutine. Por lo tanto, el map debe ser protegido con sync.RWMutex. Si faltan mutexes, ejecutar go test -race reportará condiciones de carrera inmediatamente. List ordena por ID porque el orden de iteración en un map de Go es aleatorio.

4.2. Ayudante para JSON

Crea el archivo json.go:

package main

import (
	"encoding/json"
	"net/http"
)

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func writeError(w http.ResponseWriter, status int, msg string) {
	writeJSON(w, status, map[string]string{"error": msg})
}

El orden de las llamadas es muy importante. Debes llamar a Header().Set antes de WriteHeader. Después de llamar a WriteHeader o Write, cualquier cambio de encabezado se ignora.

4.3. Handler

Crea el archivo handler.go:

package main

import (
	"encoding/json"
	"net/http"
	"strconv"
)

type OrderHandler struct {
	store *OrderStore
}

func (h *OrderHandler) Routes(mux *http.ServeMux) {
	mux.HandleFunc("GET /orders", h.list)
	mux.HandleFunc("POST /orders", h.create)
	mux.HandleFunc("GET /orders/{id}", h.get)
	mux.HandleFunc("DELETE /orders/{id}", h.delete)
}

func (h *OrderHandler) list(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, h.store.List())
}

func (h *OrderHandler) create(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // máximo 1 MB
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()

	var in Order
	if err := dec.Decode(&in); err != nil {
		writeError(w, http.StatusBadRequest, "cuerpo no válido: "+err.Error())
		return
	}
	if in.Customer == "" || in.Total <= 0 {
		writeError(w, http.StatusUnprocessableEntity, "customer y total son obligatorios")
		return
	}

	created := h.store.Create(in)
	w.Header().Set("Location", "/orders/"+strconv.Itoa(created.ID))
	writeJSON(w, http.StatusCreated, created)
}

func (h *OrderHandler) get(w http.ResponseWriter, r *http.Request) {
	id, ok := parseID(w, r)
	if !ok {
		return
	}
	order, found := h.store.Get(id)
	if !found {
		writeError(w, http.StatusNotFound, "order no encontrada")
		return
	}
	writeJSON(w, http.StatusOK, order)
}

func (h *OrderHandler) delete(w http.ResponseWriter, r *http.Request) {
	id, ok := parseID(w, r)
	if !ok {
		return
	}
	if !h.store.Delete(id) {
		writeError(w, http.StatusNotFound, "order no encontrada")
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func parseID(w http.ResponseWriter, r *http.Request) (int, bool) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil || id <= 0 {
		writeError(w, http.StatusBadRequest, "id debe ser un número entero positivo")
		return 0, false
	}
	return id, true
}

Algunos puntos a considerar:

  • La función Routes agrupa todas las declaraciones de rutas en un lugar. Lees una sola vez y sabes qué endpoints tiene la API.
  • GET /orders y GET /orders/{id} no entran en conflicto, porque tienen diferente número de segmentos.
  • ServeMux no valida tipos de datos. {id} coincide incluso con abc, así que debes validar manualmente con strconv.Atoi.
  • MaxBytesReader bloquea cuerpos demasiado grandes. DisallowUnknownFields rechaza campos desconocidos como "totl" por error de escritura.

4.4. Middleware y función main

El middleware en net/http es solo una función que toma un http.Handler y devuelve un http.Handler. Actualiza main.go:

package main

import (
	"log"
	"net/http"
	"time"
)

func logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		log.Printf("%s %s (%s)", r.Method, r.URL.Path, time.Since(start))
	})
}

func newServer() http.Handler {
	mux := http.NewServeMux()
	h := &OrderHandler{store: NewOrderStore()}
	h.Routes(mux)
	return logging(mux)
}

func main() {
	srv := &http.Server{
		Addr:              ":8080",
		Handler:           newServer(),
		ReadHeaderTimeout: 5 * time.Second,
	}
	log.Fatal(srv.ListenAndServe())
}

La función newServer separa la construcción del handler de main. Así las pruebas pueden reutilizar exactamente la misma configuración que está ejecutándose en producción. http.Server tiene ReadHeaderTimeout para evitar clientes que envíen encabezados demasiado lentamente. http.ListenAndServe no establece este timeout por defecto.

A partir de Go 1.23, http.Request tiene un campo adicional Pattern. Puedes registrar r.Pattern (por ejemplo GET /orders/{id}) en lugar de la ruta real. Esto ayuda a agrupar métricas por ruta.

Pruébalo:

go run .
curl -s -X POST localhost:8080/orders -d '{"customer":"Minh","total":250000}'
curl -s localhost:8080/orders/1
curl -s localhost:8080/orders/abc
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE localhost:8080/orders/1
{"id":1,"customer":"Minh","total":250000}
{"id":1,"customer":"Minh","total":250000}
{"error":"id debe ser un número entero positivo"}
204

5. Escribir pruebas con httptest

Go incluye net/http/httptest, así que no necesitas abrir un puerto real. Crea el archivo handler_test.go:

package main

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func TestCreateAndGetOrder(t *testing.T) {
	srv := newServer()

	body := strings.NewReader(`{"customer":"Lan","total":120000}`)
	req := httptest.NewRequest(http.MethodPost, "/orders", body)
	rec := httptest.NewRecorder()
	srv.ServeHTTP(rec, req)

	if rec.Code != http.StatusCreated {
		t.Fatalf("POST status = %d, want 201", rec.Code)
	}
	if loc := rec.Header().Get("Location"); loc != "/orders/1" {
		t.Errorf("Location = %q, want /orders/1", loc)
	}

	req = httptest.NewRequest(http.MethodGet, "/orders/1", nil)
	rec = httptest.NewRecorder()
	srv.ServeHTTP(rec, req)

	var got Order
	if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
		t.Fatal(err)
	}
	if got.Customer != "Lan" || got.Total != 120000 {
		t.Errorf("got %+v", got)
	}
}

func TestRouting(t *testing.T) {
	srv := newServer()
	tests := []struct {
		name, method, path string
		want               int
	}{
		{"id no es número", http.MethodGet, "/orders/abc", http.StatusBadRequest},
		{"no existe", http.MethodGet, "/orders/99", http.StatusNotFound},
		{"método incorrecto", http.MethodPut, "/orders/1", http.StatusMethodNotAllowed},
		{"ruta desconocida", http.MethodGet, "/customers", http.StatusNotFound},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			rec := httptest.NewRecorder()
			srv.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil))
			if rec.Code != tc.want {
				t.Errorf("status = %d, want %d", rec.Code, tc.want)
			}
		})
	}
}

La primera prueba verifica el flujo de crear y luego leer una orden. La segunda prueba es un test table-driven que verifica el comportamiento del enrutamiento, incluyendo el 405 generado automáticamente por ServeMux. Ejecuta con el detector de condiciones de carrera:

go test -race -v ./...
=== RUN   TestCreateAndGetOrder
--- PASS: TestCreateAndGetOrder (0.00s)
=== RUN   TestRouting
=== RUN   TestRouting/id_no_es_número
...
--- PASS: TestRouting (0.00s)
PASS

6. Errores comunes y cuándo no usar

Errores comunes:

  • go.mod especifica una versión antigua. Las nuevas rutas devuelven 404 por completo. Verifica la línea go en go.mod primero.
  • Sintaxis de patrón incorrecta. Solo debe haber exactamente un espacio entre método y ruta. El método debe estar en mayúsculas, por ejemplo GET no get.
  • Usar "/" como fallback accidentalmente. El patrón "/" captura todas las solicitudes que no coinciden con otras rutas. Usa "/{$}" si solo quieres la página de inicio.
  • Wildcard ocupa un segmento completo. El patrón /orders/{id}.json no es válido. El wildcard no puede estar junto con otros caracteres en el mismo segmento.
  • Error 405 devuelto como texto. ServeMux devuelve cuerpo de texto sin formato para 404 y 405, no JSON. Si el cliente necesita JSON consistente, debes encapsular una capa de manejo separada.

Nota: Si un proyecto antiguo se rompe después de actualizar go.mod a 1.22, puedes establecer temporalmente GODEBUG=httpmuxgo121=1 para revertir al comportamiento antiguo. Esta es solo una solución de transición. Aún así debes corregir los patrones.

net/http puro puede no ser suficiente cuando necesites:

  • Restricciones de tipo directamente en la ruta, por ejemplo, solo coincidencia numérica o expresiones regulares.
  • Agrupar rutas con middleware separado para cada grupo. Esto aún se puede hacer con múltiples ServeMux anidados, pero el código será más largo.
  • Ecosistema de middleware preconfigurado como CORS, rate limit o recovery.

Para esos casos, chi es una opción ligera y completamente compatible con http.Handler. Aparte de estos casos, la biblioteca estándar es suficiente para la mayoría de REST APIs.

7. Conclusión

Go 1.22 hace que http.ServeMux sea lo suficientemente poderoso para REST APIs reales. Puedes hacer coincidencias por método, leer parámetros de ruta usando r.PathValue, y dejar que Go maneje automáticamente 405 junto con las reglas de prioridad. También has construido una Order API completa con middleware, validación de entrada y pruebas con httptest, sin requerir dependencias externas.

El punto más importante a recordar: establece go 1.22 o superior en go.mod, usa {$} para coincidencia exacta, y valida manualmente los valores de wildcard antes de usarlos.

golangnet/httprest-apigo-1.22httptest