REST API com net/http: roteamento novo do Go 1.22

1. Visão Geral

Antes do Go 1.22, http.ServeMux apenas combinava rotas por caminho. Não diferenciava GET de POST e não conseguia ler parâmetros de caminho como /orders/{id}. Por isso, muitos projetos precisavam usar gorilla/mux ou chi.

A partir do Go 1.22, ServeMux suporta combinação por method, wildcard e regras de precedência claras. Este artigo orienta você a construir uma REST API para gerenciar Order usando apenas a biblioteca padrão. Você também escreverá testes com httptest e aprenderá os erros comuns da nova sintaxe.

2. Preparando o Projeto

Você precisa do Go 1.22 ou superior. A linha go em go.mod também deve ser 1.22 ou superior:

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

Arquivo go.mod após criação:

module example.com/order-api

go 1.22

Atenção: O novo comportamento de roteamento depende da linha go em go.mod, não da versão da toolchain instalada. Se go.mod indicar go 1.21, ServeMux ainda usará o comportamento antigo. Nesse caso, o padrão "GET /orders/{id}" será interpretado como um caminho contendo espaço, e sua requisição receberá 404 sem mensagem de erro.

Este artigo não requer dependências externas, portanto não há comando go get.

3. Nova Sintaxe de Padrão

3.1. Combinando por método e parâmetro de caminho

Exemplo mínimo em 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("Olá, " + name + "\n"))
	})

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

O código acima faz o seguinte:

  • "GET /hello/{name}": a parte antes do espaço é o método, a parte depois é o caminho. {name} é um wildcard, combinando exatamente um segmento.
  • r.PathValue("name") retorna o valor combinado, sempre como string. Se o nome do wildcard não existir, a função retorna uma string vazia.

Teste assim:

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

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

Quando o caminho combina mas o método não, ServeMux automaticamente retorna 405 com header Allow. O padrão GET também combina automaticamente HEAD.

3.2. Wildcard com múltiplos segmentos e combinação exata

Duas outras sintaxes especiais:

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

mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Página inicial\n"))
})
  • {path...} deve estar no final do padrão. Ele combina o restante do caminho, incluindo barras /. Para /files/docs/2024/report.pdf, o valor é docs/2024/report.pdf.
  • {$} combina apenas caminhos que terminam com /. Sem ele, o padrão "/" combina todo caminho. Esta é uma causa comum de rotas inesperadas não retornarem 404.

3.3. Regra de Precedência

Quando múltiplos padrões combinam uma requisição, o padrão mais específico vence:

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

/orders/latest é mais específico que /orders/{id}, porque toda requisição que combina o segundo padrão também combina o primeiro, mas o inverso não é verdade. A ordem de registro não importa.

Se dois padrões conflitam sem nenhum sendo mais específico, Go fará panic imediatamente durante o registro:

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

O erro aparece quando o programa inicia, não durante a requisição. Por isso você detecta erros de roteamento cedo.

4. Construindo a Order API

4.1. Model e store em memória

Crie o arquivo 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 requisição é processada em sua própria goroutine. Por isso o map deve ser protegido com sync.RWMutex. Se faltar mutex, executar go test -race reportará uma data race imediatamente. List ordena por ID porque a ordem de iteração sobre um map em Go é aleatória.

4.2. Helpers para JSON

Crie o arquivo 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})
}

A ordem de chamadas é muito importante. Você deve chamar Header().Set antes de WriteHeader. Depois de chamar WriteHeader ou Write, qualquer mudança de header é ignorada.

4.3. Handler

Crie o arquivo 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, "corpo inválido: "+err.Error())
		return
	}
	if in.Customer == "" || in.Total <= 0 {
		writeError(w, http.StatusUnprocessableEntity, "customer e total são obrigatórios")
		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 não 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 não 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 deve ser um inteiro positivo")
		return 0, false
	}
	return id, true
}

Alguns pontos importantes:

  • Routes agrupa todas as declarações de rotas em um único lugar. Você lê uma vez e sabe quais endpoints a API tem.
  • GET /orders e GET /orders/{id} não conflitam, pois têm números de segmentos diferentes.
  • ServeMux não valida tipos. {id} combina até abc, então você deve validar manualmente com strconv.Atoi.
  • MaxBytesReader previne corpos muito grandes. DisallowUnknownFields rejeita campos desconhecidos como "totl" por digitação errada.

4.4. Middleware e função main

Middleware em net/http é apenas uma função que recebe http.Handler e retorna http.Handler. Atualize 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())
}

A função newServer extrai a construção do handler de main. Assim os testes podem reutilizar a mesma configuração que está rodando. http.Server tem ReadHeaderTimeout para prevenir clientes enviando headers muito lentamente. http.ListenAndServe por padrão não define este timeout.

A partir do Go 1.23, http.Request tem um campo adicional Pattern. Você pode logar r.Pattern (por exemplo GET /orders/{id}) em vez do caminho real. Isso ajuda a agrupar métricas por rota.

Teste assim:

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 deve ser um inteiro positivo"}
204

5. Escrevendo Testes com httptest

Go vem com net/http/httptest, então você não precisa abrir uma porta real. Crie o arquivo 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 não é um número", http.MethodGet, "/orders/abc", http.StatusBadRequest},
		{"não existe", http.MethodGet, "/orders/99", http.StatusNotFound},
		{"método errado", http.MethodPut, "/orders/1", http.StatusMethodNotAllowed},
		{"rota desconhecida", 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)
			}
		})
	}
}

O primeiro teste verifica o fluxo de criar e depois ler uma order. O segundo é um table-driven test que verifica o comportamento de roteamento, incluindo 405 gerado automaticamente por ServeMux. Execute com race detector:

go test -race -v ./...
=== RUN   TestCreateAndGetOrder
--- PASS: TestCreateAndGetOrder (0.00s)
=== RUN   TestRouting
=== RUN   TestRouting/id_não_é_um_número
...
--- PASS: TestRouting (0.00s)
PASS

6. Erros Comuns e Quando Não Usar

Erros frequentes:

  • go.mod com versão antiga. Rotas novas retornam 404 em tudo. Verifique a linha go em go.mod primeiro.
  • Sintaxe de padrão incorreta. Apenas um espaço entre método e caminho. Método deve ser maiúsculo, por exemplo GET e não get.
  • Usar "/" como fallback acidentalmente. O padrão "/" captura toda requisição que não combina outra rota. Use "/{$}" se quiser apenas a página inicial.
  • Wildcard ocupando um segmento inteiro. O padrão /orders/{id}.json é inválido. Wildcard não pode compartilhar um segmento com outro texto.
  • Erro 405 retorna texto simples. ServeMux retorna corpo em texto puro para 404 e 405, não JSON. Se o cliente precisar de JSON consistente, você deve adicionar uma camada de tratamento separada.

Atenção: Se um projeto antigo quebrar após atualizar go.mod para 1.22, você pode temporariamente definir GODEBUG=httpmuxgo121=1 para reverter ao comportamento antigo. Isso é apenas uma solução transitória. Você ainda deve corrigir os padrões.

net/http puro pode ser insuficiente quando você precisa de:

  • Restrições de tipo direto na rota, por exemplo combinar apenas números ou regex.
  • Agrupar rotas com middleware específico para cada grupo. Ainda é possível com múltiplos ServeMux aninhados, mas o código fica verboso.
  • Ecossistema de middleware pré-construído como CORS, rate limit ou recovery.

Para esses casos, chi é uma escolha leve e totalmente compatível com http.Handler. Fora desses casos, a biblioteca padrão é suficiente para a maioria das REST APIs.

7. Conclusão

Go 1.22 torna http.ServeMux suficientemente poderoso para REST APIs reais. Você pode combinar por método, ler parâmetros de caminho com r.PathValue, e deixar Go lidar automaticamente com 405 e regras de precedência. Você também construiu uma Order API completa com middleware, validação de entrada e testes usando httptest, sem dependências externas.

O ponto mais importante a lembrar: coloque go 1.22 ou superior em go.mod, use {$} para combinação exata, e valide manualmente valores de wildcard antes de usar.

golangnet/httprest-apigo-1.22httptest