DEV Community

Doordashcon
Doordashcon

Posted on • Updated on

 

Actix

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
}
Enter fullscreen mode Exit fullscreen mode

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.

  1. invitation_handler::post_invitation
  2. register_handler::register_user &
  3. 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)
  }
}
Enter fullscreen mode Exit fullscreen mode

The imports

  • web contains actix-web helper functions and types while error::BlockingError we'll come to this.

  • diesel::prelude::*

  • serde::Deserialize

  • From our very own crate we import the error module, these are custom errors defined for this project in the src/error.rs.

  • Custom type defined for this project are in src/models, you've already seen the Invitation 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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)