Huma : lightway REST API sans le GraphQL

Un Pokémon CRUD avec auth JWT en un seul fichier Go, documenté automatiquement

Lorsque Go est arrivé ce fut pour moi une petite révolution. Je faisais plein de choses avec C, parfois péniblement, alors pouvoir les migrer ce fut simplement magique.

Et lorsque Gin est sortie ce fut le premier serveur en autre chose que PHP/JS que j’ai utilisé pour du dev. Mais comme tout dev d’API Rest, la doc est un problème. J’avais en JS du plugin pour prendre une spec (openapi) et en faire un serveur et partir d’une implémentation serveur et avoir la doc, mais rien de semblable facilement sur Go.

Puis j’ai découvert Huma.

Le pitch : tu écris des structs Go. Huma génère l’OpenAPI, la validation, et les erreurs automatiquement. Pas de YAML. Pas de annotations. Le code EST la doc.

Et ça marche.

Entre temps pour ce genre de choses j’utilises plutot du rust ou du Elixir, mais j’ai encore du Huma qui run, surtout au vue de sa simplicité et son accessibilité supérieur aux autres.

C’est là mes définitions et si elles vous déplaisent, cassez-vous je suis pas là pour discuter lexique.

  • Huma: micro-framework Go pour APIs REST/RPC. Génère OpenAPI 3.1 depuis les types Go. Prononcé /‘hjuːmɑ/.
  • OpenAPI 3.1: standard pour décrire des APIs REST. Utile pour la doc, les SDKs, les mocks. C’est le Swagger en mieux.
  • JWT: JSON Web Token. Token stateless pour l’authentification. Le serveur signe, le client stocke et renvoie.
  • Chi: router Go léger et rapide. Huma l’utilise par défaut mais supporte aussi Gin, Echo, Fiber, etc.
  • SQLite: base de données en fichier. Zéro config, zéro serveur. Parfait pour les prototypes.
  • Bcrypt: algorithme de hachage de mots de passe. Lent par design — c’est voulu pour éviter le brute-force.
  • Resolver: fonction custom dans Huma qui s’exécute AVANT le handler. Parfait pour l’auth, le rate limiting, etc.

En Go, t’as le choix des frameworks : Gin, Echo, Fiber, Chi. Ils sont tous bons. Ils sont tous rapides. Ils font tous la même chose : router, middleware, handlers.

Mais aucun ne génère l’OpenAPI automatiquement. Aucun ne valide tes entrées depuis les tags struct. Aucun ne te donne une doc interactive juste en lançant ton serveur.

Huma fait tout ça. Et il le fait en restant un micro-framework. Il te force à rien. Il utilise le router que tu veux. Il s’incrémente dans un projet existant.

Roblox l’utilise pour gérer plus d’un million de requêtes par seconde. Warner Bros l’utilise pour les JO et CNN. C’est pas un side project de week-end.


Classique. Un peu cliché. Mais c’est le bon test. Un CRUD avec auth, c’est assez simple pour être clair, et assez complet pour montrer ce que Huma fait de mieux :

  • Validation automatique — email format, password minLength, HP minimum/maximum
  • OpenAPI gratuit — chaque endpoint apparaît dans la doc
  • Erreurs structurées — RFC 9457, pas du texte brut
  • Auth JWT — via le resolver pattern de Huma
  • SQLite — zéro config, un fichier

Un seul fichier main.go. Pas de packages, pas de dossiers. Zéro structure de projet à maintenir.


0$ mkdir pokemon-api && cd pokemon-api
1$ go mod init pokemon-api
2$ go get github.com/danielgtaylor/huma/v2
3$ go get github.com/danielgtaylor/huma/v2/adapters/humachi
4$ go get github.com/go-chi/chi/v5
5$ go get github.com/golang-jwt/jwt/v5
6$ go get golang.org/x/crypto
7$ go get modernc.org/sqlite

Go 1.22+ recommandé. Le modernc.org/sqlite est un SQLite pur Go — pas besoin de CGO. Pas de dépendance système.


En Gin ou Echo, tu définis tes types et tu les valides toi-même. Avec Huma, les tags struct deviennent de la validation automatique ET de la documentation.

 0type RegisterInput struct {
 1	Body struct {
 2		Email    string `json:"email"    format:"email"  doc:"Adresse email"  example:"ash@ketchum.pokemon"`
 3		Password string `json:"password" minLength:"8"   doc:"Mot de passe"   example:"pikachu123"`
 4	}
 5}
 6
 7type CreatePokemonInput struct {
 8	Body struct {
 9		Name    string `json:"name"    minLength:"1" maxLength:"100" example:"Pikachu"`
10		Type    string `json:"type"    minLength:"1" maxLength:"50"  example:"Electric"`
11		HP      int    `json:"hp"      default:"100" minimum:"1"     maximum:"999"`
12		Attack  int    `json:"attack"  default:"50"  minimum:"1"     maximum:"999"`
13		Defense int    `json:"defense" default:"50"  minimum:"1"     maximum:"999"`
14		Sprite  string `json:"sprite"  format:"uri"  example:"https://pokeapi.co/sprites/25.png"`
15	}
16}

Regarde bien. format:"email" — Huma vérifie que c’est un email valide. minLength:"8" — il vérifie la longueur. minimum:"1" — il vérifie la plage. Et example:"Pikachu" — il met l’exemple dans la doc OpenAPI.

Tu écris des types Go. Tu obtiens une API documentée et validée. C’est tout.


L’authentification, c’est le truc qui empire la complexité de chaque framework. En Gin, tu finis avec un middleware wrapper qui check le token, parse les claims, et les met dans le context. En Huma ? Un resolver.

 0func generateToken(userID int, email string) (string, error) {
 1	claims := jwt.MapClaims{
 2		"user_id": userID,
 3		"email":   email,
 4		"exp":     time.Now().Add(24 * time.Hour).Unix(),
 5	}
 6	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
 7	return token.SignedString(secret)
 8}
 9
10func validateToken(tokenStr string) (int, string, error) {
11	token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
12		return secret, nil
13	})
14	if err != nil || !token.Valid {
15		return 0, "", err
16	}
17	claims := token.Claims.(jwt.MapClaims)
18	userID := int(claims["user_id"].(float64))
19	email := claims["email"].(string)
20	return userID, email, nil
21}
22
23// Le resolver — s'exécute AVANT le handler
24func authResolver(ctx context.Context, input *struct{}) (context.Context, error) {
25	auth := ctx.Value("huma:header:Authorization").(string)
26	if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
27		return ctx, huma.Error401Unauthorized("Token requis")
28	}
29	tokenStr := strings.TrimPrefix(auth, "Bearer ")
30	userID, email, err := validateToken(tokenStr)
31	if err != nil {
32		return ctx, huma.Error401Unauthorized("Token invalide")
33	}
34	ctx = context.WithValue(ctx, "user_id", userID)
35	ctx = context.WithValue(ctx, "user_email", email)
36	return ctx, nil
37}

Le authResolver est une fonction qui tourne avant le handler. Si elle retourne une erreur, le handler tourne jamais. Huma envoie la réponse d’erreur automatiquement.

Et tu l’ajoutes aux endpoints protégés en une ligne :

0huma.Register(api, huma.Operation{
1	OperationID: "list-pokemons",
2	Method:      http.MethodGet,
3	Path:        "/pokemons",
4	Summary:     "Lister mes Pokémons",
5	Tags:        []string{"Pokémons"},
6	Middlewares: []huma.Middleware{authResolver},  // ← c'est tout
7}, handler)

Pas de wrapper. Pas de middleware global. Pas de c.Get("user_id") dans chaque handler. Le resolver injecte le user_id dans le context, et ton handler le récupère directement. Propre.


Chaque endpoint est enregistré avec huma.Register. Tu donnes un Operation (ID, méthode, path, tags) et un handler typé.

 0// Register
 1huma.Register(api, huma.Operation{
 2	OperationID: "register",
 3	Method:      http.MethodPost,
 4	Path:        "/auth/register",
 5	Summary:     "Créer un compte",
 6	Tags:        []string{"Auth"},
 7}, func(ctx context.Context, input *RegisterInput) (*UserOutput, error) {
 8	hash, _ := bcrypt.GenerateFromPassword([]byte(input.Body.Password), bcrypt.DefaultCost)
 9	res, err := db.Exec("INSERT INTO users (email, password) VALUES (?, ?)",
10		input.Body.Email, string(hash))
11	if err != nil {
12		return nil, huma.Error409Conflict("Email déjà utilisé")
13	}
14	id, _ := res.LastInsertId()
15	return &UserOutput{Body: struct {
16		ID        int       `json:"id"`
17		Email     string    `json:"email"`
18		CreatedAt time.Time `json:"created_at"`
19	}{ID: int(id), Email: input.Body.Email, CreatedAt: time.Now()}}, nil
20})
21
22// Login
23huma.Register(api, huma.Operation{
24	OperationID: "login",
25	Method:      http.MethodPost,
26	Path:        "/auth/login",
27	Summary:     "Se connecter",
28	Tags:        []string{"Auth"},
29}, func(ctx context.Context, input *LoginInput) (*TokenOutput, error) {
30	var id int
31	var hash string
32	err := db.QueryRow("SELECT id, password FROM users WHERE email = ?",
33		input.Body.Email).Scan(&id, &hash)
34	if err != nil {
35		return nil, huma.Error401Unauthorized("Email ou mot de passe incorrect")
36	}
37	if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(input.Body.Password)); err != nil {
38		return nil, huma.Error401Unauthorized("Email ou mot de passe incorrect")
39	}
40	token, _ := generateToken(id, input.Body.Email)
41	return &TokenOutput{Body: struct {
42		Token string `json:"token"`
43	}{Token: token}}, nil
44})

Les erreurs ? huma.Error409Conflict, huma.Error401Unauthorized, huma.Error404NotFound. Huma les formate en RFC 9457 automatiquement. Le client reçoit un JSON structuré avec status, title, detail. Pas du texte brut.

Et le CRUD Pokémons suit le même pattern — chaque opération est une fonction typée avec son input et son output. Le code est lisible, le handler fait une seule chose, et la doc se génère toute seule.


 0func initDB() {
 1	var err error
 2	db, err = sql.Open("sqlite", "./pokemons.db?_journal_mode=WAL")
 3	if err != nil {
 4		log.Fatal(err)
 5	}
 6	db.Exec(`CREATE TABLE IF NOT EXISTS users (
 7		id INTEGER PRIMARY KEY AUTOINCREMENT,
 8		email TEXT UNIQUE NOT NULL,
 9		password TEXT NOT NULL,
10		created_at DATETIME DEFAULT CURRENT_TIMESTAMP
11	)`)
12	db.Exec(`CREATE TABLE IF NOT EXISTS pokemons (
13		id INTEGER PRIMARY KEY AUTOINCREMENT,
14		name TEXT NOT NULL, type TEXT NOT NULL,
15		hp INTEGER DEFAULT 100, attack INTEGER DEFAULT 50,
16		defense INTEGER DEFAULT 50, sprite TEXT DEFAULT '',
17		owner_id INTEGER REFERENCES users(id),
18		created_at DATETIME DEFAULT CURRENT_TIMESTAMP
19	)`)
20}

SQLite WAL mode. Deux tables. Zéro ORM. database/sql standard. C’est un prototype — si tu veux GORM ou sqlx, c’est toi qui vois.


0$ go run .
1🚀 Pokémon API démarrée sur http://localhost:8888
2📖 Documentation : http://localhost:8888/docs
3🔍 OpenAPI JSON  : http://localhost:8888/openapi.json

Trois URLs. Le serveur, la doc interactive, et le spec OpenAPI brut. C’est tout ce que tu as besoin.

0$ curl -X POST http://localhost:8888/auth/register \
1  -H "Content-Type: application/json" \
2  -d '{"email":"ash@ketchum.pokemon","password":"pikachu123"}'
3
4{
5  "id": 1,
6  "email": "ash@ketchum.pokemon",
7  "created_at": "2026-07-21T08:13:59Z"
8}
0$ curl -X POST http://localhost:8888/auth/login \
1  -H "Content-Type: application/json" \
2  -d '{"email":"ash@ketchum.pokemon","password":"pikachu123"}'
3
4{
5  "token": "eyJhbGciOiJIUzI1NiIs..."
6}
0$ curl -X POST http://localhost:8888/pokemons \
1  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
2  -H "Content-Type: application/json" \
3  -d '{
4    "name": "Pikachu", "type": "Electric",
5    "hp": 100, "attack": 55, "defense": 40,
6    "sprite": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png"
7  }'
0$ curl http://localhost:8888/pokemons
1
2{
3  "status": 401,
4  "title": "Unauthorized",
5  "detail": "Token requis"
6}

RFC 9457. Format structuré. Le client sait exactement ce qui se passe. Pas besoin de parser du HTML.


Ouvre http://localhost:8888/docs. Tu as une UI Stoplight Elements avec :

  • Tous les endpoints documentés avec résumé et tags
  • Les schemas des inputs/outputs avec types et contraintes
  • Les exemples depuis les tags example
  • Un bouton “Try it” pour tester directement depuis le navigateur

Et http://localhost:8888/openapi.json ? C’est le spec OpenAPI complet. Tu peux le coller dans Postman, Swagger UI, ou le passer à un générateur de SDK (OpenAPI Generator, oapi-codegen).

Zéro effort de doc. Zéro fichier YAML à maintenir. La doc peut JAMAIS se déconnecter du code.

C’est ça le vrai gain. Pas la performance. Pas la syntaxe. Le fait que ta doc est toujours à jour, parce qu’elle est générée depuis le code qui tourne.


Critère Gin Echo Huma
Router Intégré Intégré BYO (Chi, Gin, Echo, Fiber…)
Validation Manuelle Manuelle Auto (tags struct)
OpenAPI Plugin tiers Plugin tiers Built-in
Erreurs Manuelles Manuelles RFC 9457 auto
Doc Swagger UI externe Swagger UI externe Stoplight Elements built-in
CLI Non Non Built-in (Cobra)
Async Oui Oui Oui

Huma n’est pas mieux que Gin ou Echo. Il est différent. Si tu veux un framework opinionné avec beaucoup de middleware intégré, Gin est mieux. Si tu veux la doc automatique et la validation sans effort, Huma est ton truc.

Et le meilleur truc ? Huma est router agnostic. Tu peux l’utiliser avec Chi, Gin, Echo, Fiber, gorilla/mux, ou le http.ServeMux natif de Go 1.22. Tu ne changes pas ton router — tu ajoutes Huma par-dessus.


Huma est un framework discret. Il fait pas de bruit. Il t’apprend rien de nouveau si tu connais Go. Il te donne juste des guard rails : la validation, la doc, les erreurs structurées, l’OpenAPI.

Le meilleur compliment qu’on peut faire à un framework, c’est de pas le remarquer. Huma fait exactement ça.

Un fichier main.go. SQLite. JWT. Une API documentée et validée. Pas de YAML. Pas de plugin tiers. Pas de Swagger à maintenir.

Sa grande force est sa simplicité tout en ayant la puissance de Go.

See ya space-cowboy!


A clean flat illustration of a Go gopher mascot sitting at a futuristic terminal desk, typing code. On the terminal screen, a REST API documentation page is visible with colorful endpoint cards (GET, POST, PUT, DELETE in green, blue, orange, red) and a JSON schema panel. A floating holographic OpenAPI badge glows above the terminal. On the desk, a small Pikachu figurine sits next to the keyboard. Background is dark navy (#0d1117) with subtle code rain. Style: modern flat vector, tech blog header aesthetic. No text, no logos, no watermarks. 16:9 aspect ratio.