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
goemgo.mod, não da versão da toolchain instalada. Sego.modindicargo 1.21,ServeMuxainda 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 comostring. 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:
Routesagrupa todas as declarações de rotas em um único lugar. Você lê uma vez e sabe quais endpoints a API tem.GET /orderseGET /orders/{id}não conflitam, pois têm números de segmentos diferentes.ServeMuxnão valida tipos.{id}combina atéabc, então você deve validar manualmente comstrconv.Atoi.MaxBytesReaderprevine corpos muito grandes.DisallowUnknownFieldsrejeita 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.modcom versão antiga. Rotas novas retornam 404 em tudo. Verifique a linhagoemgo.modprimeiro.- Sintaxe de padrão incorreta. Apenas um espaço entre método e caminho. Método deve ser maiúsculo, por exemplo
GETe nãoget. - 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.
ServeMuxretorna 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.modpara 1.22, você pode temporariamente definirGODEBUG=httpmuxgo121=1para 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
ServeMuxaninhados, 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.