Creating an online forum using Rust, Actix Web, and sqlx

I really enjoy creating backend web services with Rust and wanted to create a forum style website, similar to Reddit, in order to get more experience with the sqlx crate. I chose the name Ferris Forums for this site since Ferris, the official Rust mascot isn't covered by the controversial trademark policy of the Rust foundation.

This page is focused on exploring the integration of Actix Web and a PostgreSQL database using sqlx.



  • The application entry point is shown to the right:
  • Configurations are read from environment variables and a database connection is established.
  • An Actix Web server is created and routing is configured.
  • The project dependencies are shown below:
[package]
name = "ferris_forums"
version = "0.1.0"
edition = "2021"

[dependencies]
sqlx = { version = "0.8", features = [
    "postgres",
    "runtime-tokio-native-tls",
    "macros",
    "uuid",
    "chrono",
] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.10.0", features = ["serde", "v4"] }
actix-web = "4.9.0"
serde = { version = "1.0.210", features = ["derive"] }
env_logger = "0.11.5"
log = "0.4.22"
strum_macros = "0.26.4"
strum = { version = "0.26.3", features = ["derive"] }
argon2 = "0.5.3"
rand = "0.8.5"

[dev-dependencies]
actix-rt = "2.7"
tokio = { version = "1", features = ["full"] }

                            
 
mod api;
mod model;
mod repo;
mod routing;

use actix_web::{middleware::Logger, web::Data, App, HttpServer};

use sqlx::postgres::PgPoolOptions;
use std::env;

#[actix_web::main]
async fn main() -> std::io::Result<> {
    std::env::set_var("RUST_LOG", "debug");
    std::env::set_var("RUST_BACKTRACE", "1");
    env_logger::init();

    let database_url = env::var("DATABASE_URL")
        .expect("DATABASE_URL must be set in .env or environment variables");
    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect(&database_url)
        .await
        .expect("Could not connect to the database");

    HttpServer::new(move || {
        let logger = Logger::default();
        App::new()
            .wrap(logger)
            .app_data(Data::new(pool.clone()))
            .configure(routing::configure_post_routes)
            .configure(routing::configure_comment_routes)
            .configure(routing::configure_user_routes)
            .configure(routing::configure_sub_routes)
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}                     
                    


Defining Data Models



  • The model definition of a forum user includes a user id field, usrname, password_hash, a boolean flag indicating if they are a moderator, and a timestamp indicating when the user was created.
  • I've implemented logic to hash and verify a user password, and defined similar types that will be used to serialize and deserialize JSON payloads describing a new user and add the user to the database.
  • Rust makes it easy to write succinct tests in the same file as the covered code.
  • I've defined additional models to describe Posts, Comments, and Subs.
    #[derive(Serialize)]
    pub struct Post {
        pub id: Uuid,
        pub sub: String,
        pub user_id: i32,
        pub title: String,
        pub content: String,
        pub timestamp: DateTime<Utc>,
    }
    
    #[derive(Deserialize)]
    pub struct NewPost {
        pub user_id: i32,
        pub title: String,
        pub content: String,
    }
    
    #[derive(Serialize)]
    pub struct PostResponse {
        pub post: Post,
        pub comments: Vec<Comment>,
    }
    
    #[derive(Serialize)]
    pub struct Comment {
        pub id: Uuid,
        pub post_id: Uuid,
        pub user_id: i32,
        pub content: String,
        pub timestamp: DateTime<Utc>,
        pub parent_id: Option<Uuid>,
    }
    
    #[derive(Deserialize)]
    pub struct NewComment {
        pub user_id: i32,
        pub content: String,
        pub parent_id: Option<Uuid>,
    }
    
    #[derive(Serialize, Deserialize)]
    pub struct Sub {
        pub name: String,
        pub description: String,
        pub created_at: DateTime<Utc>,
    }
                            
use argon2::{
    password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
    Argon2,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
pub struct User {
    pub id: i32,
    pub username: String,
    pub password_hash: String,
    pub is_moderator: bool,
    pub created_at: DateTime<Utc>,
}

impl User {
    pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
        let salt = SaltString::generate(&mut OsRng);
        let argon2 = Argon2::default();
        let password_hash = argon2
            .hash_password(password.as_bytes(), &salt)?
            .to_string();

        Ok(password_hash)
    }

    pub fn verify_password(&self, password: &str) -> Result<bool, argon2::password_hash::Error> {
        let parsed_stored_hash = PasswordHash::new(&self.password_hash)?;

        let result = Argon2::default()
            .verify_password(password.as_bytes(), &parsed_stored_hash)
            .is_ok();

        Ok(result)
    }
}
    
#[derive(Serialize, Deserialize)]
pub struct NewUser {
    pub username: String,
    pub password: String,
    pub is_moderator: bool,
}

#[derive(Serialize, Deserialize)]
pub struct DbAddUser {
    pub username: String,
    #[serde(skip_serializing)]
    pub password_hash: String,
    pub is_moderator: bool,
    pub created_at: DateTime<Utc>,
}

#[cfg(test)]
mod user_model_tests {
    use super::*;
    #[test]
    fn test_hash_password_success() {
        let password = "strongpassword";
        let result = User::hash_password(password);
        assert!(
            result.is_ok(),
            "Password hashing failed: {:?}",
            result.err()
        );
    }

    #[test]
    fn test_verify_password_success() {
        let password = "strongpassword";
        let password_hash = User::hash_password(password).unwrap();

        let user = User {
            id: 1,
            username: "testuser".to_string(),
            password_hash,
            is_moderator: false,
            created_at: Utc::now(),
        };

        let result = user.verify_password(password);
        assert!(result.unwrap(), "Password verification failed");
    }

    #[test]
    fn test_verify_password_failure() {
        let password = "strongpassword";
        let wrong_password = "wrongpassword";
        let password_hash = User::hash_password(password).unwrap();

        let user = User {
            id: 1,
            username: "testuser".to_string(),
            password_hash,
            is_moderator: false,
            created_at: Utc::now(),
        };

        let result = user.verify_password(wrong_password);
        assert!(!result.unwrap(), "Password verification should have failed");
    }
}
                    


Defining Data Repository Methods

The rest of this page will examine only repository and API methods for comments. This will keep the page more focused and readable. There is a link to the project source code at the bottom of the page where the entire project source code can be found.

Below, I've used sqlx macros to generate valid SQL code to operate upon my PostgreSQL database.

use crate::model::comment::Comment;
use sqlx::PgPool;
use uuid::Uuid;
                                
pub async fn create_comment(pool: &PgPool, comment: &Comment) -> Result<Uuid, sqlx::Error> {
    sqlx::query!(
        r#"
        INSERT INTO comments (id, post_id, user_id, content, timestamp, parent_id)
        VALUES ($1, $2, $3, $4, $5, $6)
        "#,
        comment.id,
        comment.post_id,
        comment.user_id,
        comment.content,
        comment.timestamp,
        comment.parent_id
    )
    .execute(pool)
    .await?;

    Ok(comment.id)
}

pub async fn get_comments_by_post(
    pool: &PgPool,
    post_id: Uuid,
) -> Result<Vec<Comment>, sqlx::Error> {
    let comments = sqlx::query_as!(
        Comment,
        r#"
        SELECT id, post_id, user_id, content, timestamp, parent_id
        FROM comments
        WHERE post_id = $1
        ORDER BY timestamp ASC
        "#,
        post_id
    )
    .fetch_all(pool)
    .await?;

    Ok(comments)
}

pub async fn update_comment(
    pool: &PgPool,
    comment_id: Uuid,
    new_comment: String,
) -> Result<Uuid, sqlx::Error> {
    sqlx::query!(
        r#"
        UPDATE comments
        SET content = $1
        WHERE id = $2
        "#,
        new_comment,
        comment_id,
    )
    .execute(pool)
    .await?;

    Ok(comment_id)
}

pub async fn delete_comment(pool: &PgPool, comment_id: Uuid) -> Result<Uuid, sqlx::Error> {
    sqlx::query!(
        r#"
        DELETE FROM comments
        WHERE id = $1
        "#,
        comment_id,
    )
    .execute(pool)
    .await?;

    Ok(comment_id)
}
                            

Defining an API: Endpoints and Associated Functions

Below, I've defined server endpoints associated with functions that will use path parameters and JSON payloads to feed data into the CRUD operations shown above.

use crate::model::comment::{Comment, NewComment};
use crate::repo::comment as comment_repo;
use actix_web::{
    delete, get, patch, post,
    web::{Data, Json, Path},
    HttpResponse, Result,
};
use chrono::Utc;
use sqlx::PgPool;
use uuid::Uuid;
                                
#[post("/posts/{post_id}/comments")]
pub async fn create_comment(
    pool: Data<PgPool>,
    path: Path<Uuid>,
    body: Json<NewComment>,
) -> Result<HttpResponse> {
    let post_id = path.into_inner();
    let comment = Comment {
        id: Uuid::new_v4(),
        post_id,
        user_id: body.user_id,
        content: body.content.clone(),
        timestamp: Utc::now(),
        parent_id: body.parent_id,
    };

    let comment_id = comment_repo::create_comment(&pool, &comment)
        .await
        .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

    Ok(HttpResponse::Ok().body(comment_id.to_string()))
}

#[get("/posts/{post_id}/comments")]
pub async fn get_comments(pool: Data<PgPool>, path: Path<Uuid>) -> Result<Json<Vec<Comment>>> {
    let post_id = path.into_inner();
    let comments = comment_repo::get_comments_by_post(&pool, post_id)
        .await
        .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

    Ok(Json(comments))
}

#[patch("/comments/{comments_id}")]
pub async fn update_comment(
    pool: Data<PgPool>,
    path: Path<Uuid>,
    body: String,
) -> Result<HttpResponse, actix_web::Error> {
    let comment_id = path.into_inner();
    let update_content = String::from(&body);

    let comment_id = comment_repo::update_comment(&pool, comment_id, update_content.clone())
        .await
        .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

    Ok(HttpResponse::Ok().body(format!("{} -> {}", comment_id.to_string(), update_content)))
}

#[delete("/comments/{comment_id}")]
pub async fn delete_comment(
    pool: Data<PgPool>,
    path: Path<Uuid>,
) -> Result<HttpResponse, actix_web::Error> {
    let comment_id = path.into_inner();

    comment_repo::delete_comment(&pool, comment_id)
        .await
        .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

    Ok(HttpResponse::Ok().body(format!("{} was deleted", comment_id.to_string())))
}
                            

Final Steps

The final step is to map endpoints as services that can be called by the Actix Web framework:

use crate::api::comment::*;
use crate::api::post::*;
use crate::api::sub::*;
use crate::api::user::*;
use actix_web::web::ServiceConfig;

pub fn configure_user_routes(cfg: &mut ServiceConfig) {
    cfg.service(create_user)
        .service(get_user_by_id)
        .service(get_user_by_username)
        .service(get_users_by_sub)
        .service(verify_user_password)
        .service(username_exists)
        .service(grant_mod_status)
        .service(remove_mod_status)
        .service(update_user_password)
        .service(delete_user);
}

pub fn configure_sub_routes(cfg: &mut ServiceConfig) {
    cfg.service(create_sub)
        .service(get_all_subs)
        .service(get_subs_by_user_id)
        .service(get_sub_by_name)
        .service(update_sub)
        .service(delete_sub)
        .service(subscribe_user_to_sub);
}

pub fn configure_comment_routes(cfg: &mut ServiceConfig) {
    cfg.service(create_comment)
        .service(get_comments)
        .service(update_comment)
        .service(delete_comment);
}

pub fn configure_post_routes(cfg: &mut ServiceConfig) {
    cfg.service(create_post)
        .service(get_post)
        .service(get_posts_by_sub)
        .service(update_post)
        .service(delete_post);
}
                        


View the project source code on GitHub

Top Of Page