Actix-web is a good starting point for anyone looking to experience Rust on the web, with it's easy to follow documentation.
Implementing actix-web
// src/main.rs
#[macro_use]
extern crate diesel;
use actix_cors::Cors;
use actix_web::{middleware, web, App, HttpServer};
use diesel::prelude::*;
use diesel::r2d2::{self, ConnectionManager};
mod attendance_handler;
mod errors;
mod invitation_handler;
mod models;
mod register_handler;
mod schema;
mod utils;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv::dotenv().ok();
std::env::set_var(
"RUST_LOG",
"rsvp=debug"
);
env_logger::init();
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
// create db connection pool
let manager = ConnectionManager::
<PgConnection>::new(database_url);
let pool: models::Pool = r2d2::Pool::builder()
.build(manager)
.expect("Failed to crate pool");
// start http server
HttpServer::new(move || {
App::new()
.data(pool.clone())
// enable logger
.wrap(middleware::Logger::default())
.wrap(Cors::permissive())
.data(web::JsonConfig::default().limit(4096))
// everything under "/api" route
.service(
web::scope("/api")
.service(
web::resource("/invitation")
.route(web::post()
.to(
invitation_handler::post_invitation
))
)
.service(
web::resource("/register/{invitation_id}")
.route(web::post()
.to(register_handler::register_user))
)
.service(
web::resource("/auth")
.route(web::post()
.to(attendance_handler::fomo))
)
)
})
.bind("127.0.0.1:8080")?
.run()
.await
}
there are a lot of new imports mostly from actix, I'll get to them shortly.
Let's have a look at the main function, situated atop it there's an attribute which designates it as the entry-point for our web server.
In the previous part we talked about how we transfer data from rust to our database using the schema.rs
file well that's just half of it, the other half requires using the environment variable in the .env
file at the root directory. To import enviroment variables into scope use the dotenv::dotenv().ok
method.
Logging events in programming has been a default for so long, it is important simply because gives information about the current running processes. To log events set an enviroment variable using rust's std::env::set_var
function and initialize with env_logger::init()
.
Exposing environment values by their keys is possible thanks to rust's std::env::var
function, assign the result to the variable database_url
using the let
keyword while handling potential errors with the .except()
method.
It's finally time to create our database connection pool, don't be scared by how that sounds, it's a simple process and the logic is straight forward. A database connection pool is a set of idle, open, and reusable database connections maintained by the database server so that the connections can be reused when the database receives future requests for data, instead of exclusively opening a new connection.
Setting up a manager for our connection pool is the next step, a manager is responsible for establishing & maintaining connections to a given database URL, we do so by binding a manager type diesel::r2d2::ConnectionManager
to a variable and passing that variable to the connection pool builder diesel::r2d2::Pool::builder
HttpServer
Is an actix struct, think of it like a pizza place receiving order(http requests), making the pizza(handling those request) & sending the pizza out/informing the customers that that particular order can't be fulfilled(giving a response).
In our pizza place there's a particular way we make the pizza(handling requests) that keeps us competitive, that's defined by an app instance. The httpserver constructs an app instance for each thread, think of threads as the employees, we can't possible have one employee running all the operations of our pizza factory our even have one employee for each section & think of app instances as the code of conduct each employee(threads) follows. then we share with the outside world the results of our efforts.
The HttpServer is initialized with the new
function and all referenced variables are transported into scope with thanks to the move keyword, receiving requests and relaying responses through the domain info we provide the .bind
method this is equivalent to employees handling the phones.
Application Factory
simply put is all the result of our app instances across multiple threads, we kick start the instance with the new
function this is automatically sheared across threads. the various methods represent configurations for our app instances.
data
- for handling application data, we pass in the connection pool and a json configuration object saying accept the specified maximum json payload.
wrap
- for handling middelware services, we have two middlewares present in our app instance middleware::Logger::default()
& Cors::permissive()
.
services
- for handling http services like web resource
, scope
, route e.t.c.
Request Handlers
you might have been wonering what the following mean in thier respective routes.
invitation_handler::post_invitation
-
register_handler::register_user
& attendance_handler::fomo
These are what handles the varoius request to the http routes, following the pizza analogy this is how our pizza factory makes the pizzas.
They are imported into scope using mod declarations as mentioned in the previous part but right now they're just empty rust files, let's fix that starting with the invitation handler. Create a new file in the src
directory.
// src/invitation_handler.rs
use actix_web::{error::BlockingError, web};
use diesel::prelude::*;
use serde::Deserialize;
use crate::errors::ServiceError;
use crate::models::{invitation, Pool};
#[derive(Deserialize)]
pub struct InvitationData {
pub email: String
}
pub async fn post_invitation(
invitation_data: web::json<InvitationData>,
pool: web::Data<Pool>
) -> Result<String, ServiceError> {
// run diesel blocking code
let res = web::block(move || query(invitation_data.into_inner().email, pool).await;
match res {
Ok(info) => Ok(format!("Here's your Id:{0} registered on:{2} with {1}", info.id, info.email, info.expires_at)),
Err(err) => match err{
BlockingError::Error(service_error) => Err(service_error),
BlockingError::Canceled => Err(ServiceError::InternalServerError)
},
}
}
// Diesel query
fn query(eml: String, pool: web::Data<Pool>) -> Result<Invitation, ServiceError> {
use crate::schema::invitations::dsl::invitations;
use crate::schema::users::dsl::users;
let conn: &PgConnection = &pool.get().unwrap();
let count: i64 = users.count().get_result(conn).unwrap();
dbg!("I count {:?} rows",count);
if count < 10 {
let new_invitation: Invitation = eml.into();
let inserted_invitation = diesel::insert_into(invitations)
.values(&new_invitation)
.get_result(conn)?;
Ok(inserted_invitation)
} else {
Err(ServiceError::Unauthorized)
}
}
The imports
web
contains actix-web helper functions and types while error::BlockingError we'll come to this.From our very own crate we import the
error
module, these are custom errors defined for this project in thesrc/error.rs
.Custom type defined for this project are in
src/models
, you've already seen theInvitation
type.
note: all request handlers are asyncrounous functions
Actix-web provides extractors passed as parameter types to request handler to retrieve http payload and application state, web::Json<InvitationData>
retrieves json payload and is parsed into InvitationData
type thanks to the Deserialize
attribute while web::Data<Pool>
is application state specifically the state of our database connection pool, this is what enables queries achieve connection.
Another thing to note is diesel queries aren't asyncronous so we use web::block
method to kind of wait for the result of the query across threads before moving on.
Followed is the handling of res
, the result from our blocking operation with a match
statement, if you are coming from javascript this is similar to the try-catch
block which return a value on success or an error upon failure.
The query
functions across all request handlers is the crux of the RSVP functionality, here we setup a conditional where if the user
database table entries are more than 10 a response error is given so we record no invitations, the result of that operations is assigned to the variable inserted_invitation
, The variable above contains the result of transforming InvitationData
type into Invitation
type which is the only type allowed to pass information to the database table invitation
.
count
contains the value from querying how many entries are currently present in the user
database table
conn
contains a reference to the database connection pool, we can't use the actual connection pool for safety reasons.
The imports are the diesel representation of relevant database tables users
& invitations
More info about request handlers
// src/register_handler.rs
use actix_web::{error::BlockingError, web, HttpResponse};
use diesel::prelude::*;
use serde::Deserialize;
use crate::errors::ServiceError;
use crate::models::{Invitation, User, SlimUser, Pool};
use crate::utils::hash_password;
// UserData is used to extract data from a post request by the client
#[derive(Debug, Deserialize)]
pub struct UserData {
pub password: String
}
pub async fn register_user(
invitation_id: web::Path<String>,
user_data: web::Json<UserData>,
pool: web::Data<Pool>
) -> Result<HttpResponse, ServiceError> {
let res = web::block(move ||
query(
invitation_id.into_inner(),
user_data.into_inner().password,
pool,
)).await;
match res {
Ok(user) => Ok(HttpResponse::Ok().json(&user)),
Err(err) => match err {
BlockingError::Error(service_error) => Err(service_error),
BlockingError::Canceled => Err(ServiceError::InternalServerError)
}
}
}
fn query(
invitation_id: String,
password: String,
pool: web::Data<Pool>
) -> Result<SlimUser, ServiceError> {
use crate::schema::invitations::dsl::{id, invitations};
use crate::schema::users::dsl::users;
let conn: &PgConnection = &pool.get().unwrap();
let invitation_id = uuid::Uuid::parse_str(&invitation_id)?;
let count: i64 = users.count().get_result(conn).unwrap();
if count < 10 {
invitations
.filter(id.eq(invitation_id))
.load::<Invitation>(conn)
.map_err(|_db_error| ServiceError::BadRequest("Invalid Invitation".into()))
.and_then(|mut result| {
if let Some(invitation) = result.pop() {
// if invitation is not expired
if invitation.expires_at > chrono::Local::now().naive_local() {
// try hashing the password, else return the error that will be converted to
// ServiceError
let password: String = hash_password(&password)?;
dbg!(&password);
let user = User::from_details(invitation.email, password);
let inserted_user: User = diesel::insert_into(users).values(&user).get_result(conn)?;
dbg!(&inserted_user);
return Ok(inserted_user.into());
}
}
Err(ServiceError::BadRequest("Invalid Invitation".into()))
})
} else {
Err(ServiceError::Unauthorized)
}
}
Added a new custom type SlimUser
located at src/models.rs
, another unfamiliar import is hashpassword
which is a function in a newly created src/utils.rs
, this basically where to store helpers for the app.
The register handler takes a url path as an argument using web::path funtion, the path parameters are defined in src/main.rs
.
The query function has some similarities with src/invitation.rs
. for one we are enforcing the participation limit of 10 people so only 10 people can register.
what's different is filtering and adding checks on the database table.
.filter(id.eq(invitation_id))
goes through the invitations database and checks if the provided invitation_id
matches those present in the database.
.load::<Invitation>(conn)
brings whatever entry matches that invitation_id
into scope.
.map_err(|_db_error| ServiceError::BadRequest("Invalid Invitation".into()))
accounts for errors that might come up from loading that data.
If you are wondering about the .into()
method in the literal for the error response, check it's definition at src/error.rs
.
.and_then(|mut result| {...}
if no errors occur take the result.
The if let
block checks if the invitation has expired, uses dbg
to print out the results of hashing the users password & inserting data into the database on the console for quick debugging.
If you are wondering about the .into in the result, it's a form of conversion. we are basically converting the User
type into SlimUser
the functionality is implemented in src/models.rs
.
// src/attendance_handler.rs
use actix_web::{error::BlockingError, web};
use diesel::prelude::*;
use serde::Deserialize;
use crate::errors::ServiceError;
use crate::models::{Pool, SlimUser, User};
use crate::utils::verify;
#[derive(Deserialize)]
pub struct AuthData {
pub email: String,
pub password: String
}
pub async fn fomo(
auth_data: web::Json<AuthData>,
pool: web::Data<Pool>,
) -> Result<String, ServiceError> {
let res = web::block(move || query(auth_data.into_inner(), pool)).await;
match res {
Ok(user) => Ok(format!("all set {}", user.email)),
Err(err) => match err {
BlockingError::Error(service_error) => Err(service_error),
BlockingError::Canceled => Err(ServiceError::InternalServerError),
},
}
}
/// Diesel query
fn query(auth_data: AuthData, pool: web::Data<Pool>) -> Result<SlimUser, ServiceError> {
use crate::schema::users::dsl::{email, users};
let conn: &PgConnection = &pool.get().unwrap();
let mut items = users
.filter(email.eq(&auth_data.email))
.load::<User>(conn)?;
if let Some(user) = items.pop() {
if let Ok(matching) = verify(&user.hash, &auth_data.password) {
if matching {
return Ok(user.into());
}
}
}
Err(ServiceError::Unauthorized)
}
All we are doing here is using the verify
function defined in the utils module to compare the user inputted password to the one we have stored in the database.
Top comments (0)