ITSkillsCenter
Développement Web

API REST en Go avec net/http : sans framework, pas-à-pas

8 دقائق للقراءة

📍 Guide principal : Go pour microservices : la stack pratique 2026
Cet article fait partie d’une série pratique sur Go en production. Pour le panorama, lire d’abord le guide principal.

Introduction

Vous avez besoin d’exposer une API REST. Vous avez ouvert un tutoriel, et la première instruction vous demande d’installer Gin, Fiber, Echo ou Chi. Vous vous demandez s’il faut vraiment ajouter une dépendance pour ce qui ressemble à un problème résolu depuis longtemps. La réponse honnête, en 2026, est non. Depuis Go 1.22 et le pattern matching ajouté à http.ServeMux, la bibliothèque standard couvre tous les besoins d’une API REST production-ready : routing par méthode HTTP et paramètres de chemin, middlewares chaînés, gestion JSON, validation, intégration PostgreSQL, tests d’intégration. Ce tutoriel vous fait monter de zéro un service que vous pouvez déployer sur un VPS le soir même.

Le service que nous allons construire expose des endpoints CRUD pour gérer un catalogue de produits. Il utilise PostgreSQL via pgx, journalise en JSON structuré avec log/slog, valide les entrées avec go-playground/validator, et inclut une suite de tests qui démarre une vraie base PostgreSQL en Docker pendant l’exécution. À la fin de la lecture, vous aurez un binaire compilé statiquement, prêt à être copié sur n’importe quelle distribution Linux et lancé sous systemd.

Prérequis

  • Go 1.26 ou plus récent — vérifiez avec go version
  • Docker 24+ pour les tests d’intégration
  • Un éditeur configuré avec gopls (extension Go officielle pour VS Code, JetBrains GoLand, ou Vim/Neovim avec coc-go)
  • Niveau attendu : intermédiaire — vous devez connaître la syntaxe Go de base et avoir déjà écrit un main.go qui compile
  • Temps estimé : 90 à 120 minutes de codage attentif

Étape 1 — Initialiser le module et la structure de projet

La première chose à comprendre quand on démarre un service Go est que la structure du projet n’est pas négociable. La communauté a convergé vers un layout standard maintenu sous le dépôt golang-standards/project-layout. Pour un service d’API simple, on n’a pas besoin de toute sa complexité — un dossier cmd/ pour les binaires et un dossier internal/ pour le code applicatif suffisent. Cette séparation a un effet immédiat : tout ce qui est sous internal/ ne peut pas être importé par un autre module Go, ce qui protège votre API publique.

mkdir -p catalogue/{cmd/api,internal/{server,store,model}}
cd catalogue
go mod init github.com/votreorg/catalogue
go get github.com/jackc/pgx/v5@latest github.com/go-playground/validator/v10@latest

Les commandes ci-dessus créent l’arborescence, initialisent un module Go avec un nom canonique (utilisez votre vraie organisation GitHub plutôt que votreorg) et installent les deux seules dépendances tierces que nous accepterons : le driver PostgreSQL pgx et le validateur de struct. Vous devez voir un fichier go.mod apparaître à la racine, avec ces deux lignes require. Si go get échoue avec une erreur de proxy, vérifiez que GOPROXY pointe sur https://proxy.golang.org,direct en lançant go env GOPROXY.

Étape 2 — Modéliser le domaine produit

Avant d’écrire la moindre ligne d’HTTP, on définit le type qui circulera dans tout le service. Cette étape semble triviale mais conditionne la qualité du code suivant. En Go, une struct bien tagguée pour le JSON et la validation devient un contrat exécutable : le compilateur vous arrête si vous tentez d’écrire un champ qui n’existe pas, le décodeur JSON vous renvoie une erreur si le payload entrant est mal formé, et le validateur signale les contraintes métier non respectées.

// internal/model/produit.go
package model

import "time"

type Produit struct {
    ID          int64     `json:"id"`
    Nom         string    `json:"nom" validate:"required,min=2,max=120"`
    PrixCentime int64     `json:"prix_centime" validate:"required,gte=0"`
    Stock       int       `json:"stock" validate:"gte=0"`
    CreeLe      time.Time `json:"cree_le"`
}

Notez le choix de PrixCentime en int64 plutôt que float64 pour le prix. C’est une habitude qui vous évitera des arrondis monétaires imprécis à six chiffres après la virgule. Stocker en plus petite unité monétaire (centimes ou millièmes) est un réflexe que tout service traitant de l’argent doit adopter. Vous pouvez vérifier que le fichier compile en lançant go build ./internal/model/... — la commande doit terminer sans output, signe que la struct est valide.

Étape 3 — Construire la couche persistance avec pgx

Beaucoup de tutoriels Go vous orientent vers un ORM comme GORM. Pour des projets simples ça marche, mais ça vous coûte de la lisibilité, de la performance et de la prévisibilité. L’approche pragmatique aujourd’hui est d’écrire du SQL brut dans une couche store dédiée, et de mapper les résultats à la main vers vos structs. Cinq lignes de SQL valent mieux que trois niveaux d’abstraction qui vous masquent la requête réellement émise.

// internal/store/produit.go
package store

import (
    "context"
    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/votreorg/catalogue/internal/model"
)

type Produits struct {
    Pool *pgxpool.Pool
}

func (s *Produits) Creer(ctx context.Context, p *model.Produit) error {
    return s.Pool.QueryRow(ctx,
        `INSERT INTO produits (nom, prix_centime, stock)
         VALUES ($1, $2, $3)
         RETURNING id, cree_le`,
        p.Nom, p.PrixCentime, p.Stock,
    ).Scan(&p.ID, &p.CreeLe)
}

func (s *Produits) Lister(ctx context.Context, limite int) ([]model.Produit, error) {
    rows, err := s.Pool.Query(ctx,
        `SELECT id, nom, prix_centime, stock, cree_le
         FROM produits ORDER BY id DESC LIMIT $1`, limite)
    if err != nil { return nil, err }
    defer rows.Close()
    var out []model.Produit
    for rows.Next() {
        var p model.Produit
        if err := rows.Scan(&p.ID, &p.Nom, &p.PrixCentime, &p.Stock, &p.CreeLe); err != nil {
            return nil, err
        }
        out = append(out, p)
    }
    return out, rows.Err()
}

Deux points méritent attention. Le context.Context propagé sur chaque appel SQL permet à la base de couper la requête si le client HTTP coupe sa connexion — c’est essentiel pour qu’un timeout côté client se traduise en libération de ressources côté serveur. Et le defer rows.Close() est obligatoire : oublier cette ligne crée une fuite progressive du pool de connexions PostgreSQL. Une fois le fichier en place, lancez go vet ./... qui ne doit rien remonter, puis go build ./... qui compile l’ensemble.

Étape 4 — Préparer la base de données et les migrations

Pour démarrer rapidement sans complexifier le tutoriel, on utilise une migration SQL minimale jouée à l’instanciation. Les outils sérieux comme golang-migrate ou Atlas sont indispensables en production, mais vous pouvez les ajouter plus tard. L’enjeu pour ce premier service est d’avoir quelque chose qui tourne sans friction.

// internal/store/schema.go
package store

const Schema = `
CREATE TABLE IF NOT EXISTS produits (
    id BIGSERIAL PRIMARY KEY,
    nom TEXT NOT NULL,
    prix_centime BIGINT NOT NULL CHECK (prix_centime >= 0),
    stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
    cree_le TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_produits_cree_le ON produits (cree_le DESC);
`

Le CREATE TABLE IF NOT EXISTS rend l’opération idempotente : on peut rejouer la migration sans casser une base déjà initialisée. Les contraintes CHECK au niveau base assurent que même un bug applicatif ne pourra pas insérer un prix négatif. C’est une défense en profondeur qui complète la validation côté Go. Quand on appliquera ce schéma au démarrage du serveur, on attendra un succès silencieux ; PostgreSQL ne renvoie aucun message si la table existe déjà.

Étape 5 — Écrire les handlers HTTP

On arrive au cœur du sujet. Depuis Go 1.22, http.ServeMux accepte des patterns avec méthode HTTP et paramètres de chemin, ce qui couvre 90 % des besoins de routing sans framework. La syntaxe "GET /produits/{id}" reconnaît la méthode et capture {id} dans r.PathValue("id"). C’est devenu suffisant pour des API REST classiques.

// internal/server/handlers.go
package server

import (
    "encoding/json"
    "log/slog"
    "net/http"
    "strconv"

    "github.com/go-playground/validator/v10"
    "github.com/votreorg/catalogue/internal/model"
    "github.com/votreorg/catalogue/internal/store"
)

type API struct {
    Logger    *slog.Logger
    Validator *validator.Validate
    Produits  *store.Produits
}

func (a *API) creerProduit(w http.ResponseWriter, r *http.Request) {
    var p model.Produit
    if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
        a.repondreErreur(w, http.StatusBadRequest, "JSON invalide")
        return
    }
    if err := a.Validator.Struct(p); err != nil {
        a.repondreErreur(w, http.StatusUnprocessableEntity, err.Error())
        return
    }
    if err := a.Produits.Creer(r.Context(), &p); err != nil {
        a.Logger.Error("creation produit", "err", err)
        a.repondreErreur(w, http.StatusInternalServerError, "erreur interne")
        return
    }
    a.repondreJSON(w, http.StatusCreated, p)
}

func (a *API) listerProduits(w http.ResponseWriter, r *http.Request) {
    limite := 50
    if v := r.URL.Query().Get("limite"); v != "" {
        if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
            limite = n
        }
    }
    items, err := a.Produits.Lister(r.Context(), limite)
    if err != nil {
        a.Logger.Error("liste produits", "err", err)
        a.repondreErreur(w, http.StatusInternalServerError, "erreur interne")
        return
    }
    a.repondreJSON(w, http.StatusOK, items)
}

func (a *API) repondreJSON(w http.ResponseWriter, code int, payload any) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(code)
    _ = json.NewEncoder(w).Encode(payload)
}

func (a *API) repondreErreur(w http.ResponseWriter, code int, msg string) {
    a.repondreJSON(w, code, map[string]string{"erreur": msg})
}

Trois habitudes à intégrer. D'abord, on ne logue jamais l'erreur applicative dans la réponse HTTP — cela fuirait des détails internes (chemins de fichiers, requêtes SQL). On logue avec slog côté serveur et on renvoie un message générique côté client. Ensuite, le paramètre limite a une borne haute hardcodée à 200 ; sans cela, un attaquant pourrait demander un million de lignes et faire chuter le service. Enfin, l'usage de r.Context() propage la coupure de connexion jusqu'à la base. Un go vet ./internal/server/ doit terminer sans warning à ce stade.

Étape 6 — Câbler le routeur et les middlewares

Le routeur monte les handlers sur des chemins, et les middlewares enveloppent le pipeline pour ajouter logging, recovery et timeout sans polluer le code métier. La grande beauté du modèle Go est qu'un middleware n'est rien d'autre qu'une fonction qui prend un http.Handler et en retourne un autre — pas de framework, pas de magie.

// internal/server/router.go
package server

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

func (a *API) Routeur() http.Handler {
    mux := http.NewServeMux()
    mux.HandleFunc("POST /produits", a.creerProduit)
    mux.HandleFunc("GET /produits", a.listerProduits)
    mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        _, _ = w.Write([]byte("ok"))
    })
    return chaine(mux, a.recuperer, a.tracer, a.timeout(5*time.Second))
}

func chaine(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
    for i := len(mw) - 1; i >= 0; i-- {
        h = mw[i](h)
    }
    return h
}

func (a *API) tracer(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        debut := time.Now()
        next.ServeHTTP(w, r)
        a.Logger.Info("requete", "methode", r.Method, "chemin", r.URL.Path, "duree_ms", time.Since(debut).Milliseconds())
    })
}

func (a *API) recuperer(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                a.Logger.Error("panic", "valeur", rec)
                http.Error(w, "erreur interne", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

func (a *API) timeout(d time.Duration) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx, cancel := context.WithTimeout(r.Context(), d)
            defer cancel()
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

L'ordre des middlewares dans la chaîne compte. recuperer doit être le plus extérieur pour intercepter même les panics qui se produiraient dans tracer ou timeout. tracer vient ensuite pour mesurer la durée totale réelle. timeout est le plus proche du handler pour que le contexte annulé soit visible par la couche store. Quand vous lancerez le service et exécuterez curl -i http://localhost:8080/healthz, vous verrez en plus dans les logs serveur une ligne JSON détaillant la requête, signe que le pipeline complet fonctionne.

Étape 7 — Câbler le main et démarrer le serveur

Le main.go fait l'orchestration : configuration depuis l'environnement, ouverture du pool PostgreSQL, exécution de la migration, instanciation de l'API, démarrage du serveur HTTP avec un mécanisme de shutdown gracieux qui écoute SIGTERM. Le shutdown gracieux est le détail qui distingue un service jouet d'un service production : sans lui, un déploiement Kubernetes coupe les requêtes en cours.

// cmd/api/main.go
package main

import (
    "context"
    "errors"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/go-playground/validator/v10"
    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/votreorg/catalogue/internal/server"
    "github.com/votreorg/catalogue/internal/store"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    dsn := os.Getenv("DATABASE_URL")
    if dsn == "" {
        logger.Error("DATABASE_URL manquant")
        os.Exit(1)
    }

    ctx := context.Background()
    pool, err := pgxpool.New(ctx, dsn)
    if err != nil {
        logger.Error("connexion pg", "err", err)
        os.Exit(1)
    }
    defer pool.Close()

    if _, err := pool.Exec(ctx, store.Schema); err != nil {
        logger.Error("migration", "err", err)
        os.Exit(1)
    }

    api := &server.API{
        Logger:    logger,
        Validator: validator.New(),
        Produits:  &store.Produits{Pool: pool},
    }

    srv := &http.Server{
        Addr:              ":8080",
        Handler:           api.Routeur(),
        ReadHeaderTimeout: 5 * time.Second,
        WriteTimeout:      10 * time.Second,
        IdleTimeout:       60 * time.Second,
    }

    arret := make(chan os.Signal, 1)
    signal.Notify(arret, os.Interrupt, syscall.SIGTERM)
    go func() {
        logger.Info("serveur demarre", "addr", srv.Addr)
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            logger.Error("serveur", "err", err)
            os.Exit(1)
        }
    }()

    <-arret
    ctxArret, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    if err := srv.Shutdown(ctxArret); err != nil {
        logger.Error("arret gracieux", "err", err)
    }
    logger.Info("serveur arrete")
}

Le ReadHeaderTimeout protège contre les attaques Slowloris où un client envoie ses en-têtes octet par octet. Sans ce timeout, vos goroutines s'accumuleraient indéfiniment. Lancez le service avec DATABASE_URL=postgres://user:pass@localhost/cat go run ./cmd/api — vous devez voir un log JSON "serveur demarre". Un curl -X POST http://localhost:8080/produits -d '{"nom":"clavier","prix_centime":2500,"stock":3}' doit renvoyer 201 avec l'objet créé incluant l'ID assigné par PostgreSQL.

Étape 8 — Tests d'intégration avec testcontainers

Tester un handler isolé avec httptest couvre la logique HTTP pure, mais ne valide pas que le SQL est correct ni que le mapping fonctionne. La pratique mature consiste à démarrer une vraie PostgreSQL en Docker pendant les tests via testcontainers-go. Le démarrage prend deux secondes la première fois, puis chaque test bénéficie d'une base réelle.

// internal/store/produit_test.go
package store

import (
    "context"
    "testing"

    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
    "github.com/votreorg/catalogue/internal/model"
)

func TestProduits_Creer(t *testing.T) {
    ctx := context.Background()
    pgC, err := postgres.Run(ctx, "postgres:16-alpine",
        postgres.WithDatabase("test"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
        postgres.BasicWaitStrategies(),
    )
    if err != nil { t.Fatal(err) }
    t.Cleanup(func() { _ = pgC.Terminate(ctx) })

    dsn, _ := pgC.ConnectionString(ctx, "sslmode=disable")
    pool, err := pgxpool.New(ctx, dsn)
    if err != nil { t.Fatal(err) }
    defer pool.Close()

    if _, err := pool.Exec(ctx, Schema); err != nil { t.Fatal(err) }

    s := &Produits{Pool: pool}
    p := &model.Produit{Nom: "souris", PrixCentime: 1500, Stock: 5}
    if err := s.Creer(ctx, p); err != nil { t.Fatal(err) }
    if p.ID == 0 { t.Fatal("ID non rempli apres insertion") }
}

Lancer la suite avec go test -race ./.... Le flag -race instrumente le binaire pour détecter les data races, et avec testcontainers vous obtenez une assurance que votre couche store fonctionne réellement contre PostgreSQL et pas contre un mock approximatif. Le premier run télécharge l'image Docker (1 à 2 minutes selon la connexion), puis les runs suivants partent du cache local en quelques secondes. Un test qui passe vous donne le signal vert : votre handler, votre store et votre schéma sont alignés.

Étape 9 — Compiler et empaqueter pour la production

La dernière étape transforme votre projet en un livrable. Un Dockerfile multi-stage compile le binaire dans une image Go puis le copie dans une image distroless minimaliste. Le résultat est une image de moins de 20 mégaoctets sans shell ni package manager — donc une surface d'attaque proche de zéro.

# Dockerfile
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/api ./cmd/api

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/api /api
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/api"]

Les flags -s -w retirent les symboles de debug et la table de strings, ce qui réduit la taille du binaire de 30 % sans impact sur l'exécution. CGO_ENABLED=0 garantit un binaire 100 % statique, qui peut donc tourner dans distroless/static. La variante :nonroot de distroless force l'exécution sous un utilisateur non-privilégié — un défaut de sécurité par construction. Build avec docker build -t catalogue:0.1.0 . ; l'image produite doit peser entre 15 et 25 Mo selon les versions, et un docker images catalogue vous le confirme. Un docker run --rm -e DATABASE_URL=... -p 8080:8080 catalogue:0.1.0 reproduit le même comportement qu'en local.

Erreurs fréquentes

Erreur Cause Solution
404 page not found sur une route définie Pattern Go 1.22 mal écrit — espace manquant entre méthode et chemin Vérifier la syntaxe : "GET /produits/{id}" avec un seul espace
Goroutines qui s'accumulent en prod Pas de defer rows.Close() sur les requêtes SQL Ajouter le defer immédiatement après la vérification d'erreur de Query
JSON renvoyé vide alors que la struct est remplie Champ non-exporté (minuscule) ou tag JSON manquant Mettre une majuscule au début du nom de champ et taguer `json:"snake_case"`
Latence p99 anormalement haute Pool PostgreSQL trop petit ou mal configuré Configurer MaxConns dans le DSN : ?pool_max_conns=25
Tests testcontainers timeout au démarrage Image Docker pas encore tirée Pré-tirer avec docker pull postgres:16-alpine

Tutoriels frères

  • Go pour microservices : la stack pratique 2026 — la vue d'ensemble qui pose les choix de stack
  • Goroutines et channels en pratique — concurrence Go pas-à-pas, complément naturel de ce tutoriel quand vos handlers doivent paralléliser des appels

Pour aller plus loin

FAQ

Pourquoi pas Chi ou Gin ?
Pour la majorité des API, net/http couvre désormais le besoin sans dépendance. Chi reste pertinent si vous avez besoin de groupes de routes profonds avec middlewares hérités, mais sa surface d'API est minimale et compatible avec la stdlib. Gin garde sa place pour des projets historiques ou des équipes habituées, mais on ne le recommanderait plus pour un nouveau projet en 2026.

Pourquoi pgx plutôt que database/sql + lib/pq ?
pgx fournit deux modes : un mode database/sql classique et un mode natif pgxpool. Le mode natif est plus rapide, expose des types PostgreSQL spécifiques (arrays, JSONB), et bénéficie d'un développement actif. lib/pq est en mode maintenance depuis plusieurs années.

Comment tester sans Docker ?
Vous pouvez utiliser une vraie PostgreSQL locale et configurer TEST_DATABASE_URL, en prenant soin de tronquer les tables entre tests. C'est plus rapide qu'un container, mais demande de la discipline pour ne pas polluer votre base de dev.

Faut-il des tests unitaires en plus des tests d'intégration ?
Oui pour la logique métier qui ne touche pas la base : calculs, formats, branches conditionnelles. Le ratio sain est environ 70 % de tests d'intégration et 30 % de tests unitaires pour ce type de service.

Comment ajouter de l'observabilité ?
Importer net/http/pprof en blank import dans main.go expose les profils sur /debug/pprof/. Pour OpenTelemetry, le module go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp ajoute un middleware qui propage les traces sans modifier vos handlers.

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité