Développement d'une API avec Actix Web en Rust : Architecture Hexagonale et TDD

Explorez le développement d'une API avec Actix Web en Rust en utilisant l'architecture hexagonale et le Test-Driven Development (TDD).

19 juillet 2025

Published

Hugo Mufraggi

Author

10 min read
Développement d'une API avec Actix Web en Rust : Architecture Hexagonale et TDD

Rust Actix TDD Architecture Hexagonale Part 3 API

Rappel et objectif

Dans les articles précédents nous avons développé et testé la partie interagissant avec la database, le Repository et la partie s’occupant de la business logic : le domain. Dans celui-ci nous allons développer la partie http qui va se connecter au domain. J’ai donc choisi d’utiliser le framework Actix mais il en existe d’autre tel que Rocket ou encore Rouille.

Préambule

Pour cet article la documentation de Actix va être votre meilleur ami. Histoire de se rapprocher d’un vrai cas d’usage, nous allons avoir un service pour nos user regroupant toutes nos routes et un autre service pour notre Health route.

Architecture de l’api

Dans notre dossier api nous allons créer deux fichiers et un dossier user :

  • Un premier fichier mod.rs du dossier api nous définiront l architecture de nos different service
  • Un second fichier du dossier api est la definition de la route health.
  • Le dossier contiendra toute l’architecture du service User et ces différentes routes, ce dossier contiendra 6 fichiers.

Cet article couvrira les routes CreateUser, Heath route et l’implémentation de notre httpServer.

Health route

La Health route est la route qui retourne toujours une response 200 et un OK. Cela sert à pouvoir verifier si le service est toujours en vie, cela permet de monitorer son service et s’assurer quil soit toujours Up.

#[derive(Serialize, PartialEq, Debug, Deserialize)]
struct Response {
    status: String,
}

pub async fn health() -> impl Responder {
    HttpResponse::Ok()
        .content_type("application/json")
        .body(serde_json::to_string(&Response {
        status: "OK".parse().unwrap()
    }).unwrap())
}

Dans cet exemple de code nous définissons la response et la fonction health. Dans le code de health nous retournons une HttpResponse::Ok avec un content_type a "application/json" puis on set le body de la response. Chose intéressante nous utilisons serde qui est la library pour serialiser/deserialiser dans notre cas nos serialiserons en string notre struct Response.

Testing

Nous allons vouloir vérifier que que notre fonction health retourne bien une response avec le statut OK.

let mut app  =
            test::init_service(App::new().route("/", web::get().to(health))).await;
let res = test::TestRequest::get()
    .uri("/")
    .send_request(&mut app).await;

Nous allons commencer par initialiser un service avec une seule route “/” contenant un get et notre fonction health. Puis nous émulons un appel à notre service sur l’url “/” puis nous l’await pour récupérer la response de notre service.

assert!(res.status().is_success());
let result:Response = test::read_body_json(res).await;
assert_eq!(result,(Response{ status: "OK".parse().unwrap()}));

Nous pouvons du coup vérifier que le statut soit bien un success, puis nous deserialisons le body avec test::read_body_json(res).await. Une fois le result extrait nous pouvons le comparer avec une struct Response contenant un OK.

User Mod

Dans le fichier [mod.rs](<http://mod.rs>) dans le dossier user nous allons vouloir centraliser et definir toutes nos differentes routes. Le App::new Actix est l’endroit où l’on va définir notre application, ce qui nous interesse est la définition des services. La fonction service peut prendre un typer Scope, le Scope permet la définition du scope du service, son routing et si il y a des structures partagées entre les routes. Dans notre cas, nous partagerons le repository.

pub fn user_service(repo: &Data<PostgresRepository>) -> Scope {
    web::scope("/user")
        .route("", web::post().to(create_user::serve))
        .route("/{id}", web::get().to(get_user::serve))
        .app_data(repo.clone())
}

Notre fonction sera définie pour être publique, prendre en argument un Data<PostgresRepository> et retourner Scope. Tout le long du tutoriel nous avons utilisé un Arc<PostgresRepository> il s’avère que Actix a un system propre à lui : les Data. Les Data utilise des types Arc d’après la documentation.

La suite du code est assez simple, on définit un web::scope("/user") cela veut dire que toutes nos routes seront précédées par /user. Puis, nous attacherons les routes pour créer des user et les get. La dernière ligne de scope est à mon goût la plus intéressante. Nous allons utiliser la fonction .app_data(repo.clone()) en lui passant notre repo cela va permettre de récupérer le repo dans nos différents endpoint du scope.

Create User

Request & Response

La Request et Response sont les définitions de ce qui va être envoyé par le client et ce qui va nous être retourné. Pour notre create user le Request sera contenu dans le body de la requête.

#[derive(Debug, Deserialize,Serialize)]
pub struct Request {
    pub first_name: String,
    pub last_name: String,
    pub birthday_date: String,
    pub city: String,
}

#[derive(Debug, Serialize, Deserialize )]
pub struct Response {
    pub id: String,
    pub first_name: String,
    pub last_name: String,
    pub birthday_date: NaiveDate,
    pub city: String,
}

Function définition

Lorsque j’introduis le concept de Data et son partage par le app_data tout va se passer maintenant. Actix permet la récupération des variables linker avec app_data, les params, params query et le body des request vous pouvez vous référer à cette partie de la documentation.

Vous pouvez retrouver l’extraction de params dans le code du get.

Pour le create user nous allons extraire le repository et la Request.

pub async fn serve(repo: Data< PostgresRepository>, req: web::Json<Request>) -> impl Responder

L’extracteur web::Json<Request> va faire un premier trie si la requet ne correspond pas à la définition de Request le service retourne une BAD_REQUEST.

Create User

La première étape est la création de notre create_user::Request avec les différentes variables de la Request extraites grace au web::Json<Request>.

let req = create_user::Request {
        first_name: req.0.first_name,
        last_name: req.0.last_name,
        birthday_date: req.0.birthday_date,
        city: req.0.city,
    };

Une fois notre req nous allons pouvoir faire appel à notre fonction,dans le domain en lui passant le repo extrait dans la definition de la fonction et notre req.

match create_user::execute(repo, req).await {
        Ok(create_user::Response {
               id,
               first_name,
               last_name,
               birthday_date,
               city
           }) => HttpResponse::Created()
            .content_type("application/json")
            .body(serde_json::to_string(&Response { id, first_name, last_name, birthday_date, city }).unwrap()),
        Err(create_user::Error::BadRequest) => HttpResponse::BadRequest().finish(),
        Err(create_user::Error::Conflict) => HttpResponse::Conflict().finish(),
        Err(create_user::Error::Unknown) => HttpResponse::Unknown().finish(),
    }

Nous allons encore utiliser le Syntax pattern proposé par Rust. Cela va nous permettre facilement de gérer les differents cas et retourner le bon comportement au client.

Test

Pour nos tests nous allons tester 3 situations:

  • Le cas où tout fonctionne
  • Le cas où le body de la request convient mais contient une erreur de format
  • Le cas où le body ne convient pas

Dans les tests du create, user se découpe en 3 parties:

  • L’initiation du repository et la création du Data<PostgresRepository>.
let url = "postgres://postgres:somePassword@localhost:5432/postgres";
let repository = PostgresRepository::new_pool(url).await.unwrap();
let repo = Data::new(repository);
  • L’initiation de l’app et notre request. La partie nouvelle et intéressante se situe dans le set_json. Cette fonction va serialiser et seter le body de la request avec la structure que l’on va lui passer. Dans notre cas, nous allons passer à la function une Request::good définie plus haut dans le fichier.
let mut app  = test::init_service(App::new()
      .route("/", web::post().to(serve))
      .app_data(repo)).await;
let res = test::TestRequest::post()
  .uri("/").set_json(Request::good())
  .send_request(&mut app).await;
  • La dernière partie est le check des valeurs retournées par notre end point cela reste similaire a ce qui a été fait pour la health route.
#[actix_web::test]
    async fn test_create_user_route_ok() {
        
       
        assert!(res.status().is_success());
        let result:Response = test::read_body_json(res).await;
        let excepted = Request::good();

        assert_eq!(result.first_name, excepted.first_name);
        assert_eq!(result.last_name, excepted.last_name);
        assert_eq!(result.birthday_date,  NaiveDate::parse_from_str(&excepted.birthday_date, "%Y-%m-%d").unwrap());
        assert_eq!(result.city, excepted.city);
    }

Http Server

Le http server est la partie regroupant toutes nos routes et lance notre App. Si nous prenons ligne par ligne nous initialisons le Logger puis nous définissons le service contenant la health route puis le service avec le scope du user service. Une fois tout initié, on lie l’app sur l’url que nous souhaitons utiliser et nous lançons le HttpServer.

let repo = Data::new(repo);
HttpServer::new(move|| {
    App::new()
        .wrap(Logger::default())
        .service(
            web::scope("/health")
                .route("", web::get().to(health))
        ).service(user_service(&repo))
}).bind((url, 8080))?
  .run().await

Conclusion

Apres avoir suivi ces 3 articles nous avons vu comment créer un service entier en architecture hexagonale en ayant testé chaque partie de notre code. J’espère que cette trilogie vous aura donné envie de vous mettre au Rust et vous aura aidé dans vos implémentations de votre api avec Actix.