Leveraging Rust's features to build my blog

studen

Ferris the rusty crab

Rust is one of the most loved languages1 by developers out there. Its in-built memory safeness and the great ecosystem around it make it a great option for building blazingly fast applications.

In this article, I highlight some examples of how I use some of the Rust features to make my code for this website more extensible and ergonomic.

The trait system

In Rust, traits are used to define functionality that particular types would implement. It's similar to the concept of interfaces in some languages. For example, in C++ one could define an interface using an abstract class, setting up the pure virtual functions that other classes would implement:

.cppclass DatabaseHandler {
public:
    virtual bool connect(std::string connection_url) = 0;
    virtual bool disconnect() = 0;
    // more necessary interface functions
};

class MyDbHandler: public DatabaseHandler {
    // some private attributes
public:
    bool connect(std::string connection_url) {
        // Implementation of the interface
    }

    bool disconnect() {
        // Implementation of the interface
    }

    // More methods if you will
};

In Rust we would use traits:

.rstrait DatabaseHandler {
    fn connect(&self, connection_url: &str) -> Result<(), Error>;
    fn disconnect(&self) -> Result<(), Error>;
}

struct MyDbHandler;

impl Databasehandler for MyDbHandler {
    fn connect(&self, connection_url: &str) -> Result<(), Error> {
        // Implementation
    }

    fn disconnect(&self) -> Result<(), Error>{
        // Implementation
    }
}

Traits and interfaces allow for polymorphism in our code, making our code easily extensible.

My database handler

I'm using MongoDB for this blog (at the time of writing this), but I wanted to create an interface to handle the database so that I could change it in the future if needed without worrying about the code that has nothing to do with the database interaction. If I decide it's better to use PostgreSQL sometime down the line, I don't want to go through my code modifying each function that needs to get the information from a user.

So I created a trait that a database handler should implement

.rspub trait DBHandler: user::UserDb + user::UnconfirmedUserDb + post::PostDb {}

Here is one of the nice things about traits in Rust. I just created a trait that is "the sum" of other traits, that I have separated for modularity. A DBHandler must implement UserDb, UnconfirmedUserDb, and PostDb. Where, for instance, the PostDb trait is defined like this:

.rsuse crate::models::{Post, PostsQueryParams};

pub trait PostDb {
    async fn create_post(&self, post: &Post) -> Result<(), ()>;
    async fn update_post(&self, slug: &str, updated_content: &str) -> Result<u64, ()>;
    async fn delete_post(&self, slug: &str) -> Result<u64, ()>;
    async fn get_post(&self, slug: &str) -> Result<Option<Post>, ()>;
    async fn get_posts(&self, query: &PostsQueryParams) -> Result<Vec<Post>, ()>;
    async fn calculate_total_pages(&self, per_page: u64) -> Result<u64, ()>;
}

Then my functions that need to use the database are implemented like this:

.rspub async fn delete_post<T: DBHandler>(
    db_handler: web::Data<T>,
    claims: Claims,
    slug: web::Path<String>,
) -> impl Responder {
    if claims.role != "Admin" && claims.role != "Editor" {
        return HttpResponse::Unauthorized().finish();
    }

    if let Ok(deleted_count) = db_handler.delete_post(&slug).await {
        return HttpResponse::Ok().json(deleted_count);
    }

    HttpResponse::InternalServerError().finish()
}

where the function takes any type as long as it implements the DBHandler trait. This allows me to create custom implementations of the database handler for different databases, for instance:

.rs// Imports

#[derive(Clone)]
pub struct MongoDBHandler {
    pub db_client: Database,
    user_collection: mongodb::Collection<User>,
    unconfirmed_user_collection: mongodb::Collection<UnconfirmedUser>,
    post_collection: mongodb::Collection<Post>,
}

// struct specific implementations
impl MongoDBHandler {
    pub async fn new(database_url: &str, database: &str) -> Result<Self, Box<dyn Error>> {
        // Implementation
    }
}

// Implementation of DBHandler, it's empty because it just 
// requires the implementation of the other traits.
impl DBHandler for MongoDBHandler {}

// Implementation of the UserDb trait
impl UserDb for MongoDBHandler {
    async fn find_user(&self, username: &str) -> Result<Option<User>, ()> {
        self.user_collection
            .find_one(doc! {"username": username}, None)
            .await
            .or(Err(()))
    }

    async fn find_user_by_email(&self, email: &str) -> Result<Option<User>, ()> {
        self.user_collection
            .find_one(doc! {"email": email}, None)
            .await
            .or(Err(()))
    }

    async fn insert_user(&self, user: &User) -> Result<(), ()> {
        match self.user_collection.insert_one(user, None).await {
            Ok(_) => Ok(()),
            Err(_) => Err(()),
        }
    }
}

// The rest of the implementations

This is compile-time polymorphism, which takes advantage of the Rust's borrow checker.

If I decided to move to a different database I would only need to implement the traits and modify the main.rs so I use the new handler.

Metaprogramming with macros

Another great feature of Rust is its powerful metaprogramming capabilities with macros. Macros allow us to generate code programmatically, which can help us evade boilerplate, make our code DRY (Don't Repeat Yourself), and sometimes make it more concise and readable.

Fail-fast Config struct

In my code, I needed some configuration coming from the environment variables. If the variable is not defined it is an irrecoverable error and the program cannot continue. Therefore, instead of waiting for the variable to be needed by the code, I want the program to fail fast if any variable is not loaded when the program starts.

I created a struct for the configuration and loaded each variable, making sure to give an error message if it's not defined.

.rsstruct Config {
    JWT_SECRET,
}

impl Config {
    fn new() -> Self {
        Self {
            JWT_SECRET: std::env::var("JWT_SECRET").expect("Environment variable JWT_SECRET is required")
        }
    }
}

But each new variable added means repeating the line to load it, so, in order not to repeat myself, I created this macro to help me with the implementation:

.rs#[macro_export]
macro_rules! create_env_struct {
    ($struct_name:ident { $($field:ident),+ }) => {
        #[allow(non_snake_case)]
        #[derive(Clone)]
        struct $struct_name {
            $(pub $field: String,)+
        }

        impl $struct_name {
            fn new() -> Self {
                Self {
                    $($field: std::env::var(stringify!($field)).expect(&format!("Environment variable `{}` is required", stringify!($field))),)+
                }
            }
        }
    };
}

Then I can declare the Config struct like

.rscreate_env_struct! {
    Config {
        JWT_SECRET,
        DATABASE_URL,
        SMTP_SERVER,
        SMTP_USERNAME,
        SMTP_PASSWORD,
        NEW_USER_DEFAULT_ROLE,
        WEBSITE_URL,
        RSS_TITLE,
        RSS_DESCRIPTION
    }
}


#[actix_web::main]
async fn main() -> std::io::Result<()> {
    dotenv().ok(); // Load environment variables from .env file
    pretty_env_logger::init(); // Initialize logger

    let config = Config::new();

    // Rest of the code
}

Of course, it seems like more code than I would need to implement it by hand,

xkcd's automation meme: it takes more time to automate than the one the automation saves
Image from xkcd

but it comes with two main advantages: 1) Extensibility: just need to add the new variable's name when needed; and 2) Prevent copy/paste related bugs.