The milestone project will be a real-time chat application. This application will showcase using Rust's Actix Web framework to handle real-time WebSocket communication, RESTful API interactions, middleware implementation, error handling, and database integration.
Features
User Registration and Authentication: Users can register and authenticate using JWT tokens. This will demonstrate the use of middleware for authentication and handling JSON data in POST requests.
Real-Time Messaging: Utilizing WebSockets, users can send and receive messages in real-time.
Create, Read, Update, Delete (CRUD) Operations: Users can create chat rooms, retrieve chat history, update their profiles, and delete messages. This will involve routing and request handling.
Database Integration: Store user data, chat messages, and room details. This will showcase database connectivity and queries.
Error Handling: Implement custom error handling for scenarios like invalid requests, authentication failures, and server errors.
Logging and Compression Middleware: Use built-in middleware for logging HTTP requests and compressing HTTP responses.
Building the project
Let’s name the project beechat. Create the project and add the following dependencies:
[dependencies]
actix-web = "4.4.0"
sqlx = { version = "0.7.3", features = ["postgres", "chrono", "runtime-tokio-native-tls"] }
jsonwebtoken = "9.1.0"
serde_json = "1.0.108"
chrono = { version = "0.4.31", features = ["serde"] }
serde = { version = "1.0.193", features = ["derive"] }
Next, let’s setup a PostgreSQL database to have relevant tables the users, chat rooms, and messages. Our tables for these objects will utilize SQL statements like:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE chat_rooms (
id SERIAL PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE messages (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
room_id INTEGER REFERENCES chat_rooms(id),
content TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
Save these into a create_tables.sql file because we will be needing them below.
We will make use of sqlx to create the Postgres connection inside of the beechat/database.rs file:
use sqlx::{Pool, Postgres, Executor};
use std::env;
pub async fn create_database_pool() -> Result<Pool<Postgres>, sqlx::Error> {
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
sqlx::postgres::PgPoolOptions::new()
.connect(&database_url)
.await
}
pub async fn setup_database(pool: &Pool<Postgres>) -> Result<(), sqlx::Error> {
let sql_query = include_str!("create_tables.sql");
pool.execute(sql_query).await?;
Ok(())
}
Now, let’s initialize the database connection pool inside of main.rs:
use crate::beechat::database;
pub mod beechat;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let create_db = create_database_pool().await.expect("Failed to create the database pool.");
setup_database(&create_db).await.expect("Failed to setup the database.");
}
Next, let’s model the user, chat room, and messages being sent. We can do this inside of beechat/model.rs:
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct User {
pub id: i32,
pub username: String,
pub password_hash: String,
pub email: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct ChatRoom {
pub id: i32,
pub name: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Message {
pub id: i32,
pub user_id: i32,
pub room_id: i32,
pub content: String,
pub created_at: DateTime<Utc>,
}
That looks decent. Now, for their implementations:
impl User {
pub async fn create(
pool: &sqlx::Pool<sqlx::Postgres>,
username: &str,
password_hash: &str,
email: &str,
) -> Result<(), sqlx::Error> {
let tx = pool;
sqlx::query("INSERT INTO users (username, password_hash, email) VALUES ($1, $2, $3)")
.bind(username)
.bind(password_hash)
.bind(email)
.execute(tx)
.await?;
tx.begin().await?.commit().await?;
Ok(())
}
}
impl ChatRoom {
pub async fn create(pool: &sqlx::Pool<sqlx::Postgres>, name: &str) -> Result<(), sqlx::Error> {
let tx = pool;
sqlx::query("INSERT INTO chat_rooms (name) VALUES ($1)")
.bind(name)
.execute(tx)
.await?;
tx.begin().await?.commit().await?;
Ok(())
}
pub async fn list_all(pool: &sqlx::Pool<sqlx::Postgres>) -> Result<Vec<Self>, sqlx::Error> {
let chat_rooms = sqlx::query_as::<_, ChatRoom>("SELECT * FROM chat_rooms")
.fetch_all(pool)
.await?;
Ok(chat_rooms)
}
}
impl Message {
pub async fn send(
pool: &sqlx::Pool<sqlx::Postgres>,
user_id: i32,
room_id: i32,
content: &str,
) -> Result<Self, sqlx::Error> {
let tx = pool;
let message = sqlx::query_as::<_, Message>(
"INSERT INTO messages (user_id, room_id, content) VALUES ($1, $2, $3) RETURNING *",
)
.bind(user_id)
.bind(room_id)
.bind(content)
.fetch_one(tx)
.await?;
tx.begin().await?.commit().await?;
Ok(message)
}
}
Looks decent! Now, we cannot just directly use them inside the main.rs file. We must create a web.rs file to house the HTTP functions supercharged by the Actix-Web framework.
Inside of web.rs, let’s add support for creating a new user. We will create a simpler structure for creating a new user and parsing the responses in JSON data format. Let’s do this:
use actix_web::{web, HttpResponse,Responder};
use serde::Deserialize;
use sqlx::Pool;
use sqlx::Postgres;
use crate::beechat::models::{ChatRoom, Message, User};
#[derive(Deserialize)]
pub struct UserCreationRequest {
pub username: String,
pub password_hash: String,
pub email: String,
}
pub async fn create_user(pool: web::Data<Pool<Postgres>>, user_info: web::Json<UserCreationRequest>) -> impl Responder {
match User::create(&pool, &user_info.username, &user_info.password_hash, &user_info.email).await {
Ok(user) =>HttpResponse::Ok().json(user),
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
}
}
Let’s do the same for the chat room and messages. For chat room:
#[derive(Deserialize)]
pub struct RoomCreationRequest {
pub name: String,
}
pub async fn create_chat_room(pool: web::Data<Pool<Postgres>>, room_info: web::Json<RoomCreationRequest>) -> impl Responder {
match ChatRoom::create(&pool, &room_info.name).await {
Ok(room) => HttpResponse::Ok().json(room),
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
}
}
And for messages:
#[derive(Deserialize)]
pub struct MessageCreationRequest {
pub user_id: i32,
pub room_id: i32,
pub content: String,
}
pub async fn send_message(pool: web::Data<Pool<Postgres>>, message_info: web::Json<MessageCreationRequest>) -> impl Responder {
match Message::send(&pool, message_info.user_id, message_info.room_id, &message_info.content).await {
Ok(message) => HttpResponse::Ok().json(message),
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
}
}
Now, let’s modify main.rs to make use of these new implementations:
use actix_web::{web, App, HttpServer};
use sqlx::postgres::PgPoolOptions;
use std::env;
mod beechat;
use beechat::{web::*, database::{create_database_pool, setup_database}};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let db_pool = PgPoolOptions::new()
.connect(&database_url)
.await
.expect("Failed to create pool");
let create_db = create_database_pool().await.expect("Failed to create the database pool.");
setup_database(&create_db).await.expect("Failed to setup the database.");
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(db_pool.clone()))
.route("/create_user", web::post().to(create_user))
.route("/create_chat_room", web::post().to(create_chat_room))
.route("/send_message", web::post().to(send_message))
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Note that we created a new App and HttpServer in the main.rs file. These two allow us to create a new HTTP server and route to the application.
You can head over to Digital Ocean to create a database, fetch the connection URL, and export it:
export DATABASE_URL=postgresql:
Now, let’s run the application:
Awesome! While the server is running, quickly pull out an HTTP client like Thunder Client and test it:

We get a Status: 200 OK messages confirming it works. In our create_user function, we returned a null response as a way to keep things simple, and this is displayed under the Response header above.