Écrire une API REST avec net/http et le routage de Go 1.22
1. Aperçu
Avant Go 1.22, http.ServeMux ne correspondait que sur le chemin. Il ne distinguait pas GET de POST et ne pouvait pas lire les paramètres de chemin comme /orders/{id}. C’est pourquoi beaucoup de projets devaient utiliser en plus gorilla/mux ou chi.
À partir de Go 1.22, ServeMux supporte la correspondance sur la méthode, les wildcards et les règles de priorité claires. Cet article vous guide pour construire une API REST gérant des Order uniquement avec les bibliothèques standard. Vous écrirez aussi des tests avec httptest et connaîtrez les erreurs courantes avec la nouvelle syntaxe.
2. Préparer le projet
Vous devez avoir Go 1.22 ou supérieur. La ligne go dans go.mod doit aussi être 1.22 ou supérieur :
mkdir order-api && cd order-api
go mod init example.com/order-api
Le fichier go.mod après sa création :
module example.com/order-api
go 1.22
Remarque : Le comportement du nouveau routage dépend de la ligne
godansgo.mod, pas de la version toolchain que vous avez installée. Sigo.modditgo 1.21,ServeMuxs’exécute toujours à l’ancienne manière. Alors le motif"GET /orders/{id}"est interprété comme un chemin contenant un espace, et votre requête recevra 404 sans aucun message d’erreur.
Cet article ne nécessite pas de dépendances externes, il n’y a donc pas de commande go get.
3. Nouvelle syntaxe des motifs
3.1. Correspondance sur la méthode et les paramètres de chemin
Un exemple minimal dans 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("Bonjour, " + name + "\n"))
})
log.Fatal(http.ListenAndServe(":8080", mux))
}
Le code ci-dessus fait les choses suivantes :
"GET /hello/{name}": la partie avant l’espace est la méthode, la partie après est le chemin.{name}est un wildcard, qui correspond exactement à un segment.r.PathValue("name")retourne la valeur trouvée, toujours sous formestring. Si le nom du wildcard n’existe pas, la fonction retourne une chaîne vide.
Essayez :
go run .
curl -i localhost:8080/hello/Lan
curl -i -X POST localhost:8080/hello/Lan
HTTP/1.1 200 OK
...
Bonjour, Lan
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
...
Method Not Allowed
Quand le chemin correspond mais que la méthode ne correspond pas, ServeMux retourne automatiquement 405 avec un header Allow. Le motif GET correspond aussi automatiquement HEAD.
3.2. Wildcard multi-segments et correspondance exacte
Deux autres syntaxes spéciales :
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Fichier : " + r.PathValue("path") + "\n"))
})
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Page d'accueil\n"))
})
{path...}doit être à la fin du motif. Il correspond au reste du chemin, y compris le/. Pour/files/docs/2024/report.pdf, la valeur estdocs/2024/report.pdf.{$}correspond exactement au chemin se terminant par/. Sans lui, le motif"/"correspond à tous les chemins. C’est la cause courante d’une route inconnue ne retournant pas 404.
3.3. Règles de priorité
Quand plusieurs motifs correspondent à la même requête, le motif plus spécifique gagne :
mux.HandleFunc("GET /orders/{id}", getOrder)
mux.HandleFunc("GET /orders/latest", getLatestOrder) // gagne pour /orders/latest
/orders/latest est plus spécifique que /orders/{id}, car chaque requête correspondant au deuxième motif correspond aussi au premier, tandis que l’inverse n’est pas vrai. L’ordre d’enregistrement n’a pas d’importance.
Si deux motifs se chevauchent et qu’aucun n’est plus spécifique, Go va panic lors de l’enregistrement :
mux.HandleFunc("GET /orders/{id}", getOrder)
mux.HandleFunc("GET /{resource}/latest", getLatest) // panic: conflicts with ...
L’erreur apparaît au démarrage du programme, pas lors du traitement d’une requête. Ainsi vous découvrez les erreurs de routage tôt.
4. Construire l’API Order
4.1. Modèle et stockage en mémoire
Créez le fichier 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
}
Chaque requête est traitée sur sa propre goroutine. C’est pourquoi map doit être protégé par sync.RWMutex. Sans le mutex, lancer go test -race signalera une course aux données immédiatement. List trie par ID car l’ordre de parcours d’une map en Go est aléatoire.
4.2. Utilitaires pour JSON
Créez le fichier 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})
}
L’ordre d’appel est très important. Vous devez appeler Header().Set avant WriteHeader. Après avoir appelé WriteHeader ou Write, toute modification de header est ignorée.
4.3. Gestionnaire
Créez le fichier 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) // max 1 MB
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var in Order
if err := dec.Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "corps invalide: "+err.Error())
return
}
if in.Customer == "" || in.Total <= 0 {
writeError(w, http.StatusUnprocessableEntity, "client et total sont obligatoires")
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, "commande non trouvée")
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, "commande non trouvée")
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 doit être un entier positif")
return 0, false
}
return id, true
}
Quelques points à noter :
Routesregroupe toutes les déclarations de route au même endroit. Vous lisez une fois et vous savez quels endpoints l’API possède.GET /ordersetGET /orders/{id}ne se chevauchent pas car le nombre de segments est différent.ServeMuxne vérifie pas les types.{id}correspond aussi àabc, donc vous devez valider vous-même en utilisantstrconv.Atoi.MaxBytesReaderbloque les corps trop volumineux.DisallowUnknownFieldsrejette les champs inconnus comme"totl"en cas de typage erroné.
4.4. Middleware et fonction main
Un middleware dans net/http n’est qu’une fonction prenant http.Handler et retournant http.Handler. Mettez à jour 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 fonction newServer sépare la construction du handler de main. Ainsi, les tests peuvent réutiliser la même configuration que celle qui s’exécute réellement. http.Server a ReadHeaderTimeout pour prévenir les clients qui envoient des headers trop lentement. http.ListenAndServe ne définit pas ce timeout par défaut.
À partir de Go 1.23, http.Request a un champ supplémentaire Pattern. Vous pouvez loguer r.Pattern (par exemple GET /orders/{id}) à la place du chemin réel. Cela aide à agréger les métriques par route.
Essayez :
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 doit être un entier positif"}
204
5. Écrire des tests avec httptest
Go a net/http/httptest intégré, vous n’avez donc pas besoin d’ouvrir un port réel. Créez le fichier 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'est pas un nombre", http.MethodGet, "/orders/abc", http.StatusBadRequest},
{"non existant", http.MethodGet, "/orders/99", http.StatusNotFound},
{"mauvaise méthode", http.MethodPut, "/orders/1", http.StatusMethodNotAllowed},
{"route inconnue", 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)
}
})
}
}
Le premier test vérifie le flux de création puis de lecture d’une commande. Le second test est un test table-driven, vérifiant le comportement du routage, y compris le 405 généré automatiquement par ServeMux. Exécutez avec le détecteur de race :
go test -race -v ./...
=== RUN TestCreateAndGetOrder
--- PASS: TestCreateAndGetOrder (0.00s)
=== RUN TestRouting
=== RUN TestRouting/id_n'est_pas_un_nombre
...
--- PASS: TestRouting (0.00s)
PASS
6. Erreurs courantes et quand ne pas l’utiliser
Les erreurs courantes :
go.modindique une ancienne version. Les nouvelles routes retournent 404 partout. Vérifiez d’abord la lignegodansgo.mod.- Syntaxe de motif incorrecte. Il ne doit y avoir qu’un seul espace entre la méthode et le chemin. La méthode doit être en majuscules, par exemple
GETet nonget. - Utiliser
"/"comme fallback involontairement. Le motif"/"capture chaque requête qui ne correspond pas à une autre route. Utilisez"/{$}"si vous voulez juste la page d’accueil. - Wildcard occupant un segment entier. Le motif
/orders/{id}.jsonn’est pas valide. Un wildcard ne peut pas être au même endroit que d’autres caractères dans un même segment. - Le 405 retourne du texte brut.
ServeMuxretourne un corps de texte pur pour 404 et 405, pas du JSON. Si vos clients ont besoin d’une réponse JSON cohérente, vous devez ajouter une couche de gestion supplémentaire.
Remarque : Si un ancien projet se casse après avoir mis à jour
go.modà 1.22, vous pouvez temporairement définirGODEBUG=httpmuxgo121=1pour revenir au comportement ancien. Ce n’est qu’une solution de transition. Vous devriez toujours corriger les motifs.
net/http brut peut être insuffisant si vous avez besoin de :
- Contraintes de type directement dans la route, par exemple correspondre uniquement des nombres ou utiliser des regex.
- Grouper les routes avec des middleware distincts pour chaque groupe. Cela peut être fait avec plusieurs
ServeMuximbriquées, mais le code sera verbeux. - Un écosystème de middleware pré-construit comme CORS, rate limiting ou recovery.
Pour ces cas, chi est un choix léger et entièrement compatible avec http.Handler. En dehors de ces cas, la bibliothèque standard suffit pour la plupart des API REST.
7. Conclusion
Go 1.22 rend http.ServeMux suffisamment puissant pour les API REST réelles. Vous pouvez correspondre sur la méthode, lire les paramètres de chemin avec r.PathValue, et laisser Go gérer automatiquement 405 et les règles de priorité. Vous avez aussi construit une API Order complète avec middleware, validation d’entrée et tests avec httptest, sans dépendances externes.
Le point le plus important à retenir : définissez go 1.22 ou supérieur dans go.mod, utilisez {$} pour une correspondance exacte, et validez vous-même les valeurs des wildcards avant de les utiliser.