Rest API avec Axum
Dans le cadre de mon exploration de Rust et de son écosystème web, j’ai choisi de découvrir le framework Axum à travers un petit projet concret : la mise en place d’une API REST.
Pour cela, je me suis inspiré de l’article Create a High-Performance REST API with Rust, puis j’ai ajouté une couche de sécurité basée sur OIDC avec Keycloak.
👉 L’ensemble du code source de ce projet est disponible sur GitHub.
Pourquoi Axum ?
J’aurais pu choisir Rocket, le plus connu, mais comme je suis tombé sur l’article qui m’a inspiré et qui utilisait Axum, j’ai préféré découvrir ce framework développé par l’équipe de Tokio.
Au final, je n’ai pas été déçu : je le trouve simple à utiliser pour développer des APIs en Rust.
Tutoriel - Étape 1 : Création d’une API REST basique
La première étape a été de suivre l’article de Rustfinity afin de construire une API REST simple. L’API expose par exemple des endpoints CRUD pour gérer une ressource (comme des articles ou des tâches).
Je vais présenter rapidement les étapes principales et ne créer que deux endpoints.
📝 Remarque : mon code source en expose un peu plus.
Initialisation du projet
On commence par initialiser le projet avec :
cargo new rust-axum-rest-api --bin
cd rust-axum-rest-api
Création d’une base PostgreSQL avec Docker Compose
👉 Le fichier docker-compose.yml
ressemble à ceci :
services:
postgresql:
image: postgres:17.6
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: rust-axum-rest-api
ports:
- "5432:5432"
On le lance avec :
docker-compose up -d
Ajout des dépendances
cargo add sqlx --features runtime-tokio,tls-native-tls,postgres
cargo install sqlx-cli --no-default-features --features native-tls,postgres
cargo add dotenvy axum serde tracing tracing_subscriber --features serde/derive
cargo add tokio -F full
Désormais, utilisez votre IDE préféré 😀.
Récupération de la configuration de l’application
On configure notre application via des variables d’environnement, par exemple dans un fichier .env
à la racine du projet :
dotenv().ok();
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
Créez un fichier .env
à la racine du projet :
DATABASE_URL=postgres://postgres:password@localhost:5432/rust-axum-rest-api
Note : pensez à l’ignorer dans Git, car il contient des informations sensibles si vous souhaitez partager votre code source avec d’autres personnes.
echo .env >> .gitignore
Lancez l’application pour charger les variables d’environnement :
cargo run
warning: unused variable: `database_url`
--> src/main.rs:5:9
|
5 | let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
| ^^^^^^^^^^^^ help: if this is intentional, prefix it with an underscore: `_database_url`
|
= note: `#[warn(unused_variables)]` on by default
warning: `rust-axum-rest-api` (bin "rust-axum-rest-api") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 11.28s
Running `target/debug/rust-axum-rest-api`
Connexion à la base via un pool de connexions
let pool = PgPoolOptions::new()
.connect(&database_url)
.await
.expect("Failed to connect to database");
info!("Connected to the database!");
On tente de lancer l’application :
cargo run
error[E0728]: `await` is only allowed inside `async` functions and blocks
--> src/main.rs:11:10
|
5 | fn main() {
| --------- this is not `async`
...
11 | .await
| ^^^^^ only allowed inside `async` functions and blocks
For more information about this error, try `rustc --explain E0728`.
error: could not compile `rust-axum-rest-api` (bin "rust-axum-rest-api") due to 1 previous error
Il faut basculer sur une fonction main
asynchrone et initialiser le logging avec tracing_subscriber
:
use dotenvy::dotenv;
use sqlx::postgres::PgPoolOptions;
use tracing::{info, Level};
#[tokio::main]
async fn main() {
// Initialisation du logging
tracing_subscriber::fmt()
.with_max_level(Level::INFO)
.init();
dotenv().ok();
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new()
.connect(&database_url)
.await
.expect("Failed to connect to database");
info!("Connected to the database!");
}
L’application se lance et se connecte à la base de données :
cargo run
warning: unused variable: `pool`
--> src/main.rs:14:9
|
14 | let pool = PgPoolOptions::new()
| ^^^^ help: if this is intentional, prefix it with an underscore: `_pool`
|
= note: `#[warn(unused_variables)]` on by default
warning: `rust-axum-rest-api` (bin "rust-axum-rest-api") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.37s
Running `target/debug/rust-axum-rest-api`
2025-09-13T20:56:41.466976Z INFO rust_axum_rest_api: Connected to the database!
Exécution des scripts de migration SQL au démarrage
Créons notre premier script de migration :
sqlx migrate add init_database
Remplissons le fichier nouvellement créé :
CREATE TABLE IF NOT EXISTS authors
(
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS posts
(
id SERIAL PRIMARY KEY,
author_id INTEGER REFERENCES authors (id) ON DELETE CASCADE,
title TEXT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
Appliquons les migrations :
- Soit manuellement :
sqlx migrate run
- Soit automatiquement au démarrage de l’application :
// Exécution des migrations
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
info!("Migrations executed successfully");
Note : SQLx exige l’utilisation de la macro migrate!
pour créer des métadonnées lui permettant de valider les requêtes SQL lors du premier démarrage de l’application avec une base vide.
En lançant l’application si on opte pour l’option 2, on obtient la sortie suivante:
cargo run
2025-09-13T21:41:14.005640Z INFO rust_axum_rest_api: Connected to the database!
2025-09-13T21:41:14.024437Z INFO rust_axum_rest_api: Migrations executed successfully
Création des routes
// Construction de l'application avec une route
let app = Router::new()
.route("/posts", get(get_posts).post(create_post))
// Couche d'extension
.layer(Extension(pool));
On utilise ici les extensions de Rust pour injecter le pool de connexions dans les routes.
J’aurais pu utiliser un état applicatif partagé, mais pour me concentrer sur la mise en place de l’API sécurisée via OIDC
(dans un seul fichier main.rs
), j’ai gardé cette approche.
Je ferai un prochain article pour migrer vers un état partagé 😊.
La déclaration d’une route ressemble à ceci :
#[derive(Serialize, Deserialize)]
struct Post {
id: i32,
author_id: Option<i32>,
title: String,
body: String,
}
async fn get_posts(Extension(pool): Extension<Pool<Postgres>>) -> Result<Json<Vec<Post>>, StatusCode> {
let posts = sqlx::query_as!(Post, "SELECT id, author_id, title, body FROM posts")
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(posts))
}
#[derive(Serialize, Deserialize)]
struct CreatePost {
title: String,
body: String,
author_id: Option<i32>,
}
async fn create_post(Extension(pool): Extension<Pool<Postgres>>, Json(new_post): Json<CreatePost>) -> Result<Json<Post>, StatusCode> {
let post = sqlx::query_as!(
Post,
"INSERT INTO posts (author_id, title, body) VALUES ($1, $2, $3) RETURNING id, title, body, author_id",
new_post.author_id,
new_post.title,
new_post.body
)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(post))
}
Lancement de l’application
Cela consiste à faire en sorte que notre application écoute sur le port 8000 via le protocole HTTP.
#[tokio::main]
async fn main() {
// ... code précédent ☝️
let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap();
info!("Listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
Code final
use axum::routing::get;
use axum::{Extension, Json, Router};
use axum::http::StatusCode;
use dotenvy::dotenv;
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Postgres};
use sqlx::postgres::PgPoolOptions;
use tracing::{info, Level};
#[tokio::main]
async fn main() {
// Initialisation du logging
tracing_subscriber::fmt().with_max_level(Level::INFO).init();
dotenv().ok();
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new()
.connect(&database_url)
.await
.expect("Failed to connect to database");
info!("Connected to the database!");
// Exécution des migrations
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
info!("Migrations executed successfully");
// Construction de l'application avec une route
let app = Router::new()
.route("/posts", get(get_posts).post(create_post))
// Couche d'extension
.layer(Extension(pool));
let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap();
info!("Listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
#[derive(Serialize, Deserialize)]
struct Post {
id: i32,
author_id: Option<i32>,
title: String,
body: String,
}
async fn get_posts(
Extension(pool): Extension<Pool<Postgres>>,
) -> Result<Json<Vec<Post>>, StatusCode> {
let posts = sqlx::query_as!(Post, "SELECT id, author_id, title, body FROM posts")
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(posts))
}
#[derive(Serialize, Deserialize)]
struct CreatePost {
title: String,
body: String,
author_id: Option<i32>,
}
async fn create_post(
Extension(pool): Extension<Pool<Postgres>>,
Json(new_post): Json<CreatePost>,
) -> Result<Json<Post>, StatusCode> {
let post = sqlx::query_as!(
Post,
"INSERT INTO posts (author_id, title, body) VALUES ($1, $2, $3) RETURNING id, title, body, author_id",
new_post.author_id,
new_post.title,
new_post.body
)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(post))
}
Lancement de l’API
cargo run
Testons-les endpoints /posts
avec le client HTTP de votre choix. Ici, j’utilise HTTPie :
http :8000/posts title='Oh my god' body='This is an awesome post'
http :8000/posts
On obtient la liste des articles :
[
{
"id": 1,
"author_id": null,
"body": "This is an awesome post",
"title": "Oh my god"
}
]
Tutoriel - Étape 2 : Mise en place de Keycloak avec Docker Compose
Pour gérer les utilisateurs et fournir des tokens OIDC, j’ai choisi Keycloak. Un fichier docker-compose.yml
permet de déployer rapidement un environnement complet avec Keycloak configuré pour l’API. Une fois Keycloak lancé, il suffit de :
- Créer un realm et un client pour l’API,
- Récupérer la configuration OIDC (endpoint
.well-known/openid-configuration
), - Configurer l’API Axum pour utiliser ces informations.
Pour ce tutoriel, j’ai déjà préparé un realm que vous pouvez télécharger ici :
mkdir keycloak
curl -o keycloak/realm.json https://raw.githubusercontent.com/clevertechware/introduction-axum-rest-api/refs/heads/main/keycloak/realm.json
Ajoutez ceci à votre docker-compose.yml
:
services:
keycloak:
image: quay.io/keycloak/keycloak:26.3
environment:
KC_HOSTNAME: localhost
KC_HOSTNAME_PORT: 7080
KC_HOSTNAME_STRICT_BACKCHANNEL: "true"
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_HEALTH_ENABLED: "true"
KC_LOG_LEVEL: info
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
interval: 15s
timeout: 2s
retries: 15
command: ["start-dev", "--import-realm"]
ports:
- "8080:8080"
volumes:
- ./keycloak:/opt/keycloak/data/import
👉 Vous pouvez aussi récupérer le docker-compose.yml complet :
curl -o docker-compose.yml https://raw.githubusercontent.com/clevertechware/introduction-axum-rest-api/refs/heads/main/docker-compose.yml
Lancez le docker compose :
docker compose up -d
Pour tester l’API, vérifiez que le realm est bien créé :
http :8080/realms/rest-axum-api
On doit obtenir :
HTTP/1.1 200 OK
Cache-Control: no-cache
Content-Type: application/json;charset=UTF-8
Referrer-Policy: no-referrer
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
content-length: 611
{
"account-service": "http://localhost:8080/realms/rest-axum-api/account",
"public_key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsPb34CfC9jDbjHn8q+vvY6aWGwWPJM7qauqHYKYYt3WCrOg39lHkVltSiTAkw/H/bKS/I5Fylsa/bD9OVrwvE1LCWgvoSjGNQJrV5hyOC27hARYTR6tj+2mkXtuL1sVIqHznNwUHA1Dy91TBdg//PIrqTKjMupS0HySoAzSFm7CU41qWPXWsMeo9Ow9Ez3oIKELpPahaJg9QleD6tz7uE8lzLExXuJUP2YFGK+A3QbcEEYLC2sOd3bW9X5j/q4/9L98Gs7g6OILfFwlOy6EJVDBeT3XWagsWb9MrgLR8Qf6n0gqOYxMDlTspJ8g5bnYbEsTvEMpf/0RJb5U0pwJCOQIDAQAB",
"realm": "rest-axum-api",
"token-service": "http://localhost:8080/realms/rest-axum-api/protocol/openid-connect",
"tokens-not-before": 0
}
Tutoriel - Étape 3 : Sécurisation avec OIDC et Axum JWT OIDC
Pour sécuriser les endpoints, j’ai ajouté une authentification basée sur OIDC (OpenID Connect). J’ai utilisé la bibliothèque axum-jwt-oidc, qui simplifie la vérification d’un bearer token JWT dans les requêtes. L’intégration est simple :
- Configuration d’un middleware qui intercepte les requêtes,
- Vérification de la présence et de la validité du token,
- Extraction des claims OIDC pour les endpoints protégés.
Ajout de la dépendance
cargo add axum-jwt-oidc async-oidc-jwt-validator
Configuration du middleware
Créons d’abord une structure pour représenter un utilisateur authentifié via les claims OIDC :
#[derive(Debug, Clone, Deserialize, Serialize)]
struct CustomClaims {
sub: String,
email: Option<String>
}
Dans le fichier .env
, ajoutez :
echo 'ISSUER_URL=http://localhost:8080/realms/rest-axum-api' >> .env
Ajoutez le middleware à la construction de l’application Axum :
// Exécution des migrations
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
info!("Migrations executed successfully");
// ...
// Initialisation du validateur OIDC
let config = OidcConfig::new_with_discovery(
std::env::var("ISSUER_URL").expect("ISSUER_URL must be set"),
"".to_string(),
).await.unwrap();
let oidc_validator = OidcValidator::new(config);
// Configuration des règles de validation
let mut validation = Validation::new(Algorithm::RS256);
validation.validate_aud = false;
// Création de la couche d'authentification
let auth_layer = OidcAuthLayer::<CustomClaims>::new(oidc_validator, validation);
// Construction de l'application avec une route
let app = Router::new()
.route("/posts", get(get_posts).post(create_post))
// Couche d'extension
.layer(Extension(pool))
// Couche d'authentification
.layer(auth_layer);
Protection des endpoints
Sur chaque endpoint à protéger, ajoutez le paramètre Auth<CustomClaims>
:
async fn get_posts(
// 👇 On ajoute cette ligne
claims: Option<Extension<CustomClaims>>,
Extension(pool): Extension<Pool<Postgres>>,
) -> Result<Json<Vec<Post>>, StatusCode> {
if let Some(Extension(claims)) = claims {
let posts = sqlx::query_as!(Post, "SELECT id, author_id, title, body FROM posts")
.fetch_all(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
debug!("Posts fetched by {}", claims.sub);
Ok(Json(posts))
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
async fn create_post(
claims: Option<Extension<CustomClaims>>,
Extension(pool): Extension<Pool<Postgres>>,
Json(new_post): Json<CreatePost>,
) -> Result<Json<Post>, StatusCode> {
if let Some(Extension(claims)) = claims {
let post = sqlx::query_as!(
Post,
"INSERT INTO posts (author_id, title, body) VALUES ($1, $2, $3) RETURNING id, title, body, author_id",
new_post.author_id,
new_post.title,
new_post.body
)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
debug!("Post created by {}", claims.sub);
Ok(Json(post))
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
Relancez l’application :
cargo run
2025-09-13T21:15:13.906364Z INFO rust_axum_rest_api: Connected to the database!
2025-09-13T21:15:13.909841Z INFO sqlx::postgres::notice: relation "_sqlx_migrations" already exists, skipping
2025-09-13T21:15:13.913361Z INFO rust_axum_rest_api: Migrations executed successfully
2025-09-13T21:15:13.972214Z INFO rust_axum_rest_api: Listening on 0.0.0.0:8000
Testez les endpoints /posts
sans jeton JWT valide :
http :8000/posts
HTTP/1.1 401 Unauthorized
content-length: 0
date: Sat, 13 Sep 2025 21:15:44 GMT
Récupérez un jeton en ligne de commande :
http --form -A basic -a cli:WOhxh2rBPgVrbeH8cXjjNSX4kp1MLFkd :8080/realms/rest-axum-api/protocol/openid-connect/token grant_type=password username=bob password=password
Note : ici, la récupération se fait via un client intégré par défaut de Keycloak appelé cli
, que j’ai défini dans le realm rest-axum-api
et qui supporte le flow password
.
Automatisons un peu plus pour récupérer directement l’access token :
export ACCESS_TOKEN=$( http --form -A basic -a cli:WOhxh2rBPgVrbeH8cXjjNSX4kp1MLFkd :8080/realms/rest-axum-api/protocol/openid-connect/token grant_type=password username=bob password=password|jq -r .access_token)
Interrogez de nouveau l’API pour récupérer les articles :
http -A bearer -a $ACCESS_TOKEN :8000/posts
On obtient la liste :
[
{
"id": 1,
"author_id": null,
"body": "This is an awesome post",
"title": "Oh my god"
}
]
Tutoriel - Nettoyage (optionnel)
Supprimez le projet :
docker compose down --volumes
cd ../
rm -r rust-axum-rest-api
Résultat
Nous obtenons une API REST écrite en Rust, sécurisée par un Bearer Token OIDC délivré par Keycloak.
C’est une excellente base pour développer des services en Rust.
Vous pouvez retrouver le dépôt associé à cet article sur GitHub.