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.


Crédits & Ressources