Book Image

Rust Web Programming - Second Edition

By : Maxwell Flitton
Book Image

Rust Web Programming - Second Edition

By: Maxwell Flitton

Overview of this book

Are safety and high performance a big concern for you while developing web applications? With this practical Rust book, you’ll discover how you can implement Rust on the web to achieve the desired performance and security as you learn techniques and tooling to build fully operational web apps. In this second edition, you’ll get hands-on with implementing emerging Rust web frameworks, including Actix, Rocket, and Hyper. It also features HTTPS configuration on AWS when deploying a web application and introduces you to Terraform for automating the building of web infrastructure on AWS. What’s more, this edition also covers advanced async topics. Built on the Tokio async runtime, this explores TCP and framing, implementing async systems with the actor framework, and queuing tasks on Redis to be consumed by a number of worker nodes. Finally, you’ll go over best practices for packaging Rust servers in distroless Rust Docker images with database drivers, so your servers are a total size of 50Mb each. By the end of this book, you’ll have confidence in your skills to build robust, functional, and scalable web applications from scratch.
Table of Contents (27 chapters)
Free Chapter
1
Part 1:Getting Started with Rust Web Development
4
Part 2:Processing Data and Managing Displays
8
Part 3:Data Persistence
12
Part 4:Testing and Deployment
16
Part 5:Making Our Projects Flexible
19
Part 6:Exploring Protocol Programming and Async Concepts with Low-Level Network Applications

Building structs

In modern high-level dynamic languages, objects have been the bedrock for building big applications and solving complex problems, and for good reason. Objects enable us to encapsulate data, functionality, and behavior. In Rust, we do not have objects. However, we do have structs that can hold data in fields. We can then manage the functionality of these structs and group them together with traits. This is a powerful approach, and it gives us the benefits of objects without the high coupling, as highlighted in the following figure:

Figure 1.10 – Difference between Rust structs and objects

Figure 1.10 – Difference between Rust structs and objects

We will start with something basic by creating a Human struct with the following code:

#[derive(Debug)]
struct Human<'a> {
    name: &'a str,
    age: i8,
    current_thought: &'a str
}

In the preceding code, we can see that our string literal fields have the same lifetime as the struct itself. We have also applied the Debug trait to the Human struct, so we can print it out and see everything. We can then create the Human struct and print the struct out using the following code:

fn main() {
    let developer = Human{
        name: "Maxwell Flitton",
        age: 32,
        current_thought: "nothing"
    };
    println!("{:?}", developer);
    println!("{}", developer.name);
}

Running the preceding code would give us the following printout:

Human { name: "Maxwell Flitton", age: 32, current_thought:    "nothing" }
Maxwell Flitton

We can see that our fields are what we expect. However, we can change our string slice fields to strings to get rid of lifetime parameters. We may also want to add another field where we can reference another Human struct under a friend field. However, we may also have no friends. We can account for this by creating an enum that is either a friend or not and assigning this to a friend field, as seen in the following code:

#[derive(Debug)]
enum Friend {
    HUMAN(Human),
    NIL
}
#[derive(Debug)]
struct Human {
    name: String,
    age: i8,
    current_thought: String,
    friend: Friend
}

We can then define the Human struct initially with no friends just to see if it works with the following code:

    let developer = Human{
        name: "Maxwell Flitton".to_string(),
        age: 32,
        current_thought: "nothing".to_string(),
        friend: Friend::NIL
    };

However, when we run the compiler, it does not work. I would like to think this is because the compiler cannot believe that I have no friends. But alas, it’s to do with the compiler not knowing how much memory to allocate for this declaration. This is shown through the following error code:

enum Friend {    HUMAN(Human),    NIL}#[derive(Debug)]
^^^^^^^^^^^            ----- recursive without indirection
|
recursive type has infinite size

Because of the enum, theoretically, the memory needed to store this variable could be infinite. One Human struct could reference another Human struct as a friend field, which could in turn reference another Human struct, resulting in a potentially infinite number of Human structs being linked together through the friend field. We can solve this problem with pointers. Instead of storing all the data of a Human struct in the friend field, we store a memory address that we know has a maximum value because it’s a standard integer. This memory address points to where another Human struct is stored in the memory. As a result, the program knows exactly how much memory to allocate when it crosses a Human struct, irrespective of whether the Human struct has a friend field or not. This can be achieved by using a Box struct, which is essentially a smart pointer for our enum, with the following code:

#[derive(Debug)]
enum Friend {
    HUMAN(Box<Human>),
    NIL
}

So, now our enum states whether the friend exists or not, and if so, it has a memory address if we need to extract information about this friend. We can achieve this with the following code:

fn main() {
    let another_developer = Human{
        name: "Caroline Morton".to_string(),
        age:30,
        current_thought: "I need to code!!".to_string(),
        friend: Friend::NIL
    };
    let developer = Human{
        name: "Maxwell Flitton".to_string(),
        age: 32,
        current_thought: "nothing".to_string(),
        friend: Friend::HUMAN(Box::new(another_developer))
    };
    match &developer.friend {
        Friend::HUMAN(data) => {
            println!("{}", data.name);
        },
        Friend::NIL => {}
    }
}

In the preceding code, we can see that we have created one Human struct, and then another Human struct with a reference to the first Human struct as a friend field. We then access the second Human struct’s friend through the friend field. Remember, we must handle both possibilities as it could be a nil value.

While it is exciting that friends can be made, if we take a step back, we can see that there is a lot of code written for each human we create. This is not helpful if we must create a lot of humans in a program. We can reduce this by implementing some functionality for our struct. We will essentially create a constructor for the struct with extra functions, so we can add optional values if we want. We will also make the thought field optional. So, a basic struct with a constructor populating only the most essential fields can be achieved with the following code:

#[derive(Debug)]
struct Human {
    name: String,
    age: i8,
    current_thought: Option<String>,
    friend: Friend
}
impl Human {    
    fn new(name: &str, age: i8) -> Human {
        return Human{
            name: name.to_string(),
            age: age,
            current_thought: None,
            friend: Friend::NIL
        }
    }
}

Therefore, creating a new human now only takes the following line of code:

let developer = Human::new("Maxwell Flitton", 32);

This will have the following field values:

  • Name: "Maxwell Flitton"
  • Age: 32
  • Current Thought: None
  • Friend: NIL

We can add more functions in the implement block for adding friends and a current thought with the following code:

    fn with_thought(mut self, thought: &str) -> Human {
        self.current_thought = Some(thought.to_string());
        return self
    }
    fn with_friend(mut self, friend: Box<Human>) -> Human {
        self.friend = Friend::HUMAN(friend);
        return self
    }

In the preceding code, we can see that we pass in a mutable version of the struct that is calling these functions. These functions can be chained because they return the struct that called them. If we want to create a developer with a thought, we can do this with the following code:

let developer = Human::new("Maxwell Flitton", 32)
    .with_thought("I love Rust!");

We must note that a function that does not require self as a parameter can be called with ::, while a function that does require self as a parameter can be called with a simple dot (.). If we want to create a developer with a friend, it can be done using the following code:

let developer_friend = Human::new("Caroline Morton", 30);
let developer = Human::new("Maxwell Flitton", 32)
    .with_thought("I love Rust!")
    .with_friend(Box::new(developer_friend));
Println!("{:?}", developer);

Running the code will result in the following parameters for developer:

Name: "Maxwell Flitton"
Age: 32
Current Thought: Some("I love Rust!")
Friend: HUMAN(Human { name: "Caroline Morton", age: 30, 
    current_thought: None, friend: NIL })

We can see that structs combined with enums and functions that have been implemented with these structs can be powerful building blocks. We can define fields and functionality with only a small amount of code if we have defined our structs well. However, writing the same functionality for multiple structs can be time-consuming and result in a lot of repeated code. If you have worked with objects before, you may have utilized inheritance for that. Rust goes one better. It has traits, which we will explore in the next section.

Verifying with traits

We can see enums can empower structs so that they can handle multiple types. This can also be translated for any type of function or data structure. However, this can lead to a lot of repetition. Take, for instance, a User struct. Users have a core set of values, such as a username and password. However, they could also have extra functionality based on roles. With users, we must check roles before firing certain processes. We can wrap up structs with traits by creating a simple toy program that defines users and their roles with the following steps:

  1. We can define our users with the following code:
    struct AdminUser {
        username: String,
        password: String
    }
    struct User {
        username: String,
        password: String
    }

We can see in the preceding code that the User and AdminUser structs have the same fields. For this exercise, we merely need two different structs to demonstrate the effect traits have on them. Now that our structs are defined, we can move on to our next step, which is creating the traits.

  1. We will be implementing these traits in our structs. The total traits that we will have are, comprise create, edit, and delete. We will be using them to assign permissions to our users. We can create these three traits with the following code:
    trait CanEdit {
        fn edit(&self) {
            println!("admin is editing");
        }
    }
    trait CanCreate {
        fn create(&self) {
            println!("admin is creating");
        }
    }
    trait CanDelete {
        fn delete(&self) {
            println!("admin is deleting");
        }
    }

We can see that the functions for the traits only take in self. We cannot make any references to the fields in the functions to self as we do not know what structs will be implemented. However, we can override functions when we implement the trait to the struct if needed. If we are to return self, we will need to wrap it in a Box struct, as the compiler will not know the size of the struct being returned. We also must note that the signature of the function (input parameters and return values) must be the same as the original if we overwrite the function for a struct. Now that we have defined the traits, we can move on to the next step of implementing the traits to define roles for our user.

  1. With our roles, we can make our admin have every permission and our user only the edit permission. This can be achieved with the following code:
    impl CanDelete for AdminUser {}
    impl CanCreate for AdminUser {}
    impl CanEdit for AdminUser {}
    impl CanEdit for User {
        fn edit(&self) {
            println!("A standard user {} is editing", 
        self.username);
        }
    }

From our previous step, we can remember that all the functions already worked for the admin by printing out that the admin is doing the action. Therefore, we do not have to do anything for the implementation of the traits for the admin. We can also see that we can implement multiple traits for a single struct. This adds a lot of flexibility. In our user implementation of the CanEdit trait, we have overwritten the edit function so that we can have the correct statement printed out. Now that we have implemented the traits, our user structs have permission in the code to enter scopes that require those traits. We can now build the functions for using these traits in the next step.

  1. We could utilize the functions in the traits by directly running them in the main function on the structs that have implemented them. However, if we do this, we will not see their true power in this exercise. We may also want this standard functionality throughout our program in the future when we span multiple files. The following code shows how we create functions that utilize the traits:
    fn create<T: CanCreate>(user: &T) -> () {
        user.create();
    }
    fn edit<T: CanEdit>(user: &T) -> () {
        user.edit();
    }
    fn delete<T: CanDelete>(user: &T) -> () {
        user.delete();
    }

The preceding notation is fairly like the lifetime annotation. We use angle brackets before the input definitions to define the trait we want to accept at T. We then state that we will accept a borrowed struct that has implemented the trait as &T. This means that any struct that implements that specific trait can pass through the function. Because we know what the trait can do, we can then use the trait functions. However, because we do not know what struct is going to be passed through, we cannot utilize specific fields. But remember, we can overwrite a trait function to utilize struct fields when we implement the trait for the struct. This might seem rigid, but the process enforces good, isolated, decoupled coding that is safe. For instance, let’s say we remove a function from a trait or remove a trait from a struct. The compiler would refuse to compile until all the effects of this change were complete. Thus, we can see that, especially for big systems, Rust is safe, and can save time by reducing the risk of silent bugs. Now that we have defined the functions, we can use them in the main function in the next step.  

  1. We can test to see whether all the traits work with the following code:
    fn main() {
        let admin = AdminUser{
            username: "admin".to_string(), 
            password: "password".to_string()
        };
        let user = User{
            username: "user".to_string(), 
            password: "password".to_string()
        };
        create(&admin);
        edit(&admin);
        edit(&user);
        delete(&admin);
    }

We can see that the functions that accept traits are used just like any other function.

Running the entire program will give us the following printout:

admin is creating
admin is editing
A standard user user is editing
admin is deleting

In our output, we can see that the overriding of the edit function for the User struct works.

We have now learned enough about traits to be productive with web development. Traits get even more powerful, and we will be using them for some key parts of our web programming. For instance, several web frameworks have traits that execute before the request is processed by the view/API endpoint. Implementing structs with these traits automatically loads the view function with the result of the trait function. This can be database connections, extraction of tokens from headers, or anything else we wish to work with. There is also one last concept that we need to tackle before we move on to the next chapter, and that is macros.