In this article, we will explore the process of building a REST API in Rust with features like user registration, email verification, login, and logout capabilities. Our API will be powered by the high-performance Axum framework and will utilize the SQLx toolkit to store data in a SQLite database. It’s worth noting that while we use SQLite in this tutorial, you can easily adapt the code to work with other databases supported by SQLx.

Before diving into the tutorial, let me provide you with a sneak peek. First, we will start by creating HTML templates using the Handlebars templating engine. These templates will enable us to craft customizable email messages. Next, we will define methods within a struct that facilitate the sending of various email types.

With these foundations in place, we will then perform the necessary database migrations using the SQLx command-line tool. Finally, we will implement API middleware, handlers, and routes to bring our application to life.

More practice:

Rust API - User Registration and Email Verification

Running the Rust API on Your Machine

To run the Rust API project on your local machine, follow these steps:

  1. Download or clone the project from its GitHub repository at https://github.com/wpcodevo/rust-user-signup-forgot-password-email and open it in your preferred IDE or code editor.
  2. Duplicate the .env.example file and rename the copy to .env. Open the .env file and add your SMTP credentials to the SMTP_HOST, SMTP_USER, and SMTP_PASS variables. If you don’t have these credentials or are unsure how to obtain them, please refer to the instructions provided here.
  3. Ensure that you have the SQLX command-line tool installed. If not, you can install it by running the command cargo install sqlx-cli -F sqlite.
  4. Generate the SQLite database by running the command sqlx database create.
  5. Apply the SQLX migrations to the SQLite database by executing sqlx migrate run.
  6. Start the API server by running cargo run. Once the server is up and running, you can import the Rust Signup SignIn Email.postman_collection.json file into Postman or the Thunder Client VS Code extension to test the API endpoints.

Running the Rust API with a Frontend Application

If you want to go beyond testing the API in Postman and see how it integrates with a frontend application, you’re in luck. Follow the instructions below to use a frontend app built with React.js to interact with the API. For a more detailed guide on building the React app, refer to the article titled “Forgot/Reset Passwords with React Query and Axios“.

  1. Download or clone the React.js project from https://github.com/wpcodevo/react-query-axios-tailwindcss and open the source code in your preferred IDE.
  2. In the terminal, navigate to the root directory of the project and run the command yarn or yarn install to install the necessary dependencies.
  3. Start the React development server by running yarn dev. Once the server is up and running, visit the provided URL in your browser to access the application. From there, you can interact with the various features such as signing up, signing in, verifying your account, requesting a password reset code, resetting your password, and accessing your profile page.

Setting up the Rust Project

To get started, we’ll create a new Rust project. Open your terminal and navigate to the directory where you typically store your Rust projects. Once you’re in the desired directory, run the following command:


cargo new --bin rust-user-signup-forgot-password-email

This command will generate a new Rust binary project in a folder named rust-user-signup-forgot-password-email. If you prefer a different project name, you can replace the name after the --bin flag.

After generating the project, open it in your preferred code editor. In the integrated terminal of your code editor or IDE, run the following commands to add the required dependencies to your Cargo.toml file:


cargo add axum
cargo add axum-extra -F cookie
cargo add time
cargo add tokio -F full
cargo add tower-http -F "cors"
cargo add serde_json
cargo add serde -F derive
cargo add chrono -F serde
cargo add dotenv
cargo add uuid -F "serde v4"
cargo add sqlx -F "runtime-async-std-native-tls sqlite chrono uuid"
cargo add jsonwebtoken
cargo add argon2
cargo add rand
cargo add handlebars
cargo add lettre -F "tokio1, tokio1-native-tls" 

Once the dependencies are added to the Cargo.toml file, use the command cargo build to compile the Rust project and its dependencies.

Creating the HTML Email Templates

Let’s move on to creating the HTML email templates. Our approach will be systematic and organized. We’ll start by creating a base template that will serve as the foundation for all other templates. Then, we’ll create a separate partial to hold the CSS styles for the templates. Finally, we’ll create the email verification HTML template.

This structured approach allows for easy template expansion in the future. For instance, if you ever need to add a password reset template, you can simply duplicate the email verification template and make the necessary adjustments.

I’d like to mention that the HTML email template used in this project is not something I created from scratch. Instead, I found it on GitHub and it has been a valuable resource for this project. You can access it at https://github.com/leemunroe/responsive-html-email-template. If you find it beneficial for your project, I encourage you to show your support by giving the repository a star.

Creating the Base Layout

To create the base layout template for our HTML emails, follow these steps:

  1. Begin by creating a new directory named “templates” in the root directory of your project.
  2. Inside the “templates” directory, create another folder named “layouts“. This folder will store our layout template files.
  3. Within the “layouts” folder, create a file called base.hbs and populate it with the provided code snippet:

templates/layouts/base.hbs


<!DOCTYPE html>
<html>

<head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <title>{{subject}}</title>

    {{!-- Include Styles --}}
    {{> styles}}
</head>

<body>
    <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body">
        <tr>
            <td>&nbsp;</td>
            <td class="container">
                <div class="content">
                    <!-- START CENTERED WHITE CONTAINER -->
                    {{> @partial-block}}
                    <!-- END CENTERED WHITE CONTAINER -->
                </div>
            </td>
            <td>&nbsp;</td>
        </tr>
    </table>
</body>

</html>

The {{> styles}} placeholder within the <head> element will be substituted with the CSS styles defined in the partials folder. On the other hand, the {{> @partial-block}} expression will be substituted with an HTML template during the rendering phase. In this case, it will be the email verification template.

Creating the CSS Styles

To style our HTML email and ensure it has an appealing and responsive design, we will create a partial template to house the necessary CSS styles. This will allow us to easily manage and apply the styles across different email templates.

Follow these steps to create the styles partial template:

  1. Inside the ‘templates‘ directory, create a new folder called ‘partials‘.
  2. Within the ‘partials‘ folder, create a file named styles.hbs.
  3. Open the styles.hbs file and add the CSS code provided below:

templates/partials/styles.hbs


<style>
    /* -------------------------------------
          GLOBAL RESETS
      ------------------------------------- */

    /*All the styling goes here*/

    img {
        border: none;
        -ms-interpolation-mode: bicubic;
        max-width: 100%;
    }

    body {
        background-color: #f6f6f6;
        font-family: sans-serif;
        -webkit-font-smoothing: antialiased;
        font-size: 14px;
        line-height: 1.4;
        margin: 0;
        padding: 0;
        -ms-text-size-adjust: 100%;
        -webkit-text-size-adjust: 100%;
    }

    table {
        border-collapse: separate;
        mso-table-lspace: 0pt;
        mso-table-rspace: 0pt;
        width: 100%;
    }

    table td {
        font-family: sans-serif;
        font-size: 14px;
        vertical-align: top;
    }

    /* -------------------------------------
          BODY & CONTAINER
      ------------------------------------- */

    .body {
        background-color: #f6f6f6;
        width: 100%;
    }

    /* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */
    .container {
        display: block;
        margin: 0 auto !important;
        /* makes it centered */
        max-width: 580px;
        padding: 10px;
        width: 580px;
    }

    /* This should also be a block element, so that it will fill 100% of the .container */
    .content {
        box-sizing: border-box;
        display: block;
        margin: 0 auto;
        max-width: 580px;
        padding: 10px;
    }

    /* -------------------------------------
          HEADER, FOOTER, MAIN
      ------------------------------------- */
    .main {
        background: #ffffff;
        border-radius: 3px;
        width: 100%;
    }

    .wrapper {
        box-sizing: border-box;
        padding: 20px;
    }

    .content-block {
        padding-bottom: 10px;
        padding-top: 10px;
    }

    .footer {
        clear: both;
        margin-top: 10px;
        text-align: center;
        width: 100%;
    }

    .footer td,
    .footer p,
    .footer span,
    .footer a {
        color: #999999;
        font-size: 12px;
        text-align: center;
    }

    /* -------------------------------------
          TYPOGRAPHY
      ------------------------------------- */
    h1,
    h2,
    h3,
    h4 {
        color: #000000;
        font-family: sans-serif;
        font-weight: 400;
        line-height: 1.4;
        margin: 0;
        margin-bottom: 30px;
    }

    h1 {
        font-size: 35px;
        font-weight: 300;
        text-align: center;
        text-transform: capitalize;
    }

    p,
    ul,
    ol {
        font-family: sans-serif;
        font-size: 14px;
        font-weight: normal;
        margin: 0;
        margin-bottom: 15px;
    }

    p li,
    ul li,
    ol li {
        list-style-position: inside;
        margin-left: 5px;
    }

    a {
        color: #3498db;
        text-decoration: underline;
    }

    /* -------------------------------------
          BUTTONS
      ------------------------------------- */
    .btn {
        box-sizing: border-box;
        width: 100%;
    }

    .btn>tbody>tr>td {
        padding-bottom: 15px;
    }

    .btn table {
        width: auto;
    }

    .btn table td {
        background-color: #ffffff;
        border-radius: 5px;
        text-align: center;
    }

    .btn a {
        background-color: #ffffff;
        border: solid 1px #3498db;
        border-radius: 5px;
        box-sizing: border-box;
        color: #3498db;
        cursor: pointer;
        display: inline-block;
        font-size: 14px;
        font-weight: bold;
        margin: 0;
        padding: 12px 25px;
        text-decoration: none;
        text-transform: capitalize;
    }

    .btn-primary table td {
        background-color: #3498db;
    }

    .btn-primary a {
        background-color: #3498db;
        border-color: #3498db;
        color: #ffffff;
    }

    /* -------------------------------------
          OTHER STYLES THAT MIGHT BE USEFUL
      ------------------------------------- */
    .last {
        margin-bottom: 0;
    }

    .first {
        margin-top: 0;
    }

    .align-center {
        text-align: center;
    }

    .align-right {
        text-align: right;
    }

    .align-left {
        text-align: left;
    }

    .clear {
        clear: both;
    }

    .mt0 {
        margin-top: 0;
    }

    .mb0 {
        margin-bottom: 0;
    }

    .preheader {
        color: transparent;
        display: none;
        height: 0;
        max-height: 0;
        max-width: 0;
        opacity: 0;
        overflow: hidden;
        mso-hide: all;
        visibility: hidden;
        width: 0;
    }

    .powered-by a {
        text-decoration: none;
    }

    hr {
        border: 0;
        border-bottom: 1px solid #f6f6f6;
        margin: 20px 0;
    }

    /* -------------------------------------
          RESPONSIVE AND MOBILE FRIENDLY STYLES
      ------------------------------------- */
    @media only screen and (max-width: 620px) {
        table.body h1 {
            font-size: 28px !important;
            margin-bottom: 10px !important;
        }

        table.body p,
        table.body ul,
        table.body ol,
        table.body td,
        table.body span,
        table.body a {
            font-size: 16px !important;
        }

        table.body .wrapper,
        table.body .article {
            padding: 10px !important;
        }

        table.body .content {
            padding: 0 !important;
        }

        table.body .container {
            padding: 0 !important;
            width: 100% !important;
        }

        table.body .main {
            border-left-width: 0 !important;
            border-radius: 0 !important;
            border-right-width: 0 !important;
        }

        table.body .btn table {
            width: 100% !important;
        }

        table.body .btn a {
            width: 100% !important;
        }

        table.body .img-responsive {
            height: auto !important;
            max-width: 100% !important;
            width: auto !important;
        }
    }

    /* -------------------------------------
          PRESERVE THESE STYLES IN THE HEAD
      ------------------------------------- */
    @media all {
        .ExternalClass {
            width: 100%;
        }

        .ExternalClass,
        .ExternalClass p,
        .ExternalClass span,
        .ExternalClass font,
        .ExternalClass td,
        .ExternalClass div {
            line-height: 100%;
        }

        .apple-link a {
            color: inherit !important;
            font-family: inherit !important;
            font-size: inherit !important;
            font-weight: inherit !important;
            line-height: inherit !important;
            text-decoration: none !important;
        }

        #MessageViewBody a {
            color: inherit;
            text-decoration: none;
            font-size: inherit;
            font-family: inherit;
            font-weight: inherit;
            line-height: inherit;
        }

        .btn-primary table td:hover {
            background-color: #34495e !important;
        }

        .btn-primary a:hover {
            background-color: #34495e !important;
            border-color: #34495e !important;
        }
    }
</style>

Creating the Email Verification Template

To create the email verification template, follow these steps:

  1. Within the ‘templates‘ folder, create a new file called verification_code.hbs.
  2. Open the verification_code.hbs file and add the code provided below:

templates/verification_code.hbs


{{#> base}}
<table role="presentation" class="main">
    <!-- START MAIN CONTENT AREA -->
    <tr>
        <td class="wrapper">
            <table role="presentation" border="0" cellpadding="0" cellspacing="0">
                <tr>
                    <td>
                        <p>Hi {{first_name}},</p>
                        <p>Please verify your account to be able to login</p>
                        <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
                            <tbody>
                                <tr>
                                    <td align="left">
                                        <table role="presentation" border="0" cellpadding="0" cellspacing="0">
                                            <tbody>
                                                <tr>
                                                    <td>
                                                        <a href="{{url}}" target="_blank">Verify your account</a>
                                                    </td>
                                                </tr>
                                            </tbody>
                                        </table>
                                    </td>
                                </tr>
                            </tbody>
                        </table>
                        <p>Good luck! Codevo CEO.</p>
                    </td>
                </tr>
            </table>
        </td>
    </tr>

    <!-- END MAIN CONTENT AREA -->
</table>
{{/base}}

In the code above, we have defined the structure and content of the email verification template. The template includes placeholders such as {{first_name}} and {{url}} that will be dynamically replaced with the recipient’s first name and the URL for account verification. By enclosing the template within {{#> base}} and {{/base}}, we ensure that the email verification template is rendered within the base layout template.

Handling Database Migrations

Now, let’s explore how we can handle database migrations in our project to ensure that the necessary tables are created in the database. There are different approaches we can take, but in this article, we’ll use the recommended method: the SQLx command-line tool. We’ll specifically focus on using the tool with SQLite, although SQLx supports other databases as well.

To begin, we need to install the SQLx command-line tool on your machine. Open your terminal and run the following command:


cargo install sqlx-cli -F sqlite

This command will install the SQLx CLI tool with SQLite support, allowing us to utilize its features for performing database migrations in our Rust project.

Starting with the Initial Migrations

Let’s begin by setting up our first migration. We’ll generate reversible migration scripts that allow us to apply the migrations to the database and also revert the changes if needed. To generate these scripts, execute the following command:


sqlx migrate add -r "initial migration"

You can choose a different name for the migration by replacing “initial migration” after the -r flag. However, it’s common to use this name for the first migration. After running the command, you’ll notice a new ‘migrations‘ folder created in the project’s root directory. Inside this folder, you’ll find the ‘up‘ and ‘down‘ SQL scripts.

Open the ‘up‘ script and include the following SQL statements:

migrations/20230616144424_initial_migration.up.sql


-- Add up migration script here

CREATE TABLE
    IF NOT EXISTS "users" (
        id CHAR(36) PRIMARY KEY NOT NULL,
        name VARCHAR(100) NOT NULL,
        email VARCHAR(255) NOT NULL UNIQUE,
        password VARCHAR(100) NOT NULL,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    );

CREATE INDEX users_email_idx ON users (email);

Next, open the corresponding ‘down‘ script and include the following SQL statement, which allows us to undo the changes made by the ‘up‘ script:

migrations/20230616144424_initial_migration.down.sql


-- Add down migration script here

DROP TABLE IF EXISTS users;

Before we can generate the SQLite database or apply migrations, the SQLx command-line tool expects us to provide the database URL in a .env file. Create a new .env file in the project’s root directory and include the following environment variable, which represents the SQLite database connection URL:


# -----------------------------------------------------------------------------
# SQLite Database Connection URL
# -----------------------------------------------------------------------------

DATABASE_URL=sqlite://sqlite.db

With the environment variable set, run the command sqlx database create in your terminal. This will create the SQLite database in the root directory. Now that the database is generated, we can proceed to apply the migrations by running the command sqlx migrate run. If you ever need to undo the changes, you can use the command sqlx migrate revert.

Adding More Fields

Oops! It seems we forgot to include some important fields in the initial migration. But no worries, we can easily generate a new migration to alter the existing table and add the missing fields. To create the reversible migration scripts for the new fields, execute the following command:


sqlx migrate add -r "added more fields"

In this case, we named the migration “added more fields” to indicate that we are adding additional fields. Once the command completes, you will find two new SQL scripts, ‘up‘ and ‘down‘, generated within the ‘migrations‘ folder. Open the ‘up‘ script and include the following SQL queries:

migrations/20230616191956_added_more_fields.up.sql


-- Add up migration script here

ALTER TABLE users
ADD
    COLUMN photo VARCHAR(255) DEFAULT 'default.png';

ALTER TABLE users ADD COLUMN verified BOOLEAN DEFAULT FALSE;

ALTER TABLE users ADD COLUMN verification_code VARCHAR(255);
CREATE INDEX idx_verification_code ON users(verification_code);

ALTER TABLE users ADD COLUMN role VARCHAR(50) DEFAULT 'user';

Next, open the corresponding ‘down‘ script and include the following SQL statements to revert the changes made by the ‘up‘ script:

migrations/20230616191956_added_more_fields.down.sql


-- Add down migration script here

ALTER TABLE users DROP COLUMN photo;

ALTER TABLE users DROP COLUMN verified;

ALTER TABLE users DROP COLUMN verification_code;

ALTER TABLE users DROP COLUMN role;

DROP INDEX idx_verification_code;

Once you have finished updating the migration scripts, you can run the command sqlx migrate run to apply the ‘up‘ migration and add the new fields to the existing table. To remove the newly added fields, simply execute sqlx migrate revert.

Creating the Database and Request Models

Now that we have added the users table to the SQLite database, we need to define a Rust struct with fields mapping to the columns of the users table. This struct will serve as a model and allow us to work with the data in a structured manner.

To accomplish this, navigate to the src directory and create a new file named model.rs. In this file, define the following models:

src/model.rs


use chrono::prelude::*;
use serde::{Deserialize, Serialize};

#[allow(non_snake_case)]
#[derive(Debug, Deserialize, sqlx::FromRow, Serialize, Clone)]
pub struct User {
    pub id: String,
    pub name: String,
    pub email: String,
    pub password: String,
    pub photo: String,
    pub verified: bool,
    pub verification_code: Option<String>,
    pub role: String,
    #[serde(rename = "createdAt")]
    pub created_at: Option<NaiveDateTime>,
    #[serde(rename = "updatedAt")]
    pub updated_at: Option<NaiveDateTime>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct TokenClaims {
    pub sub: String,
    pub iat: usize,
    pub exp: usize,
}

#[derive(Debug, Deserialize)]
pub struct RegisterUserSchema {
    pub name: String,
    pub email: String,
    pub password: String,
}

#[derive(Debug, Deserialize)]
pub struct LoginUserSchema {
    pub email: String,
    pub password: String,
}

In the code above, we defined the User struct, which represents the columns of the users table in the database. By deriving the sqlx::FromRow trait, SQLx can automatically map the results of a database query to the fields of the User struct.

Additionally, we defined other structs like TokenClaims, RegisterUserSchema, and LoginUserSchema, which are used for handling token claims, registering new users, and logging in users, respectively.

While it’s recommended to organize these models into separate files in larger projects, for the simplicity of this project, we can keep them all within the model.rs file.

Utility Functions for Sending the Emails

Now, let’s focus on the code required to send emails. We’ll take a modular approach by creating a struct with methods that enable us to send different types of emails. By invoking the appropriate method, the corresponding email will be sent.

Getting Your SMTP Provider Credentials

Before we proceed, we need to add your SMTP credentials to the environment variables in the .env file. If you don’t have the credentials or are unsure how to obtain them, refer to this article which provides guidance on the process. The article presents two options: one explains how to obtain SMTP credentials from a provider that captures all development emails, ensuring none of the emails you send go to real users, and the other explains how to obtain SMTP credentials from a provider that relays your emails to real users.

Once you have obtained the credentials, open the .env file and include them as instructed:

.env


# -----------------------------------------------------------------------------
# SQLite Database Connection URL
# -----------------------------------------------------------------------------

DATABASE_URL=sqlite://sqlite.db

# -----------------------------------------------------------------------------
# JSON Web Token
# -----------------------------------------------------------------------------

JWT_SECRET=my_ultra_secure_secret
JWT_EXPIRED_IN=60m
JWT_MAXAGE=60

# -----------------------------------------------------------------------------
# Email (Postmark || Any SMTP Provider)
# -----------------------------------------------------------------------------

SMTP_HOST=
SMTP_PORT=2525
SMTP_USER=
SMTP_PASS=
SMTP_FROM=admin@admin.com
SMTP_TO=johndoe@gmail.com

FRONTEND_ORIGIN=http://localhost:3000

In the .env file, you will also find the environment variables required to generate JSON Web Tokens. Including them here ensures that we don’t need to define them in other sections.

Loading the Environment Variables

To load the environment variables, create a config.rs file in the src directory and include the following code:

src/config.rs


#[derive(Debug, Clone)]
pub struct Config {
    pub database_url: String,
    pub jwt_secret: String,
    pub jwt_expires_in: String,
    pub jwt_maxage: i32,
    pub smtp_host: String,
    pub smtp_port: u16,
    pub smtp_user: String,
    pub smtp_pass: String,
    pub smtp_from: String,
    pub frontend_origin: String,
}

impl Config {
    pub fn init() -> Config {
        let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
        let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
        let jwt_expires_in = std::env::var("JWT_EXPIRED_IN").expect("JWT_EXPIRED_IN must be set");
        let jwt_maxage = std::env::var("JWT_MAXAGE").expect("JWT_MAXAGE must be set");

        let smtp_host = std::env::var("SMTP_HOST").expect("SMTP_HOST must be set");
        let smtp_port = std::env::var("SMTP_PORT").expect("SMTP_PORT must be set");
        let smtp_user = std::env::var("SMTP_USER").expect("SMTP_USER must be set");
        let smtp_pass = std::env::var("SMTP_PASS").expect("SMTP_PASS must be set");
        let smtp_from = std::env::var("SMTP_FROM").expect("SMTP_FROM must be set");

        let frontend_origin =
            std::env::var("FRONTEND_ORIGIN").expect("FRONTEND_ORIGIN must be set");

        Config {
            database_url,
            jwt_secret,
            jwt_expires_in,
            jwt_maxage: jwt_maxage.parse::<i32>().unwrap(),
            smtp_host,
            smtp_pass,
            smtp_user,
            smtp_port: smtp_port.parse::<u16>().unwrap(),
            smtp_from,
            frontend_origin,
        }
    }
}

Creating a Struct with Methods for Sending the Emails

Here comes the most important code, which generates the email templates using the Handlebars template engine, populates them with data, and sends them to the provided email addresses. For more details about each method, refer to this article: ‘How to Send HTML Emails in Rust via SMTP‘.

Create a file named email.rs in the src directory and include the following code:

src/email.rs


use handlebars::Handlebars;
use lettre::{
    message::header::ContentType, transport::smtp::authentication::Credentials, AsyncSmtpTransport,
    AsyncTransport, Message, Tokio1Executor,
};

use crate::{config::Config, model::User};

pub struct Email {
    user: User,
    url: String,
    from: String,
    config: Config,
}

impl Email {
    pub fn new(user: User, url: String, config: Config) -> Self {
        let from = format!("Codevo <{}>", config.smtp_from.to_owned());

        Email {
            user,
            url,
            from,
            config,
        }
    }

    fn new_transport(
        &self,
    ) -> Result<AsyncSmtpTransport<Tokio1Executor>, lettre::transport::smtp::Error> {
        let creds = Credentials::new(
            self.config.smtp_user.to_owned(),
            self.config.smtp_pass.to_owned(),
        );

        let transport = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(
            &self.config.smtp_host.to_owned(),
        )?
        .port(self.config.smtp_port)
        .credentials(creds)
        .build();

        Ok(transport)
    }

    fn render_template(&self, template_name: &str) -> Result<String, handlebars::RenderError> {
        let mut handlebars = Handlebars::new();
        handlebars
            .register_template_file(template_name, &format!("./templates/{}.hbs", template_name))?;
        handlebars.register_template_file("styles", "./templates/partials/styles.hbs")?;
        handlebars.register_template_file("base", "./templates/layouts/base.hbs")?;

        let data = serde_json::json!({
            "first_name": &self.user.name.split_whitespace().next().unwrap(),
            "subject": &template_name,
            "url": &self.url
        });

        let content_template = handlebars.render(template_name, &data)?;

        Ok(content_template)
    }

    async fn send_email(
        &self,
        template_name: &str,
        subject: &str,
    ) -> Result<(), Box<dyn std::error::Error>> {
        let html_template = self.render_template(template_name)?;
        let email = Message::builder()
            .to(
                format!("{} <{}>", self.user.name.as_str(), self.user.email.as_str())
                    .parse()
                    .unwrap(),
            )
            .reply_to(self.from.as_str().parse().unwrap())
            .from(self.from.as_str().parse().unwrap())
            .subject(subject)
            .header(ContentType::TEXT_HTML)
            .body(html_template)?;

        let transport = self.new_transport()?;

        transport.send(email).await?;
        Ok(())
    }

    pub async fn send_verification_code(&self) -> Result<(), Box<dyn std::error::Error>> {
        self.send_email("verification_code", "Your account verification code")
            .await
    }
}

Creating the API Response Models

In order to handle user credentials securely, we need to create a struct with methods that allow us to filter the user data retrieved from the database. This way, we can exclude sensitive information, such as the user’s hashed password or phone number, from the JSON response. Follow these steps to implement the required functionality:

  1. Start by creating a new file named response.rs within the src directory.
  2. Inside the response.rs file, add the following code:

src/response.rs


use chrono::prelude::*;
use serde::Serialize;

use crate::model::User;

#[allow(non_snake_case)]
#[derive(Debug, Serialize)]
pub struct FilteredUser {
    pub id: String,
    pub name: String,
    pub email: String,
    pub role: String,
    pub photo: String,
    pub verified: bool,
    pub createdAt: DateTime<Utc>,
    pub updatedAt: DateTime<Utc>,
}

impl FilteredUser {
    pub fn new_user(user: &User) -> Self {
        let created_at_utc: DateTime<Utc> = DateTime::from_utc(user.created_at.unwrap(), Utc);
        let updated_at_utc: DateTime<Utc> = DateTime::from_utc(user.updated_at.unwrap(), Utc);
        Self {
            id: user.id.to_string(),
            email: user.email.to_owned(),
            name: user.name.to_owned(),
            photo: user.photo.to_owned(),
            role: user.role.to_owned(),
            verified: user.verified,
            createdAt: created_at_utc,
            updatedAt: updated_at_utc,
        }
    }
}

#[derive(Serialize, Debug)]
pub struct UserData {
    pub user: FilteredUser,
}

#[derive(Serialize, Debug)]
pub struct UserResponse {
    pub status: String,
    pub data: UserData,
}

#[derive(Debug, Serialize)]
pub struct ErrorResponse {
    pub status: &'static str,
    pub message: String,
}

Creating the JWT Middleware Guard

To secure our private routes and ensure that only authenticated users can access them, we need to create a middleware. This middleware will check for the presence of a valid JWT token in the request. This middleware will act as a guard, verifying the presence and validity of a JSON Web Token in the request. Follow these steps to implement the JWT middleware guard:

  1. Create a new file named jwt_auth.rs within the src directory.
  2. Inside the jwt_auth.rs file, include the following code:

src/jwt_auth.rs


use std::sync::Arc;

use axum::{
    extract::State,
    http::{header, Request, StatusCode},
    middleware::Next,
    response::IntoResponse,
    Json, body::Body,
};

use axum_extra::extract::cookie::CookieJar;
use jsonwebtoken::{decode, DecodingKey, Validation};

use crate::{
    model::{TokenClaims, User},
    response::ErrorResponse,
    AppState,
};

pub async fn auth(
    cookie_jar: CookieJar,
    State(data): State<Arc<AppState>>,
    mut req: Request<Body>,
    next: Next,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let token = cookie_jar
        .get("token")
        .map(|cookie| cookie.value().to_string())
        .or_else(|| {
            req.headers()
                .get(header::AUTHORIZATION)
                .and_then(|auth_header| auth_header.to_str().ok())
                .and_then(|auth_value| {
                    if auth_value.starts_with("Bearer ") {
                        Some(auth_value[7..].to_owned())
                    } else {
                        None
                    }
                })
        });

    let token = token.ok_or_else(|| {
        let json_error = ErrorResponse {
            status: "fail",
            message: "You are not logged in, please provide token".to_string(),
        };
        (StatusCode::UNAUTHORIZED, Json(json_error))
    })?;

    let claims = decode::<TokenClaims>(
        &token,
        &DecodingKey::from_secret(data.config.jwt_secret.as_ref()),
        &Validation::default(),
    )
    .map_err(|_| {
        let json_error = ErrorResponse {
            status: "fail",
            message: "Invalid token".to_string(),
        };
        (StatusCode::UNAUTHORIZED, Json(json_error))
    })?
    .claims;

    let user: Option<User> = match sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
        .bind(claims.sub)
        .fetch_optional(&data.db)
        .await
    {
        Ok(user) => user,
        Err(e) => {
            let json_error = ErrorResponse {
                status: "fail",
                message: format!("Error fetching user from database: {}", e),
            };
            return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(json_error)));
        }
    };

    let user = user.ok_or_else(|| {
        let json_error = ErrorResponse {
            status: "fail",
            message: "The user belonging to this token no longer exists".to_string(),
        };
        (StatusCode::UNAUTHORIZED, Json(json_error))
    })?;

    req.extensions_mut().insert(user);
    Ok(next.run(req).await)
}

In the middleware, you can see that we retrieve the token from either the Authorization header or the Cookie object. This approach gives users some flexibility, allowing them to manually include the token in the Authorization header or let their browser or API client automatically send the cookie along with the request.

Creating the API Endpoint Handlers

Now it’s time to start creating the route handlers. Here is an overview of the handlers we will create:

  • health_checker_handler – This handler returns a JSON response with a simple message to indicate the health of the API.
  • register_user_handler – This handler is responsible for user registration and sends email verification instructions to the user.
  • login_user_handler – This handler authenticates the user and returns a JSON Web Token (JWT) that can be used for accessing protected routes.
  • verify_email_handler – This handler verifies the email verification token sent to the user’s email address.
  • logout_handler – This handler logs out the user by invalidating the JWT token.
  • get_me_handler – This handler returns the credentials of the currently authenticated user.

To begin implementing these handlers, create a new file called handler.rs in the src directory and import the required dependencies.

src/handler.rs


use std::sync::Arc;

use argon2::{password_hash::SaltString, Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use axum::{
    extract::{Path, State},
    http::{header, Response, StatusCode},
    response::IntoResponse,
    Extension, Json,
};
use axum_extra::extract::cookie::{Cookie, SameSite};
use jsonwebtoken::{encode, EncodingKey, Header};
use rand::{distributions::Alphanumeric, rngs::OsRng, Rng};
use serde_json::json;

use crate::{
    email::Email,
    model::{LoginUserSchema, RegisterUserSchema, TokenClaims, User},
    response::{ErrorResponse, FilteredUser},
    AppState,
};

Health Checker Handler

Let’s start with the first route handler, which is quite simple. Here, we will return a JSON response with a success status and a message indicating the technology stack used in the application. To implement this, add the following code to the handler.rs file:

src/handler.rs


pub async fn health_checker_handler() -> impl IntoResponse {
    const MESSAGE: &str =
        "Rust - User Registration and Email Verification using Axum, Postgres, and SQLX";

    let json_response = serde_json::json!({
        "status": "success",
        "message": MESSAGE
    });

    Json(json_response)
}

Register User with Email Verification Handler

Next, let’s create the handler responsible for registering new users and sending them an email verification link. Before we proceed, we need to create a utility function called generate_random_string that will generate a random string to be used as the email verification token. Add the following code to the handler.rs file:

src/handler.rs


fn generate_random_string(length: usize) -> String {
    let rng = rand::thread_rng();
    let random_string: String = rng
        .sample_iter(&Alphanumeric)
        .take(length)
        .map(char::from)
        .collect();

    random_string
}

The generate_random_string function utilizes the rand::thread_rng to generate a random alphanumeric string of the specified length.

Before implementing the account registration handler, let’s review the requirements. When the handler is invoked, it performs the following steps:

  1. Check the database to see if the provided email in the request body already exists.
  2. If a matching user is found, return a 409 CONFLICT error to the user.
  3. If no user is found, hash the user’s password and store the user’s credentials in the database.
  4. Generate an email verification token.
  5. Send the token in an email template to the user’s email address.
  6. If the email is sent successfully, store a copy of the verification token in the database.
  7. Return a JSON response with a success message if all operations are successful.

To implement this handler, copy and paste the following code into the handler.rs file:

src/handler.rs


pub async fn register_user_handler(
    State(data): State<Arc<AppState>>,
    Json(body): Json<RegisterUserSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    // Check if the email already exists in the database
    let user_exists: Option<bool> =
        sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)")
            .bind(body.email.to_owned().to_ascii_lowercase())
            .fetch_one(&data.db)
            .await
            .map_err(|e| {
                let error_response = ErrorResponse {
                    status: "fail",
                    message: format!("Database error: {}", e),
                };
                (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
            })?;

    if let Some(exists) = user_exists {
        if exists {
            let error_response = ErrorResponse {
                status: "fail",
                message: "User with that email already exists".to_string(),
            };
            return Err((StatusCode::CONFLICT, Json(error_response)));
        }
    }

    // Hash the user's password
    let salt = SaltString::generate(&mut OsRng);
    let hashed_password = Argon2::default()
        .hash_password(body.password.as_bytes(), &salt)
        .map_err(|e| {
            let error_response = ErrorResponse {
                status: "fail",
                message: format!("Error while hashing password: {}", e),
            };
            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
        })
        .map(|hash| hash.to_string())?;
    
    // Generate the email verification token
    let verification_code = generate_random_string(10);

    // Store user credentials in the database
    let email = body.email.to_owned().to_ascii_lowercase();
    let id = uuid::Uuid::new_v4().to_string();
    let verification_url = format!(
        "{}/verifyemail/{}",
        data.config.frontend_origin.to_owned(),
        verification_code
    );

    let user: User = sqlx::query_as(
        "INSERT INTO users (id,name,email,password) VALUES ($1, $2, $3, $4) RETURNING *",
    )
    .bind(id.clone())
    .bind(body.name.to_owned())
    .bind(email.clone())
    .bind(hashed_password)
    .fetch_one(&data.db)
    .await
    .map_err(|e| {
        let error_response = ErrorResponse {
            status: "fail",
            message: format!("Database error: {}", e),
        };
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(error_response.into()),
        )
    })?;

    // Send email verification token to the user's email address
    let email_instance = Email::new(user, verification_url, data.config.clone());
    if let Err(_) = email_instance.send_verification_code().await {
        let json_error = ErrorResponse {
            status: "fail",
            message: "Something bad happended while sending the verification code".to_string(),
        };
        return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(json_error)));
    }

    // Store the email verification token in the database
    sqlx::query("UPDATE users SET verification_code = $1 WHERE id = $2")
        .bind(verification_code)
        .bind(id)
        .execute(&data.db)
        .await
        .map_err(|e| {
            let json_error = ErrorResponse {
                status: "fail",
                message: format!("Error updating user: {}", e),
            };
            (StatusCode::INTERNAL_SERVER_ERROR, Json(json_error))
        })?;

    let user_response = serde_json::json!({"status": "success","message": format!("We sent an email with a verification code to {}", email)});

    Ok(Json(user_response))
}

Login User Handler

Now let’s create the login handler, which will be called when a POST request is made to the /api/auth/login endpoint. The handler performs the following steps:

  1. Query the database to check if a user with the provided email exists.
  2. Check if the user’s account is verified.
  3. Compare the provided plain-text password with the hashed password stored in the database using the Argon2 crate.
  4. If the passwords match, generate a new access token and include it in the JSON response.

To implement this handler, use the following code in the handler.rs file:

src/handler.rs


pub async fn login_user_handler(
    State(data): State<Arc<AppState>>,
    Json(body): Json<LoginUserSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let email = body.email.to_ascii_lowercase();

    // Check if the user exists in the database
    let user: User = sqlx::query_as("SELECT * FROM users WHERE email = $1")
        .bind(email)
        .fetch_optional(&data.db)
        .await
        .map_err(|e| {
            let error_response = ErrorResponse {
                status: "error",
                message: format!("Database error: {}", e),
            };
            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
        })?
        .ok_or_else(|| {
            let error_response = ErrorResponse {
                status: "fail",
                message: "Invalid email or password".to_string(),
            };
            (StatusCode::BAD_REQUEST, Json(error_response))
        })?;

    if !user.verified {
        let error_response = ErrorResponse {
            status: "fail",
            message: "Please verify your email before you can log in".to_string(),
        };
        return Err((StatusCode::BAD_REQUEST, Json(error_response)));
    }

    // Verify the password
    let is_valid = match PasswordHash::new(&user.password) {
        Ok(parsed_hash) => Argon2::default()
            .verify_password(body.password.as_bytes(), &parsed_hash)
            .map_or(false, |_| true),
        Err(_) => false,
    };

    if !is_valid {
        let error_response = ErrorResponse {
            status: "fail",
            message: "Invalid email or password".to_string(),
        };
        return Err((StatusCode::BAD_REQUEST, Json(error_response)));
    }

    // Generate a new access token
    let now = chrono::Utc::now();
    let iat = now.timestamp() as usize;
    let exp = (now + chrono::Duration::minutes(60)).timestamp() as usize;
    let claims: TokenClaims = TokenClaims {
        sub: user.id.to_string(),
        exp,
        iat,
    };

    let token = encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(data.config.jwt_secret.as_ref()),
    )
    .unwrap();

    // Create a response with the access token and set it as a cookie
    let cookie = Cookie::build(("token", token.to_owned()))
        .path("/")
        .max_age(time::Duration::hours(1))
        .same_site(SameSite::Lax)
        .http_only(true);

    let mut response = Response::new(json!({"status": "success", "token": token}).to_string());
    response
        .headers_mut()
        .insert(header::SET_COOKIE, cookie.to_string().parse().unwrap());
    Ok(response)
}

Verify Email Verification Token Handler

Next, let’s focus on the handler responsible for validating the email verification token and marking the user as verified if the token is valid. When this handler is invoked, it performs the following steps:

  1. Extracts the verification token from the URL path.
  2. Queries the database to check if any user matches the provided token.
  3. If a user exists (indicating a valid token), checks if the user has already been verified.
  4. If the user is already verified, returns a 409 CONFLICT error.
  5. If the user is not verified, updates the user’s information in the database to mark them as verified.
  6. Returns a JSON response with a success status and message to the user.

To implement this handler, use the following code in the handler.rs file:

src/handler.rs


pub async fn verify_email_handler(
    State(data): State<Arc<AppState>>,
    Path(verification_code): Path<String>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let user: User = sqlx::query_as("SELECT * FROM users WHERE verification_code = $1")
        .bind(&verification_code)
        .fetch_optional(&data.db)
        .await
        .map_err(|e| {
            let error_response = ErrorResponse {
                status: "error",
                message: format!("Database error: {}", e),
            };
            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
        })?
        .ok_or_else(|| {
            let error_response = ErrorResponse {
                status: "fail",
                message: "Invalid verification code or user doesn't exist".to_string(),
            };
            (StatusCode::UNAUTHORIZED, Json(error_response))
        })?;

    if user.verified {
        let error_response = ErrorResponse {
            status: "fail",
            message: "User already verified".to_string(),
        };
        return Err((StatusCode::CONFLICT, Json(error_response)));
    }

    sqlx::query(
        "UPDATE users SET verification_code = $1, verified = $2 WHERE verification_code = $3",
    )
    .bind("")
    .bind(true)
    .bind(&verification_code)
    .execute(&data.db)
    .await
    .map_err(|e| {
        let json_error = ErrorResponse {
            status: "fail",
            message: format!("Error updating user: {}", e),
        };
        (StatusCode::INTERNAL_SERVER_ERROR, Json(json_error))
    })?;

    let response = serde_json::json!({
            "status": "success",
            "message": "Email verified successfully"
        }
    );

    Ok(Json(response))
}

Logout User Handler

The logout handler is simple as it involves returning an expired cookie to the client. This action prompts the client’s API client or browser to automatically delete the existing cookie. To implement the logout handler, copy and paste the code below into your handler.rs file.

src/handler.rs


pub async fn logout_handler() -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let cookie = Cookie::build(("token", ""))
        .path("/")
        .max_age(time::Duration::hours(-1))
        .same_site(SameSite::Lax)
        .http_only(true);

    let mut response = Response::new(json!({"status": "success"}).to_string());
    response
        .headers_mut()
        .insert(header::SET_COOKIE, cookie.to_string().parse().unwrap());
    Ok(response)
}

Retrieve Authentication User Credentials Handler

For our final handler, the objective is to extract the authenticated user passed down by the JWT middleware guard, filter out any sensitive information, and respond with the remaining data in a JSON format. To implement the get_me_handler in your handler.rs file, you can use the following code:

src/handler.rs


pub async fn get_me_handler(
    Extension(user): Extension,
) -> Result)> {
    let filtered_user = FilteredUser::new_user(&user);

    let json_response = serde_json::json!({
        "status": "success",
        "data": {
            "user": filtered_user
        }
    });

    Ok(Json(json_response))
}

The Complete Code of the API Endpoint Handlers

src/handler.rs


use std::sync::Arc;

use argon2::{password_hash::SaltString, Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use axum::{
    extract::{Path, State},
    http::{header, Response, StatusCode},
    response::IntoResponse,
    Extension, Json,
};
use axum_extra::extract::cookie::{Cookie, SameSite};
use chrono::{DateTime, Utc};
use jsonwebtoken::{encode, EncodingKey, Header};
use rand::{distributions::Alphanumeric, rngs::OsRng, Rng};
use serde_json::json;

use crate::{
    email::Email,
    model::{LoginUserSchema, RegisterUserSchema, TokenClaims, User},
    response::{ErrorResponse, FilteredUser},
    AppState,
};

pub async fn health_checker_handler() -> impl IntoResponse {
    const MESSAGE: &str =
        "Rust - User Registration and Email Verification using Axum, Postgres, and SQLX";

    let json_response = serde_json::json!({
        "status": "success",
        "message": MESSAGE
    });

    Json(json_response)
}

pub async fn register_user_handler(
    State(data): State<Arc<AppState>>,
    Json(body): Json<RegisterUserSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let user_exists: Option<bool> =
        sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)")
            .bind(body.email.to_owned().to_ascii_lowercase())
            .fetch_one(&data.db)
            .await
            .map_err(|e| {
                let error_response = ErrorResponse {
                    status: "fail",
                    message: format!("Database error: {}", e),
                };
                (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
            })?;

    if let Some(exists) = user_exists {
        if exists {
            let error_response = ErrorResponse {
                status: "fail",
                message: "User with that email already exists".to_string(),
            };
            return Err((StatusCode::CONFLICT, Json(error_response)));
        }
    }

    let salt = SaltString::generate(&mut OsRng);
    let hashed_password = Argon2::default()
        .hash_password(body.password.as_bytes(), &salt)
        .map_err(|e| {
            let error_response = ErrorResponse {
                status: "fail",
                message: format!("Error while hashing password: {}", e),
            };
            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
        })
        .map(|hash| hash.to_string())?;

    let verification_code = generate_random_string(10);
    let email = body.email.to_owned().to_ascii_lowercase();
    let id = uuid::Uuid::new_v4().to_string();
    let verification_url = format!(
        "{}/verifyemail/{}",
        data.config.frontend_origin.to_owned(),
        verification_code
    );

    let user: User = sqlx::query_as(
        "INSERT INTO users (id,name,email,password) VALUES ($1, $2, $3, $4) RETURNING *",
    )
    .bind(id.clone())
    .bind(body.name.to_owned())
    .bind(email.clone())
    .bind(hashed_password)
    .fetch_one(&data.db)
    .await
    .map_err(|e| {
        let error_response = ErrorResponse {
            status: "fail",
            message: format!("Database error: {}", e),
        };
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(error_response.into()),
        )
    })?;

    //  Create an Email instance
    let email_instance = Email::new(user, verification_url, data.config.clone());
    if let Err(_) = email_instance.send_verification_code().await {
        let json_error = ErrorResponse {
            status: "fail",
            message: "Something bad happended while sending the verification code".to_string(),
        };
        return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(json_error)));
    }

    sqlx::query("UPDATE users SET verification_code = $1 WHERE id = $2")
        .bind(verification_code)
        .bind(id)
        .execute(&data.db)
        .await
        .map_err(|e| {
            let json_error = ErrorResponse {
                status: "fail",
                message: format!("Error updating user: {}", e),
            };
            (StatusCode::INTERNAL_SERVER_ERROR, Json(json_error))
        })?;

    let user_response = serde_json::json!({"status": "success","message": format!("We sent an email with a verification code to {}", email)});

    Ok(Json(user_response))
}

pub async fn login_user_handler(
    State(data): State<Arc<AppState>>,
    Json(body): Json<LoginUserSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let email = body.email.to_ascii_lowercase();
    let user: User = sqlx::query_as("SELECT * FROM users WHERE email = $1")
        .bind(email)
        .fetch_optional(&data.db)
        .await
        .map_err(|e| {
            let error_response = ErrorResponse {
                status: "error",
                message: format!("Database error: {}", e),
            };
            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
        })?
        .ok_or_else(|| {
            let error_response = ErrorResponse {
                status: "fail",
                message: "Invalid email or password".to_string(),
            };
            (StatusCode::BAD_REQUEST, Json(error_response))
        })?;

    if !user.verified {
        let error_response = ErrorResponse {
            status: "fail",
            message: "Please verify your email before you can log in".to_string(),
        };
        return Err((StatusCode::BAD_REQUEST, Json(error_response)));
    }

    let is_valid = match PasswordHash::new(&user.password) {
        Ok(parsed_hash) => Argon2::default()
            .verify_password(body.password.as_bytes(), &parsed_hash)
            .map_or(false, |_| true),
        Err(_) => false,
    };

    if !is_valid {
        let error_response = ErrorResponse {
            status: "fail",
            message: "Invalid email or password".to_string(),
        };
        return Err((StatusCode::BAD_REQUEST, Json(error_response)));
    }

    let now = chrono::Utc::now();
    let iat = now.timestamp() as usize;
    let exp = (now + chrono::Duration::minutes(60)).timestamp() as usize;
    let claims: TokenClaims = TokenClaims {
        sub: user.id.to_string(),
        exp,
        iat,
    };

    let token = encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(data.config.jwt_secret.as_ref()),
    )
    .unwrap();

    let cookie = Cookie::build(("token", token.to_owned()))
        .path("/")
        .max_age(time::Duration::hours(1))
        .same_site(SameSite::Lax)
        .http_only(true);

    let mut response = Response::new(json!({"status": "success", "token": token}).to_string());
    response
        .headers_mut()
        .insert(header::SET_COOKIE, cookie.to_string().parse().unwrap());
    Ok(response)
}

pub async fn verify_email_handler(
    State(data): State<Arc<AppState>>,
    Path(verification_code): Path<String>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let user: User = sqlx::query_as("SELECT * FROM users WHERE verification_code = $1")
        .bind(&verification_code)
        .fetch_optional(&data.db)
        .await
        .map_err(|e| {
            let error_response = ErrorResponse {
                status: "error",
                message: format!("Database error: {}", e),
            };
            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
        })?
        .ok_or_else(|| {
            let error_response = ErrorResponse {
                status: "fail",
                message: "Invalid verification code or user doesn't exist".to_string(),
            };
            (StatusCode::UNAUTHORIZED, Json(error_response))
        })?;

    if user.verified {
        let error_response = ErrorResponse {
            status: "fail",
            message: "User already verified".to_string(),
        };
        return Err((StatusCode::CONFLICT, Json(error_response)));
    }

    sqlx::query(
        "UPDATE users SET verification_code = $1, verified = $2 WHERE verification_code = $3",
    )
    .bind("")
    .bind(true)
    .bind(&verification_code)
    .execute(&data.db)
    .await
    .map_err(|e| {
        let json_error = ErrorResponse {
            status: "fail",
            message: format!("Error updating user: {}", e),
        };
        (StatusCode::INTERNAL_SERVER_ERROR, Json(json_error))
    })?;

    let response = serde_json::json!({
            "status": "success",
            "message": "Email verified successfully"
        }
    );

    Ok(Json(response))
}

pub async fn logout_handler() -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let cookie = Cookie::build(("token", ""))
        .path("/")
        .max_age(time::Duration::hours(-1))
        .same_site(SameSite::Lax)
        .http_only(true);

    let mut response = Response::new(json!({"status": "success"}).to_string());
    response
        .headers_mut()
        .insert(header::SET_COOKIE, cookie.to_string().parse().unwrap());
    Ok(response)
}

pub async fn get_me_handler(
    Extension(user): Extension<User>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let json_response = serde_json::json!({
        "status":  "success",
        "data": serde_json::json!({
            "user": filter_user_record(&user)
        })
    });

    Ok(Json(json_response))
}

fn filter_user_record(user: &User) -> FilteredUser {
    let created_at_utc: DateTime<Utc> = DateTime::from_utc(user.created_at.unwrap(), Utc);
    let updated_at_utc: DateTime<Utc> = DateTime::from_utc(user.updated_at.unwrap(), Utc);
    FilteredUser {
        id: user.id.to_string(),
        email: user.email.to_owned(),
        name: user.name.to_owned(),
        photo: user.photo.to_owned(),
        role: user.role.to_owned(),
        verified: user.verified,
        createdAt: created_at_utc,
        updatedAt: updated_at_utc,
    }
}

fn generate_random_string(length: usize) -> String {
    let rng = rand::thread_rng();
    let random_string: String = rng
        .sample_iter(&Alphanumeric)
        .take(length)
        .map(char::from)
        .collect();

    random_string
}

Creating the API Endpoints

To create the API endpoints and associate them with their respective handlers, follow these steps:

  1. Create a route.rs file in the src directory.
  2. Use the code provided below as the content of the route.rs file:

src/route.rs


use std::sync::Arc;

use axum::{
    middleware,
    routing::{get, post},
    Router,
};

use crate::{
    handler::{
        get_me_handler, health_checker_handler, login_user_handler, logout_handler,
        register_user_handler, verify_email_handler,
    },
    jwt_auth::auth,
    AppState,
};

pub fn create_router(app_state: Arc<AppState>) -> Router {
    Router::new()
        .route("/api/healthchecker", get(health_checker_handler))
        .route("/api/auth/register", post(register_user_handler))
        .route("/api/auth/login", post(login_user_handler))
        .route(
            "/api/auth/logout",
            get(logout_handler)
                .route_layer(middleware::from_fn_with_state(app_state.clone(), auth)),
        )
        .route(
            "/api/auth/verifyemail/:verification_code",
            get(verify_email_handler),
        )
        .route(
            "/api/users/me",
            get(get_me_handler)
                .route_layer(middleware::from_fn_with_state(app_state.clone(), auth)),
        )
        .with_state(app_state)
}

Registering the API Router

To register the API router and configure the server, follow these steps:

  1. Open the main.rs file.
  2. Replace the existing code with the improved version provided below:

src/main.rs


mod config;
mod email;
mod handler;
mod jwt_auth;
mod model;
mod response;
mod route;

use config::Config;
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};
use std::sync::Arc;

use axum::http::{
    header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
    HeaderValue, Method,
};
use dotenv::dotenv;
use route::create_router;
use tower_http::cors::CorsLayer;

pub struct AppState {
    db: SqlitePool,
    config: Config,
}

#[tokio::main]
async fn main() {
    dotenv().ok();

    let config = Config::init();

    let pool = match SqlitePoolOptions::new()
        .max_connections(10)
        .connect(&config.database_url)
        .await
    {
        Ok(pool) => {
            println!("✅Connection to the database is successful!");
            pool
        }
        Err(err) => {
            println!("🔥 Failed to connect to the database: {:?}", err);
            std::process::exit(1);
        }
    };

    let cors = CorsLayer::new()
        .allow_origin("http://localhost:3000".parse::<HeaderValue>().unwrap())
        .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
        .allow_credentials(true)
        .allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE]);

    let app = create_router(Arc::new(AppState {
        db: pool.clone(),
        config: config.clone(),
    }))
    .layer(cors);

    println!("🚀 Server started successfully");
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

In the above code snippet, we imported the required modules and structs to set up our application. We then established a database connection and configured the CORS settings to handle cross-origin requests. Finally, we initialized the server with the router.

Now, with the project completed, you can easily run it by executing the command cargo run. Once the server is up and running, you can begin sending requests to the API using tools like Postman or the Thunder Client VS Code extension. These API testing software options will enable you to thoroughly test the functionality of the endpoints and ensure everything is working as expected.

Conclusion

That concludes our journey. In this article, you have learned how to develop a Rust-based API that encompasses user registration, login, email verification, and logout functionality.

I trust that you found this article to be informative and beneficial. If you have any questions or feedback, please don’t hesitate to leave them in the comments section. Thank you for taking the time to read this article!