Post

Building E-commerce with Rust - Backend (part 1)

Building E-commerce with Rust - Backend (part 1)

You can find the full source code here

Database design

Special thanks to my friend (Kevin Eldurson) for their invaluable assistance in designing the database schema. Their expertise and insights greatly contributed to the development process, and their input will be reflected in the final writeup. You can find the complete database schema on my GitHub at the following link: database schema

Setting up the server

First things first, let’s start by installing necessary crates but as we go on with the project we would add other crates.

We would need the following crates:

  • axum - This is the main crate which we would use for the creation of the APIs. When installing it we shall add the macro feature.
  • tokio - This crate would help us make our function asynchronous.
  • dotenvy - This crate would read the .env file which all the secret variables would be stored.
  • dotenvy_macro - This crate would help us load the environments variables to the working environment.
  • serde - This crate help us to Serialize and Deserialize data into JSON format.

Your cargo.toml should be like this. (Depending on when you will read this article the version numbers might have change)

1
2
3
4
5
6
[dependencies]
axum = { version = "0.7.5", features = ["macros"] }
dotenvy = "0.15.7"
dotenvy_macro = "0.15.7"
serde = { version = "1.0.198", features = ["derive"] }
tokio = { version = "1.37.0", features = ["macros", "rt-multi-thread", "net"] }

Once we have the crates added, we can now proceed with the project.

Design and structure of the project

We shall split this project into several modules so that when the project grows it would be easier to navigate and add other ideas to it.

  • utils: This will have utilities that we would use all over the application.
  • routes: This will have routes that we would be building.
  • middleware: This will have the custom middleware on the application.
  • database: This is auto generated by sea-orm.

Error handling

Before we even start setting up the server we need to create our custom error handler which will help us display our error message properly.

It will be on utils/app_error.rs

Here we shall create a public struct called AppError which shall be used to hold our error. The error would have two parts: StatusCode and Message.

The StatusCode comes from the crate axum::http::StatusCode

1
2
3
4
5
6
7
// File: utils/app_error.rs

#[derive(Debug)]
pub struct AppError{
    code: StatusCode,
    message: String
}

We would then create a constructor method on the AppError implementation block named new, which allows clients to specify both the StatusCode and the associated error message

1
2
3
4
5
6
7
8
9
10
11
12
// File: utils/app_error.rs

impl AppError {

    pub fn new( code: StatusCode, message: impl Into<String>) -> Self {

        Self{
            code,
            message: message.into()
        }
    }
}

The next thing is to implement IntoResponse trait for the AppError type.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// File: utils/app_error.rs

#[derive(Serialize, Deserialize)]
struct ErrorResponse{
    error: String
}


impl IntoResponse for AppError {
    fn into_response(self) -> axum::response::Response {
        (
            self.code,
            Json(ErrorResponse{ error: self.message.clone()})
        ).into_response()
    }

}

into_response function is responsible to convert AppError instance into an HTTP response. It constructs a tuple containing the HTTP status code (self.code) and a JSON-formatted error message then wraps it into ErrorResponse struct.

There we go we have now implemented our custom error handler which we would use it all over our application

Creating the server

We would start by making our main function asynchronous by using #[tokio::main] macro which will execute the function within the Tokio runtime. This will ensure that our asynchronous operations such as launching the server be easy to be intergrated into program flow.

Lets only declare the function then later on after implementing the launch function we would deal with the main function.

1
2
3
4
#[tokio::main]
async fn main() ->{

}

On the lib.rs we shall create an async function called launch which will take in AppState and return AppError which we’ve already built. I will use the todo macro so that we can go and implement AppState struct.

1
2
3
pub async fn launch(app_state: AppState)-> Result<(), AppError>{
    todo!()
}

On the utils module lets create a file named app_state.rs then create the AppState struct which it will have our database, base_url and the port where the server would be served from.

Yes I know the database should be type DatabaseConnection but lets first anotate it to be a string since we are not even going to use it right now but the moment we are going to use the database we shall change it to correct type.

1
2
3
4
5
pub struct AppState{
    pub database: String, // should be changed to DatabaseConnection later
    pub base_url: String,
    pub port: String,
}

Lets finish implementing launch function. Firstly, we define our application routes using the Router provided by axum framework. Within this router, we specify a single route /test that respond with "I have just been hit" (later on we shall change all the routes to come from routes module.)

Then we construct the address where the server will bind. This address is formed by combining the base_url and the port obtained from the AppState struct. We then bind the TCP listener to the constructed address using tokio::net::TcpListener::bind(). If an error occurs we would handle it by first logging the error then construct an AppError instance to represent an internal server error.

If everything works fine with no errors, we shall launch the server using axum::serve() This function takes in the the listener and the configured Router instance then initiates the server startup process. Again if there’s an error we would log the specific error and construct the AppError instance, showing that we were unable to start the server.

If the server starts successfully, we would return Ok(()) and if there’s any error we would return AppError.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pub async fn launch(app_state: AppState)-> Result<(), AppError>{
    let app:Router = Router::new().route("/test", get(||async { "I have just been hit"}));

    let address = format!("{}:{}", app_state.base_url, app_state.port);
    let listenter = tokio::net::TcpListener::bind(&address)
        .await
        .map_err(|error|{
            eprintln!("There was an issue with the bind address {}, {}", address, error);
            AppError::new(StatusCode::INTERNAL_SERVER_ERROR, "Could not connect with the address and port")
        })?;

    axum::serve(listenter, app)
        .await
        .map_err(|error|{
            eprintln!("Could not start the server: {}", error);
            AppError::new(StatusCode::INTERNAL_SERVER_ERROR, "Could not start the server")
        })

}

Back to the main function.

We invoke the dotenvy::dotenv().ok() which loads environment variables from a .env file into the program’s environment. After that, dotenv! macro retrives the port and the base address.

We then construct AppState struct which would have database configuration and the server address details. Well for the database I will just pass none since we don’t have any database connection yet. We invoke the launch function to initiate the startup process of our web server.

As the launch function is asynchronous and may potentially return an error, we employ the await? syntax to handle any errors that may occur during server startup. If the server starts successfully, the Ok(()) value is returned, signifying a smooth execution flow.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#[tokio::main]
async fn main() -> Result<(), AppError> {

    dotenvy::dotenv().ok();
    let port = dotenv!("PORT").to_string();
    let base_address = dotenv!("BASE_ADDRESS").to_string();
    let app_state = AppState{
        database: "none".to_string(),
        base_url: base_address,
        port

    };
    launch(app_state).await?;
    Ok(())
}

make sure you create .env file on the root of the project and have the base_address and the port setup.

1
2
PORT="3000"
BASE_ADDRESS="127.0.0.1"

If we now test it using curl or postman we would get a 200 OK status code meaning we have our server up and running.

Connecting to the database

On the .env file add the database url.

1
DATABASE_URL=postgresql://postgres:password@localhost:5432/e_commerce

For my database I decided to call it e_commerce. Go with any name, it’s yours…

We shall use sea-orm to generate the code for the database and store it on the database directory

1
 sea-orm-cli generate entity -o src/database

We would then connect to the database by first getting the url using the dotenvy_macro then connect to the database. After Connecting to the database lets not forget to handle any errors that might occur.

1
2
3
4
5
6
7
8
9
// File: main.rs

    let database_url = dotenv!("DATABASE_URL");
    let database = Database::connect(database_url)
        .await
        .map_err(|error|{
            eprintln!("Error could not connect to the database: {}", error);
            AppError::new(StatusCode::INTERNAL_SERVER_ERROR, "Could not connect to the database")
        })?;

Lets modify the AppState struct to handle our database connection.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// file: utils/app_state.rs

use axum::extract::FromRef;
use sea_orm::DatabaseConnection;


#[derive(Clone, FromRef)]
pub struct AppState{
    pub database: DatabaseConnection,
    pub base_url: Wrapper,
}

#[derive(Clone)]
pub struct Wrapper{
    pub url: String,
    pub port: String
}

I created another struct called wrapper which it will have our url and port then later on we shall use it to create the base_url. On the AppState struct we shall pass our wrapper as the type expected so that we would be able to construct the url. Remember to change on launch function where we built our base address.

Having that, the database on the AppState struct is now of the correct type which is DatabaseConnection. On the main function lets edit the app_state variable which we had constructed earlier to have the database connection passed.

1
2
3
4
5
6
7
// main.rs

    let app_state = AppState{
        database,
        base_url: Wrapper { url: base_url, port }

    };

Awesome!! We now have our DatabaseConnection and we need to create a route to test if we have problems connecting to the database and peforming any of the CRUD operations.

Creating Routes

We shall create a new module/route” which will have all our routes.

1
2
3
// File: lib.rs

mod route

First, we shall create a function called create routes which will be responsible to creating routes as the name suggests. The create route will take in the AppState which it will have the database connection in it. We shall use with_state so as we shall pass the connections to various routes that will require them.

1
2
3
4
5
6
7
8
9
10
11
// File: route/mod.rs

use axum::{routing::get, Router};
use crate::utils::app_state::AppState;

pub fn create_route(app_state: AppState) -> Router{
    Router::new()
        .route("/test", get(test))
        .with_state(app_state)

}

Since we have created a function to create routes, lets create the individual routes just below it. We would create /test route so as we can know if it is really working.

1
2
3
4
5
// File: route/mod.rs

fn test() -> String{
  "from the test route".to_string()
}

Do you remember we created a route /test route which we said we shall come and change later on? Yes this is the moment. On the launch function search where we created the Router and change it to:

1
2
3
//File: lib.rs

let app = create_route(app_state);

and make sure you have correct path to create_route.

Anyways if everything went fine we are able to get a string back saying from the test route with a 200 OK status code.

this was test code meaning we are going to get rid of it.

Register route

Now let’s create a new route which will enable users to register to our application. We would take in the following information: username, first_name, last_name, email, telephone, default_address_id, password, salt, password_hash.

Lets create a struct to hold all those information. We shall derive Serialize and Deserialize so as we would be able to convert the data to JSON.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// File: route/register.rs


#[derive(Serialize, Deserialize)]
pub struct RequestUser{
    username: String,
    first_name: String,
    last_name: String,
    email: String,
    telephone: String,
    default_address_id: Option<i32>,
    password: String,
    salt: String,
    password_hash: String,
}

Then we would create a struct to have whatever the application will respond with.

1
2
3
4
5
6
7
8
9
// File: route/register.rs

#[derive(Serialize, Deserialize)]
pub struct RespondUser{
    user_id: i32,
    username: String,
    telephone: String,
    email: String
}

The function takes in two parameters: State(database) and Json(request_user). The state parameter use the axum framework State extractor to access the application Database connection. On the other hand, Json extractor Deserialize JSON data from request body into RequestUser struct.

After receiving the registration request, the function proceeds to create new user instance (new_user) using the provided request_user data. This user instance is constructed as an ActiveModel of the user struct representing user entity. Then the save method is invoked to save the data to the database. If there’s any error, we shall handle the error using AppError which we implemented earlier.

Finally, if everything is successful, the function constructs a JSON response containing the user details including user_id, username, telephone and email.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// File: route/register.rs

pub async fn register(
    State(database): State<DatabaseConnection>,
    Json(request_user): Json<RequestUser>
    ) -> Result<Json<RespondUser>, AppError>{

    let new_user = User::ActiveModel{
        username: Set(request_user.username),
        first_name: Set(request_user.first_name),
        last_name: Set(request_user.last_name),
        email: Set(request_user.email),
        telephone: Set(request_user.telephone),
        default_address_id: Set(request_user.default_address_id),
        password_hash: Set(request_user.password_hash),
        salt: Set(request_user.salt),
        ..Default::default()
    }.save(&database)
        .await
        .map_err(|error|{
            let error_mess = error.to_string();
            if error_mess.contains("duplicate key value violates unique constraint"){
                return AppError::new(StatusCode::BAD_REQUEST, "Another user having those details");
            }
            eprintln!("Error could not create new user: {} ", error);
            AppError::new(StatusCode::INTERNAL_SERVER_ERROR, "Could not create the account, Please try again later")
        })?;
   let user = new_user.try_into_model()
        .map_err(|error|{
            eprintln!("Error, could not convert users into model: {}", error);
            AppError::new(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong")
        })?;

    Ok(Json(RespondUser { user_id: user.id, username: user.username, telephone: user.telephone, email: user.email }))

}

On the create_route function create the route called /register which it is post and test if everything is working fine by using postman.

1
2
3
4
5
6
7
8
9
// File: route/mod.rs

pub fn create_route(app_state: AppState) -> Router{
    Router::new()
        .route("/register", post(register))
        .with_state(app_state)

}

Login route

The first thing we shall do is finding if the user is on the database.

1
2
3
4
5
6
7
8
9
10
// File: routes/login.rs

    let user = User::find()
        .filter(customer::Column::Username.eq(requet_user.username))
        .one(&database)
        .await
        .map_err(|error|{
            eprintln!("Error finding the user {}", error);
            AppError::new(StatusCode::INTERNAL_SERVER_ERROR, "Error login in, Please try again later")
        })?;

If the user is on the database we shall then verify if the password provided matches the password which he/she registered the account with. We have the function which we created to help us verify the password which we will pass the request user password and user password hash on the database.

1
2
3
4
5
6
7
8
// FILE: routes/login.rs

    if let Some(user) = user{
        // verify password
        if !verifiy_pass(requet_user.password, &user.password_hash)?{
            return Err(AppError::new(StatusCode::UNAUTHORIZED, "Bad username OR password"));
        }

If the user and the password is valid, we would then generate JWT token for the user.

1
2
3
// FILE: routes/login.rs

        let token = create_jwt(&jwt_secret.0)?;

Then save the token to the database.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// FILE: routes/login.rs


        let mut user = user.into_active_model();
        user.token = Set(Some(token));
        let saved_user = user.save(&database)
            .await
            .map_err(|error|{
                eprintln!("Error, could not save the token: {}", error);
                AppError::new(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong")
            })?;
        let user = saved_user.try_into_model()
            .map_err(|error|{
                eprintln!("Error, Could not convert into model: {}", error);
                AppError::new(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong")
            })?;

Finally if everything is okay we would return JSON of the RespondUser which contains email, telephone number, user id, username and token otherwise an error would be returned indicating either the username or the password was not found.

1
2
3
4
5
6
7
8
// FILE: routes/login.rs

        Ok(Json(RespondUser{email: user.email, telephone: user.telephone, user_id: user.id, username:user.username, token: user.token}))

    }else {
        return Err(AppError::new(StatusCode::NOT_FOUND, "Bad username OR password"));
    }
}

Middleware

At this point we now need a middleware which shall be checking if we are authenticated then once we are we can access other routes. I will place the middleware on utils module because why not. This is the function signature where we shall have the database, jwt_secret, request and next

1
2
3
4
5
6
7
8
// FILE: utils/middleware.rs

pub async fn guard_routes(
    State(database): State<DatabaseConnection>,
    State(jwt_secret): State<TokenWrapper>,
    mut request: Request,
    next: Next
    )-> Result<Response, AppError> {

we shall first get the token that we were given when we logged in.

1
2
3
4
5
6
7
// FILE: utils/middleware.rs

    let token = request.headers().typed_get::<Authorization<Bearer>>()
        .ok_or_else(||AppError::new(StatusCode::BAD_REQUEST, "Not authenticated Please login"))?
        .token()
        .to_owned();

We shall then do the same thing as the login route where we find the user.

1
2
3
4
5
6
7
8
9
10
// FILE: utils/middleware.rs

    let user = Users::find()
        .filter(customer::Column::Token.eq(&token))
        .one(&database)
        .await
        .map_err(|error|{
            eprintln!("Error finding user: {}", error);
            AppError::new(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong")
        })?;

Now rather than creating the token we shall validate the token that we created.

1
2
//FILE: utils/middleware.rs
    validate_jwt(&jwt_secret.0, &token)?;

We shall then handle the authentication and authorization by inserting the authenticated user into the request extensions if present, and returning an unauthorized error otherwise. The function returns an Ok variant of the Result type, indicating that the operation was successful, and it returns the result of invoking next.run(request).await.

1
2
3
4
5
6
7
8
9
10
11
// FILE: utils/middleware.rs

    if let Some(user) = user{
        request.extensions_mut().insert(user);

    }else {
        return Err( AppError::new(StatusCode::UNAUTHORIZED, "You are not authorized"));
    }
    Ok(next.run(request).await)
}

On the route/mod.rs we shall add the custom middleware and add the function that we have just created

1
2
3
// FILE: routes/mod.rs

        .route_layer(middleware::from_fn_with_state(app_state.clone(), guard_routes))

lets implement logout which will be on top of the middleware where we can only hit the route if we have only logged in.

logout

On the logout route all we need is to set the token to NONE which will reset the token we generated to none.

1
2
3
4
5
6
7
8
9
10
11
12
FILE: routes/logout.rs

    let mut user = user.into_active_model();
    user.token = Set(None);
    user.save(&database)
        .await
        .map_err(|error|{
            eprintln!("Error login out: {}", error);
            AppError::new(StatusCode::INTERNAL_SERVER_ERROR, "Error login out, please try again later")
        })?;
    Ok(StatusCode::OK)

That’s all for the logout.

way forward?

Regarding the way forward, please bear with me as I prepare for part 2. I’m currently focusing on completing other sections, but rest assured, I’ll resume the write-up shortly. Thank you for your patience.

This post is licensed under CC BY 4.0 by the author.