In this chapter, we will apply the knowledge acquired from the previous chapters to develop a real-world web application using Rocket. We'll build a blog platform, integrating user authentication, database operations, and dynamic content rendering.
5.1 Project Overview
The project is a blog platform with the following features:
User Authentication: Users can sign up, log in, and log out.
Blog Post Management: Users can create, read, update, and delete blog posts.
Comment System: Users can comment on posts.
Search and Tagging Functionality: Posts can be searched and tagged for better organization.
5.2 Setting Up the Project
Start by creating a new Rocket project:
cargo new rocket_blog --bin
cd rocket_blog
Edit your Cargo.toml to include Rocket and other necessary dependencies like bcrypt for password hashing and Tera for templating.
5.3 Database Setup and Models
In this section, we focus on setting up the PostgreSQL database and defining the models for our Rocket blog platform. Instead of using an ORM like Diesel, we'll write our SQL queries directly and execute them from .sql files within Rust functions. This approach gives us more control over the database interactions.
Step 1: Setting Up the Database
Install PostgreSQL: Ensure PostgreSQL is installed on your system.
Create a New Database: Create a new database for the blog platform. You can use a PostgreSQL client or the command line:
CREATE DATABASE rocket_blog;
Database Configuration: In your Rocket project, configure the database connection. You can use environment variables or a configuration file to store your database credentials.
In your Rocket.toml file:
[global.databases]
rocket_blog_db = { url = "postgres://username:password@localhost/rocket_blog" }
Replace username and password with your actual PostgreSQL credentials.
Step 2: Creating SQL Files
Create a new directory in your project, sql, and add your SQL files here. For instance:
create_users_table.sql
create_posts_table.sql
create_comments_table.sql
Here's an example for create_users_table.sql:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
Repeat this process for posts, comments, and other tables you need.
Step 3: Writing Rust Functions to Execute SQL Queries
In your Rust project, create a module to handle database operations. For example, src/db.rs.
Database Connection Pool: Set up a connection pool to manage your database connections. You can use the tokio-postgres crate along with deadpool_postgres for asynchronous pool management.
use deadpool_postgres::{Config, Manager, Pool};
use tokio_postgres::NoTls;
pub fn create_pool() -> Pool {
let mut cfg = Config::new();
cfg.host = Some("localhost".to_string());
cfg.user = Some("username".to_string());
cfg.password = Some("password".to_string());
cfg.dbname = Some("rocket_blog".to_string());
let manager = Manager::new(cfg, NoTls);
Pool::new(manager, 16)
}
Executing SQL Files: Create functions to execute SQL queries from your .sql files. For example, a function to create the user's table:
use std::fs;
pub async fn create_users_table(pool: &Pool) -> Result<(), Box<dyn std::error::Error>> {
let sql = fs::read_to_string("sql/create_users_table.sql")?;
let conn = pool.get().await?;
conn.execute(sql.as_str(), &[]).await?;
Ok(())
}
pub async fn create_posts_table(pool: &Pool) -> Result<(), Box<dyn std::error::Error>> {
let sql = fs::read_to_string("sql/create_posts_table.sql")?;
let conn = pool.get().await?;
conn.execute(sql.as_str(), &[]).await?;
Ok(())
}
pub async fn create_comments_table(pool: &Pool) -> Result<(), Box<dyn std::error::Error>> {
let sql = fs::read_to_string("sql/create_comments_table.sql")?;
let conn = pool.get().await?;
conn.execute(sql.as_str(), &[]).await?;
Ok(())
}
Calling the Function: In your main application or during initialization, call this function to create the necessary tables.
#[tokio::main]
async fn main() {
let pool = create_pool();
create_users_table(&pool).await.expect("Failed to create users table.");
create_posts_table(&pool).await.expect("Failed to create posts table.");
create_comments_table(&pool).await.expect("Failed to create comments table.");
}
Repeat this process for other tables and queries. This approach of using raw SQL provides you with fine-grained control over your database operations, allowing for complex queries and database interactions that may be cumbersome with an ORM.
5.4 Implementing User Authentication
In this step, we'll implement user authentication using direct PostgreSQL queries, including registration, login, and logout functionalities for our Rocket blog platform.
Step 1: Setting Up User Authentication
Add Dependencies: Ensure your Cargo.toml includes necessary dependencies like bcrypt for hashing passwords and jsonwebtoken for handling JSON Web Tokens (JWT) if you use them for session management.
[dependencies]
bcrypt = "0.15.0"
jsonwebtoken = "9.1.0"
serde = "1.0.193"
User Model: Update your user model in src/models.rs to include the necessary fields:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
pub struct User {
pub id: i32,
pub username: String,
pub email: String,
pub password_hash: String,
}
Password Hashing: Create a utility function for hashing passwords using bcrypt.
use bcrypt::{hash, DEFAULT_COST};
pub fn hash_password(password: &str) -> Result<String, bcrypt::BcryptError> {
hash(password, DEFAULT_COST)
}
Step 2: Implementing Registration and Login Logic
Registration Handler: Create a route to handle user registration. This will involve receiving user data, hashing the password, and storing the user in the database. This will be done inside the body of the main.rs for now.
#[post("/register", data = "<user>")]
async fn register(user: Json<User>, db_pool: State<DbPool>) -> Result<String, String> {
let hashed_password = hash_password(&user.password).map_err(|e| e.to_string())?;
let insert_query = "INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3)";
Ok("User registered successfully".to_string())
}
Login Handler: Create a route that checks the provided credentials against the database for logging in.
#[post("/login", data = "<login>")]
async fn login(login: Json<Login>, db_pool: State<DbPool>) -> Result<String, String> {
let find_user_query = "SELECT * FROM users WHERE email = $1";
Ok("User logged in successfully".to_string())
}
Session Management: Depending on your approach (cookies, JWT, etc.), implement session creation upon successful login.
Step 3: Implementing Logout
Logout Handler: If using JWT, this might involve the client deleting the token. For cookie-based sessions, it would involve clearing the session cookie.
#[post("/logout")]
async fn logout() -> Result<&'static str, String> {
Ok("Logged out")
}
Step 4: Integrating Authentication with the Database
Database Functions: In your src/db.rs, add functions to insert a new user during registration and fetch a user during login.
use tokio_postgres::{Client, Error};
use crate::blogengine::models::User;
pub async fn create_user(client: &Client, username: &str, email: &str, password_hash: &str) -> Result<(), Error> {
let stmt = "INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3)";
client.execute(stmt, &[&username, &email, &password_hash]).await?;
Ok(())
}
pub async fn get_user_by_email(client: &Client, email: &str) -> Result<User, Error> {
let stmt = "SELECT id, username, email, password_hash FROM users WHERE email = $1";
let row = client.query_one(stmt, &[&email]).await?;
Ok(User {
id: row.get(0),
username: row.get(1),
email: row.get(2),
password_hash: row.get(3),
})
}
Using the Database Functions: In your route handlers (register and login), call these functions to interact with the database.
Now, let’s write the complete main.rs file:
pub mod blogengine;
#[macro_use] extern crate rocket;
use rocket::serde::json::{Json, json};
use rocket::State;
use tokio_postgres::NoTls;
use deadpool_postgres::{Config, Pool};
use crate::blogengine::db::{create_user, get_user_by_email};
use crate::blogengine::models::{User, hash_password};
type DbPool = Pool;
fn init_db_pool() -> DbPool {
let mut cfg = Config::new();
cfg.host = Some("localhost".to_string());
cfg.user = Some("username".to_string());
cfg.password = Some("password".to_string());
cfg.dbname = Some("rocket_blog".to_string());
let pool = cfg.create_pool(NoTls).expect("Database pool creation failed");
pool
}
#[post("/register", data = "<user_data>")]
async fn register(user_data: Json<User>, db_pool: State<DbPool>) -> Result<Json<&'static str>, String> {
let conn = db_pool.get().await.map_err(|e| e.to_string())?;
let hashed_password = hash_password(&user_data.password_hash).map_err(|e| e.to_string())?;
create_user(&conn, &user_data.username, &user_data.email, &hashed_password).await.map_err(|e| e.to_string())?;
Ok(json!("User registered successfully"))
}
#[post("/login", data = "<login_data>")]
async fn login(login_data: Json<User>, db_pool: State<DbPool>) -> Result<Json<&'static str>, String> {
let conn = db_pool.get().await.map_err(|e| e.to_string())?;
let user = get_user_by_email(&conn, &login_data.email).await.map_err(|e| e.to_string())?;
Ok(json!("User logged in successfully"))
}
#[post("/logout")]
async fn logout() -> Result<Json<&'static str>, String> {
Ok(json!("Logged out"))
}
#[launch]
fn rocket() -> _ {
let db_pool = init_db_pool();
rocket::build()
.manage(db_pool)
.mount("/", routes![register, login, logout])
}
Awesome! You can now recreate the Blog and Comments models and implementations similarly.
5.5 Building the Blog Post Functionality
To implement CRUD operations for blog posts, we first need to define the Post model and then create the necessary routes and handlers for creating, reading, updating, and deleting blog posts. These operations will interact with the PostgreSQL database.
Step 1: Define the Post Model
In your models.rs, define a Post struct to represent blog posts:
#[derive(Serialize, Deserialize)]
pub struct Post {
pub id: i32,
pub title: String,
pub content: String,
pub author_id: i32,
}
Step 2: Database Functions for Post CRUD Operations
In db.rs, add functions for CRUD operations on blog posts:
use crate::blogengine::models::Post;
use tokio_postgres::Client;
pub async fn create_post(client: &Client, title: &str, content: &str, author_id: i32) -> Result<(), tokio_postgres::Error> {
let stmt = "INSERT INTO posts (title, content, author_id) VALUES ($1, $2, $3)";
client.execute(stmt, &[&title, &content, &author_id]).await?;
Ok(())
}
pub async fn get_posts(client: &Client) -> Result<Vec<Post>, tokio_postgres::Error> {
let stmt = "SELECT id, title, content, author_id FROM posts";
let rows = client.query(stmt, &[]).await?;
let posts = rows.into_iter().map(|row| Post {
id: row.get(0),
title: row.get(1),
content: row.get(2),
author_id: row.get(3),
}).collect();
Ok(posts)
}
Step 3: Route Handlers for Post Operations
In your main application file, define route handlers for each CRUD operation:
#[post("/posts", data = "<post_data>")]
async fn create_post(post_data: Json<Post>, db_pool: State<DbPool>) -> Result<Json<&'static str>, String> {
let conn = db_pool.get().await.map_err(|e| e.to_string())?;
blogengine::db::create_post(&conn, &post_data.title, &post_data.content, post_data.author_id).await.map_err(|e| e.to_string())?;
Ok(json!("Post created successfully"))
}
#[get("/posts")]
async fn read_posts(db_pool: State<DbPool>) -> Result<Json<Vec<Post>>, String> {
let conn = db_pool.get().await.map_err(|e| e.to_string())?;
let posts = blogengine::db::get_posts(&conn).await.map_err(|e| e.to_string())?;
Ok(Json(posts))
}
#[launch]
fn rocket() -> _ {
let db_pool = init_db_pool();
rocket::build()
.manage(db_pool)
.mount("/", routes![register, login, logout, create_post, read_posts])
}
Now, let’s work on commenting under blog posts.
5.6 Adding Comments
We'll create a Comment model to add a comment system to our blog platform. Database functions to handle comment operations and the necessary route handlers.
Step 1: Define the Comment Model
In models.rs, define a Comment struct to represent comments on blog posts:
#[derive(Serialize, Deserialize)]
pub struct Comment {
pub id: i32,
pub post_id: i32,
pub author_id: i32,
pub content: String,
}
Step 2: Database Functions for Comment Operations
In db.rs, add functions to insert and retrieve comments:
use crate::blogengine::models::Comment;
pub async fn create_comment(client: &Client, post_id: i32, author_id: i32, content: &str) -> Result<(), tokio_postgres::Error> {
let stmt = "INSERT INTO comments (post_id, author_id, content) VALUES ($1, $2, $3)";
client.execute(stmt, &[&post_id, &author_id, &content]).await?;
Ok(())
}
pub async fn get_comments_for_post(client: &Client, post_id: i32) -> Result<Vec<Comment>, tokio_postgres::Error> {
let stmt = "SELECT id, post_id, author_id, content FROM comments WHERE post_id = $1";
let rows = client.query(stmt, &[&post_id]).await?;
let comments = rows.into_iter().map(|row| Comment {
id: row.get(0),
post_id: row.get(1),
author_id: row.get(2),
content: row.get(3),
}).collect();
Ok(comments)
}
Step 3: Route Handlers for Comment Operations
In your main application file, define route handlers for creating and reading comments:
#[post("/posts/<post_id>/comments", data = "<comment_data>")]
async fn create_comment(post_id: i32, comment_data: Json<Comment>, db_pool: State<DbPool>) -> Result<Json<&'static str>, String> {
let conn = db_pool.get().await.map_err(|e| e.to_string())?;
blogengine::db::create_comment(&conn, post_id, comment_data.author_id, &comment_data.content).await.map_err(|e| e.to_string())?;
Ok(json!("Comment added successfully"))
}
#[get("/posts/<post_id>/comments")]
async fn read_comments(post_id: i32, db_pool: State<DbPool>) -> Result<Json<Vec<Comment>>, String> {
let conn = db_pool.get().await.map_err(|e| e.to_string())?;
let comments = blogengine::db::get_comments_for_post(&conn, post_id).await.map_err(|e| e.to_string())?;
Ok(Json(comments))
}
#[launch]
fn rocket() -> _ {
let db_pool = init_db_pool();
rocket::build()
.manage(db_pool)
.mount("/", routes![register, login, logout, create_post, read_posts, create_comment, read_comments])
}
Awesome! We are done with the MVP for our blog engine. We can now create a database, complete its configuration, and test the application.
5.7 Conclusion
This chapter provided a step-by-step guide to building a real-world web application using Rocket. Through this project, we demonstrated the application of concepts like database integration, user authentication, templating, and CRUD operations in a practical scenario. This project is a blueprint for building complex, feature-rich applications with Rocket.