User Management System using Actix
RUST SRM
Building a User Management API with Rust and Actix: A Step-by-Step Guide
1. Introduction to Actix and Hello World
Let’s start by creating a simple “Hello World” application with Actix.
First, create a new Rust project:
cargo new user_management_api
cd user_management_api
Update your Cargo.toml
file:
[dependencies]
actix-web = "4.0"
Now, create a simple “Hello World” route in src/main.rs
:
use actix_web::{get, App, HttpServer, HttpResponse, Responder};
#[get("/")]
async fn hello() -> impl Responder {
HttpResponse::Ok().body("Hello world!")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new().service(hello)
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Run the application with cargo run
and visit http://localhost:8080
in your browser to see “Hello world!“.
2. User Management Use Case
Our User Management system will perform CRUD (Create, Read, Update, Delete) operations on user data. This is a common requirement in many applications, allowing us to manage user accounts effectively.
3. Introducing CRUD Operations
Let’s define the structure for our User data and create placeholder functions for our CRUD operations:
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct User {
id: u32,
name: String,
email: String,
}
async fn create_user(user: web::Json<User>) -> impl Responder {
// TODO: Implement user creation
HttpResponse::Ok().body("User created")
}
async fn get_user(id: web::Path<u32>) -> impl Responder {
// TODO: Implement user retrieval
HttpResponse::Ok().body(format!("Get user with id: {}", id))
}
async fn update_user(id: web::Path<u32>, user: web::Json<User>) -> impl Responder {
// TODO: Implement user update
HttpResponse::Ok().body(format!("Update user with id: {}", id))
}
async fn delete_user(id: web::Path<u32>) -> impl Responder {
// TODO: Implement user deletion
HttpResponse::Ok().body(format!("Delete user with id: {}", id))
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.route("/users", web::post().to(create_user))
.route("/users/{id}", web::get().to(get_user))
.route("/users/{id}", web::put().to(update_user))
.route("/users/{id}", web::delete().to(delete_user))
})
.bind("127.0.0.1:8080")?
.run()
.await
}
4. Serialization and Deserialization with Serde
In the previous step, we used serde
for serialization and deserialization. Serde is a framework for serializing and deserializing Rust data structures efficiently and generically.
The #[derive(Serialize, Deserialize)]
attribute on our User
struct automatically implements the necessary traits for converting between Rust structures and JSON.
Example of how Serde works:
use serde::{Serialize, Deserialize};
use serde_json;
#[derive(Serialize, Deserialize, Debug)]
struct User {
id: u32,
name: String,
email: String,
}
fn main() {
let user = User {
id: 1,
name: String::from("John Doe"),
email: String::from("john@example.com"),
};
// Serialize
let serialized = serde_json::to_string(&user).unwrap();
println!("Serialized: {}", serialized);
// Deserialize
let deserialized: User = serde_json::from_str(&serialized).unwrap();
println!("Deserialized: {:?}", deserialized);
}
5. Adding Chrono for Timestamp Handling
Let’s add a created_at
field to our User
struct using the chrono
crate for better timestamp handling:
Update Cargo.toml
:
[dependencies]
actix-web = "4.0"
serde = { version = "1.0", features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] }
Update the User
struct:
use chrono::{DateTime, Utc};
#[derive(Serialize, Deserialize)]
struct User {
id: u32,
name: String,
email: String,
created_at: DateTime<Utc>,
}
6. Introducing Diesel ORM
Now, let’s integrate Diesel ORM for database operations. We’ll use Neon, a serverless PostgreSQL service.
Add Diesel to your Cargo.toml
:
[dependencies]
actix-web = "4.0"
serde = { version = "1.0", features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] }
diesel = { version = "2.0.0", features = ["postgres", "r2d2", "chrono"] }
dotenv = "0.15"
7. Setting up Neon Database
- Sign up for a Neon account at https://neon.tech/
- Create a new project
- Get your connection string
Create a .env
file in your project root:
DATABASE_URL=postgres://your-username:your-password@your-neon-host/your-database
8. Creating Migrations
Install the Diesel CLI:
cargo install diesel_cli --no-default-features --features postgres
Set up Diesel and create a migration:
diesel setup
diesel migration generate create_users
In the up.sql
file:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
email VARCHAR NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Run the migration:
diesel migration run
9. Connecting to the Database
Update src/main.rs
to include database connection:
use diesel::prelude::*;
use diesel::r2d2::{self, ConnectionManager};
use dotenv::dotenv;
type DbPool = r2d2::Pool<ConnectionManager<PgConnection>>;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv().ok();
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let manager = ConnectionManager::<PgConnection>::new(database_url);
let pool = r2d2::Pool::builder()
.build(manager)
.expect("Failed to create pool.");
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(pool.clone()))
// ... routes ...
})
.bind("127.0.0.1:8080")?
.run()
.await
}
10. Implementing CRUD Operations
Now, let’s implement our CRUD operations using Diesel:
use diesel::prelude::*;
use crate::schema::users;
#[derive(Queryable, Serialize)]
struct User {
id: i32,
name: String,
email: String,
created_at: chrono::NaiveDateTime,
}
#[derive(Insertable, Deserialize)]
#[diesel(table_name = users)]
struct NewUser {
name: String,
email: String,
}
async fn create_user(pool: web::Data<DbPool>, new_user: web::Json<NewUser>) -> impl Responder {
let conn = pool.get().expect("couldn't get db connection from pool");
let user = web::block(move || {
diesel::insert_into(users::table)
.values(&new_user.0)
.get_result::<User>(&conn)
})
.await
.unwrap();
HttpResponse::Ok().json(user)
}
async fn get_user(pool: web::Data<DbPool>, user_id: web::Path<i32>) -> impl Responder {
let conn = pool.get().expect("couldn't get db connection from pool");
let user = web::block(move || users::table.find(user_id.into_inner()).first::<User>(&conn))
.await
.unwrap();
HttpResponse::Ok().json(user)
}
async fn update_user(
pool: web::Data<DbPool>,
user_id: web::Path<i32>,
updated_user: web::Json<NewUser>,
) -> impl Responder {
let conn = pool.get().expect("couldn't get db connection from pool");
let user = web::block(move || {
diesel::update(users::table.find(user_id.into_inner()))
.set(&updated_user.0)
.get_result::<User>(&conn)
})
.await
.unwrap();
HttpResponse::Ok().json(user)
}
async fn delete_user(pool: web::Data<DbPool>, user_id: web::Path<i32>) -> impl Responder {
let conn = pool.get().expect("couldn't get db connection from pool");
let deleted = web::block(move || {
diesel::delete(users::table.find(user_id.into_inner())).execute(&conn)
})
.await
.unwrap();
if deleted > 0 {
HttpResponse::Ok().body("User deleted successfully")
} else {
HttpResponse::NotFound().body("User not found")
}
}
11. Error Handling
To handle errors properly, we can create a custom error type:
use actix_web::{error::ResponseError, HttpResponse};
use thiserror::Error;
#[derive(Error, Debug)]
enum MyError {
#[error("Database error: {0}")]
DbError(#[from] diesel::result::Error),
#[error("Environment error: {0}")]
EnvError(#[from] std::env::VarError),
#[error("IO error: {0}")]
IOError(#[from] std::io::Error),
#[error("Blocking error: {0}")]
BlockingError(#[from] actix_web::error::BlockingError),
}
impl ResponseError for MyError {
fn error_response(&self) -> HttpResponse {
HttpResponse::InternalServerError().json(format!("Internal Server Error: {}", self))
}
}
Then update your handler functions to return Result<HttpResponse, MyError>
.
12. Final Main.rs File
Here’s the complete main.rs
file with all the changes:
use actix_web::{web, App, HttpResponse, HttpServer, ResponseError};
use diesel::prelude::*;
use diesel::r2d2::{self, ConnectionManager};
use dotenv::dotenv;
use serde::{Deserialize, Serialize};
use std::env;
use thiserror::Error;
mod schema;
use schema::users;
type DbPool = r2d2::Pool<ConnectionManager<PgConnection>>;
#[derive(Queryable, Serialize)]
struct User {
id: i32,
name: String,
email: String,
created_at: chrono::NaiveDateTime,
}
#[derive(Insertable, AsChangeset, Deserialize)]
#[diesel(table_name = users)]
struct NewUser {
name: String,
email: String,
}
#[derive(Error, Debug)]
enum MyError {
#[error("Database error: {0}")]
DbError(#[from] diesel::result::Error),
#[error("Environment error: {0}")]
EnvError(#[from] std::env::VarError),
#[error("IO error: {0}")]
IOError(#[from] std::io::Error),
#[error("Blocking error: {0}")]
BlockingError(#[from] actix_web::error::BlockingError),
}
impl ResponseError for MyError {
fn error_response(&self) -> HttpResponse {
HttpResponse::InternalServerError().json(format!("Internal Server Error: {}", self))
}
}
async fn create_user(pool: web::Data<DbPool>, new_user: web::Json<NewUser>) -> Result<HttpResponse, MyError> {
let mut conn = pool.get().expect("couldn't get db connection from pool");
let user = web::block(move || {
diesel::insert_into(users::table)
.values(new_user.into_inner())
.get_result::<User>(&mut conn)
})
.await??;
Ok(HttpResponse::Ok().json(user))
}
async fn get_user(pool: web::Data<DbPool>, user_id: web::Path<i32>) -> Result<HttpResponse, MyError> {
let mut conn = pool.get().expect("couldn't get db connection from pool");
let user = web::block(move || users::table.find(user_id.into_inner()).first::<User>(&mut conn))
.await??;
Ok(HttpResponse::Ok().json(user))
}
async fn update_user(
pool: web::Data<DbPool>,
user_id: web::Path<i32>,
updated_user: web::Json<NewUser>,
) -> Result<HttpResponse, MyError> {
let mut conn = pool.get().expect("couldn't get db connection from pool");
let user = web::block(move || {
diesel::update(users::table.find(user_id.into_inner()))
.set(updated_user.into_inner())
.get_result::<User>(&mut conn)
})
.await??;
Ok(HttpResponse::Ok().json(user))
}
async fn delete_user(pool: web::Data<DbPool>, user_id: web::Path<i32>) -> Result<HttpResponse, MyError> {
let mut conn = pool.get().expect("couldn't get db connection from pool");
let deleted = web::block(move || {
diesel::delete(users::table.find(user_id.into_inner())).execute(&mut conn)
})
.await??;
if deleted > 0 {
Ok(HttpResponse::Ok().body("User deleted successfully"))
} else {
Ok(HttpResponse::NotFound().body("User not found"))
}
}
#[actix_web::main]
async fn main() -> Result<(), MyError> {
dotenv().ok();
env_logger::init();
let database_url = env::var("DATABASE_URL")?;
let manager = ConnectionManager::<PgConnection>::new(database_url);
let pool = r2d2::Pool::builder()
.build(manager)
.expect("Failed to create pool.");
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(pool.clone()))
.route("/users", web::post().to(create_user))
.route("/users/{id}", web::get().to(get_user))
.route("/users/{id}", web::put().to(update_user))
.route("/users/{id}", web::delete().to(delete_user))
})
.bind("127.0.0.1:8080")?
.run()
.await?;
Ok(())
}
Now, let’s explain some of the key concepts we’ve used in this code:
Explanation of Key Concepts
-
NewUser
vsUser
:User
represents a user as stored in the database, including anid
andcreated_at
timestamp.NewUser
represents the data needed to create a new user, typically justname
andemail
.
-
Derive Attributes:
#[derive(Queryable)]
: This attribute is provided by Diesel and automatically implements theQueryable
trait, allowing the struct to be the result of a database query.#[derive(Serialize)]
: This attribute from Serde allows the struct to be serialized (e.g., to JSON).#[derive(Insertable)]
: This Diesel attribute allows the struct to be inserted into the database.#[derive(Deserialize)]
: This Serde attribute allows the struct to be deserialized (e.g., from JSON).#[derive(AsChangeset)]
: This Diesel attribute allows the struct to be used for updating existing records.
-
Database Pool:
type DbPool = r2d2::Pool<ConnectionManager<PgConnection>>;
This creates a type alias for a connection pool. It uses r2d2 to manage a pool of database connections, which is more efficient than creating a new connection for each request.
-
web::Data<DbPool>
: This is how we pass the database pool to our handlers.web::Data
is an Actix construct that allows us to share application state across handlers. -
Custom Error Type: We create a custom error type (
MyError
) to handle different types of errors that might occur in our application. TheDebug
andDisplay
traits are used for formatting the error:Debug
provides a format intended for developers.Display
provides a user-friendly format.
Testing the API
Now that we have our API set up, let’s test it using curl commands:
- Create a User:
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name": "John Doe", "email": "john@example.com"}'
- Get a User:
curl http://localhost:8080/users/1
- Update a User:
curl -X PUT http://localhost:8080/users/1 \
-H "Content-Type: application/json" \
-d '{"name": "John Updated", "email": "john_updated@example.com"}'
- Delete a User:
curl -X DELETE http://localhost:8080/users/1
Using Postman
While curl is great for quick command-line tests, Postman provides a more user-friendly interface for API testing. Here’s how to use Postman with our API:
- Open Postman and click on “Import” in the top left corner.
- Choose the “Raw text” tab.
- Paste one of the curl commands from above.
- Click “Continue” and then “Import”.
- Postman will create a new request with the correct method, URL, headers, and body.
- You can then send the request and see the response directly in Postman.
Remember to update the user ID in the URL for GET, PUT, and DELETE requests to match the ID of an existing user in your database.
Conclusion
In this tutorial, we’ve built a robust User Management API using Rust, Actix, and Diesel ORM. We’ve covered:
- Setting up a basic Actix web server
- Defining data structures with Serde for serialization and deserialization
- Using Chrono for timestamp handling
- Integrating Diesel ORM for database operations with Neon (serverless PostgreSQL)
- Implementing CRUD operations for user management
- Adding proper error handling
- Testing the API using curl and Postman
This API provides a solid foundation for building more complex applications. Remember to add proper authentication and authorization before using this in a production environment.
From here, you could extend this API by adding more complex queries, implementing authentication, or adding additional endpoints as needed for your application.