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.
[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
}
#[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");
}
}
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)
}
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())))
}
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);
}