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 bysea-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
andport
then later on we shall use it to create thebase_url
. On theAppState
struct we shall pass ourwrapper
as the type expected so that we would be able to construct the url. Remember to change onlaunch
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.