Book Image

Rust Web Development with Rocket

By : Karuna Murti
Book Image

Rust Web Development with Rocket

By: Karuna Murti

Overview of this book

Looking for a fast, powerful, and intuitive framework to build web applications? This Rust book will help you kickstart your web development journey and take your Rust programming skills to the next level as you uncover the power of Rocket - a fast, flexible, and fun framework powered by Rust. Rust Web Development with Rocket wastes no time in getting you up to speed with what Rust is and how to use it. You’ll discover what makes it so productive and reliable, eventually mastering all of the concepts you need to play with the Rocket framework while developing a wide set of web development skills. Throughout this book, you'll be able to walk through a hands-on project, covering everything that goes into making advanced web applications, and get to grips with the ins and outs of Rocket development, including error handling, Rust vectors, and wrappers. You'll also learn how to use synchronous and asynchronous programming to improve application performance and make processing user content easy. By the end of the book, you'll have answers to all your questions about creating a web application using the Rust language and the Rocket web framework.
Table of Contents (20 chapters)
1
Part 1: An Introduction to the Rust Programming Language and the Rocket Web Framework
7
Part 2: An In-Depth Look at Rocket Web Application Development
14
Part 3: Finishing the Rust Web Application Development

Packages and Cargo

Now that we know how to create a simple program in Rust, let's explore Cargo, the Rust package manager. Cargo is a command-line application that manages your application dependencies and compiles your code.

Rust has a community package registry at https://crates.io. You can use that website to search for a library that you can use in your application. Don't forget to check the license of the library or application that you want to use. If you register on that website, you can use Cargo to publicly distribute your library or binary.

How do we install Cargo into our system? The good news is Cargo is already installed if you install the Rust toolchain in the stable channel using rustup.

Cargo package layout

Let's try using Cargo in our application. First, let's copy the application that we wrote earlier:

cp -r 02ComplexProgram  03Packages
cd 03Packages
cargo init . --name our_package

Since we already have an existing application, we can initialize our existing application with cargo init. Notice we add the --name option because we are prefixing our folder name with a number, and a Rust package name cannot start with a number.

If we are creating a new application, we can use the cargo new package_name command. To create a library-only package instead of a binary package, you can pass the --lib option to cargo new.

You will see two new files, Cargo.toml and Cargo.lock, inside the folder. The .toml file is a file format commonly used as a configuration file. The lock file is generated automatically by Cargo, and we don't usually change the content manually. It's also common to add Cargo.lock to your source code versioning application ignore list, such as .gitignore, for example.

Let's check the content of the Cargo.toml file:

[package]
name = "our_package"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at
https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
[[bin]]
name = "our_package"
path = "main.rs"

As you can see, we can define basic things for our application such as name and version. We can also add important information such as authors, homepage, repository, and much more. We can also add dependencies that we want to use in the Cargo application.

One thing that stands out is the edition configuration. The Rust edition is an optional marker to group various Rust language releases that have the same compatibility. When Rust 1.0 was released, the compiler did not have the capability to know the async and await keywords. After async and await were added, it created all sorts of problems with older compilers. The solution to that problem was to introduce Rust editions. Three editions have been defined: 2015, 2018, and 2021.

Right now, the Rust compiler can compile our package perfectly fine, but it is not very idiomatic because a Cargo project has conventions on file and folder names and structures. Let's change the files and directory structure a little bit:

  1. A package is expected to reside in the src directory. Let's change the Cargo.toml file [[bin]] path from "main.rs" to "src/main.rs".
  2. Create the src directory inside our application folder. Then, move the main.rs file and the encryptor folder to the src folder.
  3. Add these lines to Cargo.toml after [[bin]]:
    [lib]
    name = "our_package"
    path = "src/lib.rs"
  4. Let's create the src/lib.rs file and move this line from src/main.rs to src/lib.rs:
    pub mod encryptor;
  5. We can then simplify using both the rot13 and Encryptable modules in our main.rs file:
    use our_package::encryptor::{rot13, Encryptable};
    use std::io;
    fn main() {
        ...
        println!(
            "Your encrypted string: {}",
            rot13::Rot13(user_input).encrypt()
        );
    }
  6. We can check whether there's an error that prevents the code from being compiled by typing cargo check in the command line. It should produce something like this:
    > cargo check
        Checking our_package v0.1.0 
        (/Users/karuna/Chapter01/03Packages)
        Finished dev [unoptimized + debuginfo] target(s) 
        in 1.01s
  7. After that, we can build the binary using the cargo build command. Since we didn't specify any option in our command, the default binary should be unoptimized and contain debugging symbols. The default location for the generated binary is in the target folder at the root of the workspace:
    $ cargo build
       Compiling our_package v0.1.0 
       (/Users/karuna/Chapter01/03Packages)
        Finished dev [unoptimized + debuginfo] target(s) 
        in 5.09s

You can then run the binary in the target folder as follows:

./target/debug/our_package

debug is enabled by the default dev profile, and our_package is the name that we specify in Cargo.toml.

If you want to create a release binary, you can specify the --release option, cargo build --release. You can find the release binary in ./target/release/our_package.

You can also type cargo run, which will compile and run the application for you.

Now that we have arranged our application structure, let's add real-world encryption to our application by using a third-party crate.

Using third-party crates

Before we implement another encryptor using a third-party module, let's modify our application a little bit. Copy the previous 03Packages folder to the new folder, 04Crates, and use the folder for the following steps:

  1. We will rename our Encryptor trait as a Cipher trait and modify the functions. The reason is that we only need to think about the output of the type, not the encrypt process itself:
    • Let's change the content of src/lib.rs to pub mod cipher;.
    • After that, rename the encryptor folder as cipher.
    • Then, modify the Encryptable trait into the following:
      pub trait Cipher {
          fn original_string(&self) -> String;
          fn encrypted_string(&self) -> String;
      }

The reality is we only need functions to show the original string and the encrypted string. We don't need to expose the encryption in the type itself.

  1. After that, let's also change src/cipher/rot13.rs to use the renamed trait:
    impl super::Cipher for Rot13 {
        fn original_string(&self) -> String {
            String::from(&self.0)
        }
        fn encrypted_string(&self) -> String {
            self.0
                .chars()
                .map(|ch| match ch {
                    'a'..='m' | 'A'..='M' => (ch as u8 + 
                    13) as char,
                    'n'..='z' | 'N'..='Z' => (ch as u8 – 
                    13) as char,
                    _ => ch,
                })
                .collect()
        }
    }
  2. Let's also modify main.rs to use the new trait and function:
    use our_package::cipher::{rot13, Cipher};
    …
    fn main() {
        …
        println!(
            "Your encrypted string: {}",
            rot13::Rot13(user_input).encrypted_string()
        );
    }

The next step is to determine what encryption and library we want to use for our new type. We can go to https://crates.io and search for an available crate. After searching for a real-world encryption algorithm on the website, we found https://crates.io/crates/rsa. We found that the RSA algorithm is a secure algorithm, the crate has good documentation and has been audited by security researchers, the license is compatible with what we need, and there's a huge number of downloads. Aside from checking the source code of this library, all indications show that this is a good crate to use. Luckily, there's an install section on the right side of that page. Besides the rsa crate, we are also going to use the rand crate, since the RSA algorithm requires a random number generator. Since the generated encryption is in bytes, we must encode it somehow to string. One of the common ways is to use base64.

  1. Add these lines in our Cargo.toml file, under the [dependencies] section:
    rsa = "0.5.0"
    rand = "0.8.4"
    base64 = "0.13.0"
  2. The next step should be adding a new module and typing using the rsa crate. But, for this type, we want to modify it a little bit. First, we want to create an associated function, which might be called a constructor in other languages. We want to then encrypt the input string in this function and store the encrypted string in a field. There's a saying that all data not in processing should be encrypted by default, but the fact is that we as programmers rarely do this.

Since RSA encryption is dealing with byte manipulation, there's a possibility of errors, so the return value of the associated function should be wrapped in the Result type. There's no compiler rule, but if a function cannot fail, the return should be straightforward. Regardless of whether or not a function can produce a result, the return value should be Option, but if a function can produce an error, it's better to use Result.

The encrypted_string() method should return the stored encrypted string, and the original_string() method should decrypt the stored string and return the plain text.

In src/cipher/mod.rs, change the code to the following:

pub trait Cipher {
    fn original_string(&self) -> Result<String, 
    Box<dyn Error>>;
    fn encrypted_string(&self) -> Result<String, 
    Box<dyn Error>>;
}
  1. Since we changed the definition of the trait, we have to change the code in src/cipher/rot13.rs as well. Change the code to the following:
    use std::error::Error;
    pub struct Rot13(pub String);
    impl super::Cipher for Rot13 {
        fn original_string(&self) -> Result<String, 
        Box<dyn Error>> {
            Ok(String::from(&self.0))
        }
        fn encrypted_string(&self) -> Result<String, 
        Box<dyn Error>> {
            Ok(self
                .0
                ...
                .collect())
        }
    }
  2. Let's add the following line in the src/cipher/mod.rs file:
    pub mod rsa;
  3. After that, create rsa.rs inside the cipher folder and create the Rsa struct inside it. Notice that we use Rsa instead of RSA as the type name. The convention is to use CamelCase for type:
    use std::error::Error;
    pub struct Rsa {
        data: String,
    }
    impl Rsa {
        pub fn new(input: String) -> Result<Self, Box<
        dyn Error>> {
            unimplemented!();
        }
    }
    impl super::Cipher for Rsa {
        fn original_string(&self) -> Result<String, ()> {
           unimplemented!();
        }
        fn encrypted_string(&self) -> Result<String, ()> {
            Ok(String::from(&self.data))
        }
    }

There are a couple of things we can observe. The first one is the data field does not have the pub keyword since we want to make it private. You can see that we have two impl blocks: one is for defining the methods of the Rsa type itself, and the other is for implementing the Cipher trait.

Also, the new() function does not have self, mut self, &self, or &mut self as the first parameter. Consider it as a static method in other languages. This method is returning Result, which is either Ok(Self) or Box<dyn Error>. The Self instance is the instance of the Rsa struct, but we'll discuss Box<dyn Error> later when we talk about error handling in Chapter 7, Handling Errors in Rust and Rocket. Right now, we haven't implemented this method, hence the usage of the unimplemented!() macro. Macros in Rust look like a function but with an extra bang (!).

  1. Now, let's implement the associated function. Modify src/cipher/rsa.rs:
    use rand::rngs::OsRng;
    use rsa::{PaddingScheme, PublicKey, RsaPrivateKey};
    use std::error::Error;
    const KEY_SIZE: usize = 2048;
    pub struct Rsa {
        data: String,
        private_key: RsaPrivateKey,
    }
    impl Rsa {
         pub fn new(input: String) -> Result<Self, Box<
        dyn Error>> {
            let mut rng = OsRng;
            let private_key = RsaPrivateKey::new(&mut rng, 
            KEY_SIZE)?;
            let public_key = private_key.to_public_key();
            let input_bytes = input.as_bytes();
            let encrypted_data =
                public_key.encrypt(&mut rng, PaddingScheme
                ::new_pkcs1v15_encrypt(), input_bytes)?;
            let encoded_data = 
            base64::encode(encrypted_data);
            Ok(Self {
                data: encoded_data,
                private_key,
            })
        }
    }

The first thing we do is declare the various types we are going to use. After that, we define a constant to denote what size key we are going to use.

If you understand the RSA algorithm, you already know that it's an asymmetric algorithm, meaning we have two keys: a public key and a private key. We use the public key to encrypt data and use the private key to decrypt the data. We can generate and give the public key to the other party, but we don't want to give the private key to the other party. That means we must store the private key inside the struct as well.

The new() implementation is pretty straightforward. The first thing we do is declare a random number generator, rng. We then generate the RSA private key. But, pay attention to the question mark operator (?) on the initialization of the private key. If a function returns Result, we can quickly return the error generated by calling any method or function inside it by using (?) after that function.

Then, we generate the RSA public key from a private key, encode the input string as bytes, and encrypt the data. Since encrypting the data might have resulted in an error, we use the question mark operator again. We then encode the encrypted bytes as a base64 string and initialize Self, which means the Rsa struct itself.

  1. Now, let's implement the original_string() method. We should do the opposite of what we do when we create the struct:
    fn original_string(&self) -> Result<String, Box<dyn Error>> {
        let decoded_data = base64::decode(&self.data)?;
        let decrypted_data = self
            .private_key
            .decrypt(PaddingScheme::
            new_pkcs1v15_encrypt(), &decoded_data)?;
        Ok(String::from_utf8(decrypted_data)?)
    }

First, we decode the base64 encoded string in the data field. Then, we decrypt the decoded bytes and convert them back to a string.

  1. Now that we have finished our Rsa type, let's use it in our main.rs file:
    fn main() {
        ...
        println!(
            "Your encrypted string: {}",
            rot13::Rot13(user_input).encrypted_
            string().unwrap()
        );
        println!("Input the string you want to encrypt:");
        let mut user_input = String::new();
        io::stdin()
            .read_line(&mut user_input)
            .expect("Cannot read input");
        let encrypted_input = rsa::Rsa::new(
        user_input).expect("");
        let encrypted_string = encrypted_input.encrypted_
        string().expect("");
        println!("Your encrypted string: {}", 
        encrypted_string);
        let decrypted_string = encrypted_input
        .original_string().expect("");
        println!("Your original string: {}", 
        decrypted_string);
    }

Some of you might wonder why we redeclared the user_input variable. The simple explanation is that Rust already moved the resource to the new Rot13 type, and Rust does not allow the reuse of the moved value. You can try commenting on the second variable declaration and compile the application to see the explanation. We will discuss the Rust borrow checker and moving in more detail in Chapter 9, Displaying Users' Post.

Now, try running the program by typing cargo run:

$ cargo run
   Compiling cfg-if v1.0.0
   Compiling subtle v2.4.1
   Compiling const-oid v0.6.0
   Compiling ppv-lite86 v0.2.10
   ...
   Compiling our_package v0.1.0 
   (/Users/karuna//Chapter01/04Crates)
    Finished dev [unoptimized + debuginfo] target(s) 
    in 3.17s
     Running `target/debug/our_package`
Input the string you want to encrypt:
first
Your encrypted string: svefg
Input the string you want to encrypt:
second
Your encrypted string: lhhb9RvG9zI75U2VC3FxvfUujw0cVqqZFgPXhNixQTF7RoVBEJh2inn7sEefDB7eNlQcf09lD2nULfgc2mK55ZE+UUcYzbMDu45oTaPiDPog4L6FRVpbQR27bkOj9Bq1KS+QAvRtxtTbTa1L5/OigZbqBc2QOm2yHLCimMPeZKhLBtK2whhtzIDM8l5AYTBg+rA688ZfB7ZI4FSRm4/h22kNzSPo1DECI04ZBprAq4hWHxEKRwtn5TkRLhClGFLSYKkY7Ajjr3EOf4QfkUvFFhZ0qRDndPI5c9RecavofVLxECrYfv5ygYRmW3B1cJn4vcBhVKfQF0JQ+vs+FuTUpw==
Your original string: second

You will see that Cargo automatically downloaded the dependencies and builds them one by one. Also, you might notice that encrypting using the Rsa type took a while. Isn't Rust supposed to be a fast system language? The RSA algorithm itself is a slow algorithm, but that's not the real cause of the slowness. Because we are running the program in a development profile, the Rust compiler generates an application binary with all the debugging information and does not optimize the resulting binary. On the other hand, if you build the application using the --release flag, the compiler generates an optimized application binary and strips the debugging symbols. The resulting binary compiled with the release flag should execute faster than the debug binary. Try doing it yourself so you'll remember how to build a release binary.

In this section, we have learned about Cargo and third-party packages, so next, let's find out where to find help and documentation for the tools that we have used.