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
goengo.mod, no depende de la versión de toolchain que instales. Sigo.modespecificago 1.21,ServeMuxseguirá 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 comostring. 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 esdocs/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
Routesagrupa todas las declaraciones de rutas en un lugar. Lees una sola vez y sabes qué endpoints tiene la API. GET /ordersyGET /orders/{id}no entran en conflicto, porque tienen diferente número de segmentos.ServeMuxno valida tipos de datos.{id}coincide incluso conabc, así que debes validar manualmente constrconv.Atoi.MaxBytesReaderbloquea cuerpos demasiado grandes.DisallowUnknownFieldsrechaza 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.modespecifica una versión antigua. Las nuevas rutas devuelven 404 por completo. Verifica la líneagoengo.modprimero.- 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
GETnoget. - 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}.jsonno es válido. El wildcard no puede estar junto con otros caracteres en el mismo segmento. - Error 405 devuelto como texto.
ServeMuxdevuelve 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.moda 1.22, puedes establecer temporalmenteGODEBUG=httpmuxgo121=1para 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
ServeMuxanidados, 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.