Construire une API en Rust avec Rocket.rs et Diesel.rs (Clean Architecture)

Dec 02 2022
Construire une application de blogging simple en utilisant Rust + Rocket.rs + Diesel.rs suivant Clean Architecture.
Dans ce guide, je vais vous guider tout au long du processus de création d'une API CRUD simple à partir de zéro dans Rust à l'aide de Rocket.rs.

Dans ce guide, je vais vous guider tout au long du processus de création d'une API CRUD simple à partir de zéro dans Rust à l'aide de Rocket.rs. Je vais vous montrer comment créer des migrations et accéder à une base de données PostgreSQL à l'aide de Diesel.rs et connecter le tout à un frontal React + Typescript. Lors de la construction du projet, nous suivrons l'architecture propre, bien que je ne m'étendrai pas trop sur ce que c'est car ce n'est pas l'objet de ce guide.

Ce guide suppose :

  • Vous avez déjà une configuration de base de données PostgreSQL
  • Vous avez la dernière version de Rust (ce guide utilise la v1.65.0)
  • Vous avez une compréhension basique à décente des concepts de Rust et de la syntaxe du langage

Construire l'architecture du projet

La première étape consiste à configurer l'architecture de l'application. Commencez par créer un projet Rust global :

cargo new rust-blog
cd rust-blog

  • La couche API gérera les demandes d'API et agira en tant que notre gestionnaire d'itinéraire.
  • La couche d'application gérera la logique derrière les demandes d'API.
  • La couche de domaine contiendra nos modèles et schémas de base de données.
  • La couche d'infrastructure contiendra nos migrations et nos connexions à la base de données.
  • La couche partagée contiendra tous les autres modèles dont notre projet aura besoin, tels que les structures de réponse.
  • cargo new api --lib
    cargo new application --lib
    cargo new domain --lib
    cargo new infrastructure --lib
    cargo new shared --lib
    
    

    .
    ├── Cargo.lock
    ├── Cargo.toml
    ├── api
    │   ├── Cargo.toml
    │   └── src
    │       └── lib.rs
    ├── application
    │   ├── Cargo.toml
    │   └── src
    │       └── lib.rs
    ├── domain
    │   ├── Cargo.toml
    │   └── src
    │       └── lib.rs
    ├── infrastructure
    │   ├── Cargo.toml
    │   └── src
    │       └── lib.rs
    └── shared
        ├── Cargo.toml
        └── src
            └── lib.rs
    

    [workspace]
    members = [
      "api",
      "domain",
      "infrastructure",
      "application",
      "shared",
    ]
    

Puisque nous utilisons Diesel.rs comme gestionnaire de base de données, nous devrons installer l' outil CLI . Diesel CLI a quelques dépendances qui doivent être installées au préalable en fonction de la base de données que vous prévoyez d'utiliser :

  • libpqpour PostgreSQL
  • libmysqlclientpour MySQL
  • libsqlite3pour SQlite

Une libpqfois installé, nous pouvons maintenant exécuter la commande suivante pour installer Diesel CLI :

cargo install diesel_cli --no-default-features --features postgres

echo DATABASE_URL=postgres://username:password@localhost/blog > .env

diesel setup

  • Un dossier de migrations utilisé pour stocker toutes les migrations
  • Une migration vide que nous pouvons utiliser pour gérer notre schéma de base de données.

diesel migration generate create_posts

Maintenant, allons-y et écrivons du SQL pour les migrations.

-- up.sql

CREATE TABLE posts (
  id SERIAL PRIMARY KEY,
  title VARCHAR NOT NULL,
  body TEXT NOT NULL,
  genre VARCHAR NOT NULL,
  published BOOLEAN NOT NULL DEFAULT false
)
-- down.sql

DROP TABLE posts

diesel migration run

Créer une connexion

Une fois notre première série de migrations terminée et notre architecture de projet définie, écrivons enfin du code Rust pour connecter notre application à la base de données.

# infrastructure/Cargo.toml

[package]
name = "infrastructure"
version = "0.1.0"
edition = "2021"

[dependencies]
diesel = { version = "2.0.0", features = ["postgres"] }
dotenvy = "0.15"
// infrastructure/src/lib.rs

use diesel::pg::PgConnection;
use diesel::prelude::*;
use dotenvy::dotenv;
use std::env;

pub fn establish_connection() -> PgConnection {
    dotenv().ok();

    let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set.");

    PgConnection::establish(&database_url).unwrap_or_else(|_| panic!("Error connecting to {}", database_url))
}

Modèles et schémas

Commencez par naviguer dans domainet ajouter les modules suivants à lib.rs.

// domain/src/lib.rs

pub mod models;
pub mod schema;

# domain/Cargo.toml

[package]
name = "domain"
version = "0.1.0"
edition = "2021"

[dependencies]
rocket = { version = "0.5.0-rc.2", features = ["json"] }
diesel = { version = "2.0.0", features = ["postgres"] }
serde = { version = "1.0.147", features = ["derive"] }
// domain/src/models.rs

use crate::schema::posts;
use diesel::prelude::*;
use rocket::serde::{Deserialize, Serialize};
use std::cmp::{Ord, Eq, PartialOrd, PartialEq};

// Queryable will generate the code needed to load the struct from an SQL statement
#[derive(Queryable, Serialize, Ord, Eq, PartialEq, PartialOrd)]
pub struct Post {
    pub id: i32,
    pub title: String,
    pub body: String,
    pub genre: String,
    pub published: bool,
}

#[derive(Insertable, Deserialize)]
#[serde(crate = "rocket::serde")]
#[diesel(table_name = posts)]
pub struct NewPost {
    pub title: String,
    pub body: String,
    pub genre: String,
}
// domain/src/schema.rs

// @generated automatically by Diesel CLI.

diesel::table! {
    posts (id) {
        id -> Int4,
        title -> Varchar,
        body -> Text,
        genre -> Varchar,
        published -> Bool,
    }
}

En plus de définir des modèles de base de données, créons un modèle pour structurer le formatage de nos réponses API. Accédez à shared/srcun nouveau fichier et créez-le response_models.rs.

# shared/Cargo.toml

[package]
name = "shared"
version = "0.1.0"
edition = "2021"

[dependencies]
domain = { path = "../domain" }

rocket = { version = "0.5.0-rc.2", features = ["json"] }
serde = { version = "1.0.147", features = ["derive"] }
// shared/src/lib.rs

pub mod response_models;
// shared/src/response_models.rs

use domain::models::Post;
use rocket::serde::Serialize;

#[derive(Serialize)]
pub enum ResponseBody {
    Message(String),
    Post(Post),
    Posts(Vec<Post>)
}

#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
pub struct Response {
    pub body: ResponseBody,
}

Configuration de Rocket.rs

Ouah! C'était beaucoup de configuration juste pour notre base de données, juste pour que nous soyons tous à jour, voici à quoi devrait ressembler la structure du projet actuellement :

.
├── Cargo.lock
├── Cargo.toml
├── api
│   └── ...
├── application
│   └── ...
├── domain
│   ├── Cargo.toml
│   └── src
│       ├── lib.rs
│       └── models.rs
├── infrastructure
│   ├── Cargo.toml
│   ├── migrations
│   │   └── 2022–11–18–090125_create_posts
│   │       ├── up.sql
│   │       └── down.sql
│   └── src
│     ├── lib.rs
│     └── schema.rs
└── shared
    ├── Cargo.toml
    └── src
        ├── lib.rs
        └── response_models.rs

Accédez aux apidépendances suivantes et importez-les :

# api/Cargo.toml

[package]
name = "api"
version = "0.1.0"
edition = "2021"

[dependencies]
domain = { path = "../domain" }
application = { path = "../application" }
shared = { path = "../shared" }

rocket = { version = "0.5.0-rc.2", features = ["json"] }
serde_json = "1.0.88"

.
└── api
    ├── Cargo.toml
    └── src
        ├── bin
        │   └── main.rs
        └── lib.rs

// api/src/lib.rs

pub mod post_handler;
// api/src/bin/main.rs

#[macro_use] extern crate rocket;
use api::post_handler;

#[launch]
fn rocket() -> _ {
    rocket::build()
        .mount("/api", routes![
            post_handler::list_posts_handler, 
            post_handler::list_post_handler,
        ])
}

Créez un nouveau fichier appelé post_handler.rsdans srcet écrivez le code de modèle suivant :

// api/src/post_handler.rs

use shared::response_models::{Response, ResponseBody};
use application::post::{read};
use domain::models::{Post};
use rocket::{get};
use rocket::response::status::{NotFound};
use rocket::serde::json::Json;

#[get("/")]
pub fn list_posts_handler() -> String {
    todo!()
}

#[get("/post/<post_id>")]
pub fn list_post_handler(post_id: i32) -> Result<String, NotFound<String>> {
    todo!()
}

  1. GET /api/ (utilisé pour lister tous les messages)
  2. GET /api/post/<post_id> (utilisé pour répertorier un article par identifiant)

Avec les gestionnaires de requêtes modélisés, écrivons la logique requise pour les routes. Dans application, créez un nouveau dossier appelé post. Ce dossier contiendra un fichier pour chacune de nos logiques de routage.

# application/Cargo.toml

[package]
name = "application"
version = "0.1.0"
edition = "2021"

[dependencies]
domain = { path = "../domain" }
infrastructure = { path = "../infrastructure" }
shared = { path = "../shared" }

diesel = { version = "2.0.0", features = ["postgres"] }
serde_json = "1.0.88"
rocket = { version = "0.5.0-rc.2", features = ["json"] }
// application/src/lib.rs

pub mod post;
// application/src/post/mod.rs

pub mod read;

// application/src/post/read.rs

use domain::models::Post;
use shared::response_models::{Response, ResponseBody};
use infrastructure::establish_connection;
use diesel::prelude::*;
use rocket::response::status::NotFound;

pub fn list_post(post_id: i32) -> Result<Post, NotFound<String>> {
    use domain::schema::posts;

    match posts::table.find(post_id).first::<Post>(&mut establish_connection()) {
        Ok(post) => Ok(post),
        Err(err) => match err {
            diesel::result::Error::NotFound => {
                let response = Response { body: ResponseBody::Message(format!("Error selecting post with id {} - {}", post_id, err))};
                return Err(NotFound(serde_json::to_string(&response).unwrap()));
            },
            _ => {
                panic!("Database error - {}", err);
            }        
        }
    }
}

pub fn list_posts() -> Vec<Post> {
    use domain::schema::posts;

    match posts::table.select(posts::all_columns).load::<Post>(&mut establish_connection()) {
        Ok(mut posts) => {
            posts.sort();
            posts
        },
        Err(err) => match err {
            _ => {
                panic!("Database error - {}", err);
            }
        }
    }
}

Une fois la logique de notre route écrite, revenons à notre gestionnaire de publication pour terminer nos deux routes GET.

// api/src/post_handler.rs

// ...

#[get("/")]
pub fn list_posts_handler() -> String {
    //  New function body!
    let posts: Vec<Post> = read::list_posts();
    let response = Response { body: ResponseBody::Posts(posts) };

    serde_json::to_string(&response).unwrap()
}

#[get("/post/<post_id>")]
pub fn list_post_handler(post_id: i32) -> Result<String, NotFound<String>> {
    //  New function body!
    let post = read::list_post(post_id)?;
    let response = Response { body: ResponseBody::Post(post) };

    Ok(serde_json::to_string(&response).unwrap())
}

Changeons cela.

Création de publications

Comme précédemment, nous allons commencer par modéliser le gestionnaire de route. Ce sera une requête POST qui acceptera les données JSON.

// api/src/post_handler.rs

use shared::response_models::{Response, ResponseBody};
use application::post::{create, read}; //  New!
use domain::models::{Post, NewPost}; //  New! 
use rocket::{get, post}; //  New! 
use rocket::response::status::{NotFound, Created}; //  New! 
use rocket::serde::json::Json;

// ...

#[post("/new_post", format = "application/json", data = "<post>")]
pub fn create_post_handler(post: Json<NewPost>) -> Created<String> {
    create::create_post(post)
}

// application/src/post/mod.rs

pub mod read;
pub mod create; //  New!
// application/src/post/create.rs

use domain::models::{Post, NewPost};
use shared::response_models::{Response, ResponseBody};
use infrastructure::establish_connection;
use diesel::prelude::*;
use rocket::response::status::Created;
use rocket::serde::json::Json;

pub fn create_post(post: Json<NewPost>) -> Created<String> {
    use domain::schema::posts;

    let post = post.into_inner();

    match diesel::insert_into(posts::table).values(&post).get_result::<Post>(&mut establish_connection()) {
        Ok(post) => {
            let response = Response { body: ResponseBody::Post(post) };
            Created::new("").tagged_body(serde_json::to_string(&response).unwrap())
        },
        Err(err) => match err {
            _ => {
                panic!("Database error - {}", err);
            }
        }
    }
}

// api/src/bin/main.rs

#[macro_use] extern crate rocket;
use api::post_handler;

#[launch]
fn rocket() -> _ {
    rocket::build()
        .mount("/api", routes![
            post_handler::list_posts_handler, 
            post_handler::list_post_handler,
            post_handler::create_post_handler, //  New!
        ])
}

Test CR__

Avec deux de nos quatre lettres implémentées, faisons un petit test. Revenez au répertoire racine et exécutez l'application.

cargo run

Figure : GET / fonctionne comme prévu
Figure : POST /new_post fonctionne comme prévu

Les deux dernières lettres

Les deux dernières opérations dont nous avons besoin sont la mise à jour et la suppression. Nous mettrons en œuvre la mise à jour via la « publication » d'un message et la suppression par, eh bien… la suppression d'un message.

Comme pour les deux dernières lettres, créons nos gestionnaires.

// api/src/post_handler.rs

use shared::response_models::{Response, ResponseBody};
use application::post::{create, read, publish, delete}; //  New!
use domain::models::{Post, NewPost};
use rocket::{get, post};
use rocket::response::status::{NotFound, Created};
use rocket::serde::json::Json;

// ...

#[get("/publish/<post_id>")]
pub fn publish_post_handler(post_id: i32) -> Result<String, NotFound<String>> {
    let post = publish::publish_post(post_id)?; 
    let response = Response { body: ResponseBody::Post(post) };

    Ok(serde_json::to_string(&response).unwrap())
}

#[get("/delete/<post_id>")]
pub fn delete_post_handler(post_id: i32) -> Result<String, NotFound<String>> {
    let posts = delete::delete_post(post_id)?;
    let response = Response { body: ResponseBody::Posts(posts) };

    Ok(serde_json::to_string(&response).unwrap())
}

// application/src/post/mod.rs

pub mod create;
pub mod read;
pub mod publish; //  New!
pub mod delete; //  New!
// application/src/post/publish.rs

use domain::models::Post;
use shared::response_models::{Response, ResponseBody};
use infrastructure::establish_connection;
use rocket::response::status::NotFound;
use diesel::prelude::*;

pub fn publish_post(post_id: i32) -> Result<Post, NotFound<String>> {
    use domain::schema::posts::dsl::*;

    match diesel::update(posts.find(post_id)).set(published.eq(true)).get_result::<Post>(&mut establish_connection()) {
        Ok(post) => Ok(post),
        Err(err) => match err {
            diesel::result::Error::NotFound => {
                let response = Response { body: ResponseBody::Message(format!("Error publishing post with id {} - {}", post_id, err))};
                return Err(NotFound(serde_json::to_string(&response).unwrap()));
            },
            _ => {
                panic!("Database error - {}", err);
            }        
        }
    }
}
// application/src/post/delete.rs

use shared::response_models::{Response, ResponseBody};
use infrastructure::establish_connection;
use diesel::prelude::*;
use rocket::response::status::NotFound;
use domain::models::Post;

pub fn delete_post(post_id: i32) -> Result<Vec<Post>, NotFound<String>> {
    use domain::schema::posts::dsl::*;
    use domain::schema::posts;

    let response: Response;

    let num_deleted = match diesel::delete(posts.filter(id.eq(post_id))).execute(&mut establish_connection()) {
        Ok(count) => count,
        Err(err) => match err {
            diesel::result::Error::NotFound => {
                let response = Response { body: ResponseBody::Message(format!("Error deleting post with id {} - {}", post_id, err))};
                return Err(NotFound(serde_json::to_string(&response).unwrap()));
            },
            _ => {
                panic!("Database error - {}", err);
            }        
        }
    };

    if num_deleted > 0 {
        match posts::table.select(posts::all_columns).load::<Post>(&mut establish_connection()) {
            Ok(mut posts_) => {
                posts_.sort();
                Ok(posts_)
            },
            Err(err) => match err {
                _ => {
                    panic!("Database error - {}", err);
                }
            }
        }
    } else {
        response = Response { body: ResponseBody::Message(format!("Error - no post with id {}", post_id))};
        Err(NotFound(serde_json::to_string(&response).unwrap()))
    } 
}

// api/src/bin/main.rs

#[macro_use] extern crate rocket;
use api::post_handler;

#[launch]
fn rocket() -> _ {
    rocket::build()
        .mount("/api", routes![
            post_handler::list_posts_handler, 
            post_handler::list_post_handler,
            post_handler::create_post_handler,
            post_handler::publish_post_handler, //  New!
            post_handler::delete_post_handler, //  New!
        ])
}

Votre projet devrait maintenant se rapprocher de ce qui suit :

.
├── Cargo.lock
├── Cargo.toml
├── api
│   ├── Cargo.toml
│   └── src
│       ├── bin
│       │   └── main.rs
│       ├── lib.rs
│       └── post_handler.rs
├── application
│   ├── Cargo.toml
│   └── src
│       ├── lib.rs
│       └── post
│           ├── create.rs
│           ├── delete.rs
│           ├── mod.rs
│           ├── publish.rs
│           └── read.rs
├── domain
│   ├── Cargo.toml
│   └── src
│       ├── lib.rs
│       ├── models.rs
│       └── schema.rs
├── infrastructure
│   ├── Cargo.toml
│   ├── migrations
│   │   └── 2022–11–18–090125_create_posts
│   │       ├── up.sql
│   │       └── down.sql
│   └── src
│       └── lib.rs
└── shared
    ├── Cargo.toml
    └── src
        ├── lib.rs
        └── response_models.rs

Il y a quelques choses qui pourraient être améliorées en regardant l'application dans son ensemble.

Premièrement, chaque fois que nous voulons utiliser la base de données, nous ouvrons une nouvelle connexion. Cela peut devenir coûteux et gourmand en ressources à plus grande échelle. Une façon de résoudre ce problème consiste à utiliser un pool de connexions, Rocket.rs inclut un support intégré pour R2D2 , un gestionnaire de pool de connexions pour Rust.

Deuxièmement, Diesel.rs n'est pas asynchrone - ce n'est pas trop un problème à cette échelle. Cependant, cela peut devenir un problème plus important pour les applications plus importantes. Il n'y a, au moment de la rédaction, aucune implémentation asynchrone de la part de l'équipe officielle derrière Diesel.rs. Comme alternative, une caisse externe est disponible pour fournir cette fonctionnalité.

Enfin, une interface utilisateur frontale pourrait être créée parallèlement à l'API Rust. Dans le répertoire racine, vous créerez un nouveau projet appelé web_uien utilisant le langage frontal de votre choix. Tout ce que vous auriez alors à faire est d'exécuter les deux projets séparément, en appelant l'API Rust à partir de votre client frontal. Voici ma mise en œuvre d'un front-end pour un peu d'inspiration:

Figure : Ma mise en œuvre de l'interface utilisateur frontale

Conclusion

Phew! Quel voyage. Non seulement avons-nous appris à utiliser Rocket.rs et Diesel.rs, mais nous avons également appris à les utiliser ensemble pour créer une API de blog dans Rust. Parallèlement à cela, nous avons construit une interface pour cela et l'avons regroupé dans un seul fichier de projet suivant Clean Architecture.

Tout le code ainsi que mon implémentation du front-end peuvent être trouvés ici :https://github.com/BrookJeynes/blog-rust

J'espère que vous avez beaucoup appris aujourd'hui, et essayez vous-même le processus et créez quelque chose de nouveau ! Assurez-vous de mettre en vedette le référentiel Github et faites-moi savoir ce que je devrais couvrir ensuite ou tout commentaire que vous avez.

Merci d'avoir lu,
- Brook ❤

Références

Caisses :

  • Diesel.rs
  • Fusée.rs
  • Serde-rs/serde
  • Serde-rs/json