A short trip into the fascinating world of writing high-level software with a low level language.

I am, by no means, a Rust expert. I started learning Rust on and off back in summer 2021 during a 90-minute bus commute to work (three-hour round trip!) when I read the Rust book. I tried to do some Advent of Code here, some exercises there.. To be fair, I didn't even finish the book! I should give it another try...

Anyway! I've been thinking about building a small web project and, as it is customary, I decided to write the first iteration in Python. Python's great for things as short-lived as quick mocks and as permanent as small, feature complete programs. I've written my share of Python and it's a language I love (don't tell Lua). The general idea is that I first write a rough sketch of what I want to do in Python and, if I'm satisfied, I move the project to another language.

However, a bug bit me this week. I was going through the No Boilerplate YouTube channel and took interest in their videos about Rust. There, the author made the most remarkable assertion: "You can write anything in Rust, just watch me do it". The videos give some pointers (pun intended, did you get the reference?) to tools for developing Rust in different scenarios and, yesterday night, I managed to write a small web server in pure Rust.

In this entry, I want to give you a tour of what I went through and how it works. Not that this is not a Rust tutorial and I am, by no means, a Rust expert. This is a recollection of my programming process yesterday night that I want to put into writing lest I forget it. This is an introduction to writing a simple Rust web service the same way I figured out twelve hours ago. I'm going to give insight into what's going on in every line because I'm sure there are better ways to do some things, but I found a way to do them that worked for me at the moment.

If you're happy with these assumptions, let's start by taking a look at...

Serving a static page with Rocket

Oh, no, this is one of those "import a thousand dependencies and everything works by act of pure will" posts.

Well, yes and no. I'm not going to teach you how to write a web server from scratch, that job is already solved by multiple libraries. What I want to show you is how to use one of those libraries to start the server with ease while explaining how things work under the hood. I'm going to assume that you don't know any Rust but you are familiar with general programming concepts, such as OOP and generics, the basics of which I wrote a post about explaining how they work in C++ under the name of templates. I also assume that you have Rust installed in your system. I'm not going to tell you how, because I respect that you've done it as expected by your system's best practices.

Note that this section is an abridged version of Rocket's getting started guide. Refer to it and the rest of the documentation if you want to dive deeper into this entry's concepts.

Let's first start by creating a new Rust program. Open your terminal, accommodate yourself into your most desired directory and let the words flow from your voice:

cargo new myweb

You'll find a new myweb directory has been brought to life, which you can cd into. The program contains just two files:

  • Cargo.toml: A file that describes to Cargo, Rust's build tool, what's needed to build your program.
  • src/main.rs: A simple Hello, world file that you can build and run.

In fact, let's run the program:

cargo run # Use `cargo build` if you don't want to run after building

This will output a couple of build messages and run the program, printing Hello, world!.

Now that we're sure that Rust's build system is working fine for us, let's tell Cargo that we want to install the Rocket crate, the official documentation for whom you can find in https://rocket.rs/. A crate is the name Rust gives to its packages; you'll find a lot of industrial references in Rust's lingo. To install Rocket for our project, we want to edit the build configuration:

Cargo.toml

[package]
name = "myweb"
version = "0.1.0"
edition = "2021"

[dependencies]
rocket = "0.5.1"

If you edited this file without closing your editor and you're running an LSP client, you'll be delighted to find that Cargo has fetched (but not built) the necessary files for you in the background. This ensures that, when you write code that uses the rocket crate, your editor won't report an error as it's now capable of finding the corresponding source. Neat!

Now, we want to edit the main.rs file (the only source file we'll be working with today) to set up a simple Hello world server. If you've used Flask before, you'll be surprisingly familiar with this code:

src/main.rs

#[macro_use] // (1)
extern crate rocket;

#[get("/")] // (3)
fn index() -> &'static str { // (2)
    return "Hello, world!";
}

#[launch] // (4)
fn rocket() -> _ {
    rocket::build().mount("/", routes![index]) // (5)
}

Let's go over this code quickly.

First, there's the macro_use (1) statement, that imports the macros from the rocket crate to the current scope. These are get, launch and routes!, which we'll see in a moment. The #[...] syntax encloses attributes. Specifically, get and launch are attribute-like macros, which read the piece of code they're attached to in order to prepend or append more code to it or even modify what you've written!

Next, we have the index function, which returns a string slice with a static lifetime specifier (&'static str) (2). In short, this means that we're returning a string literal that's defined inside the function. The &'static annotation is required because Rust expects us to be extremely explicit with the way our code behaves. This function is accompanied by macro #[get("/")] (3), which tells Rocket that we want to run this function when the user opens your website's index page.

Finally, we substituted the main function with one named rocket accompanied by the #[launch] macro (4), which creates the main function for us, letting us just focus on starting the Rocket engine. To do so, we call rocket::build() and, since it returns a reference to the Rocket<Build> object, directly call its mount function to tell the engine that we want to register the index function to listen in the "/" namespace (5).

The namespace is a quick way of telling the engine that we want a certain path to precede the route we annotated the function with. That is, if the namespace was "/something/", we would be calling the index function when the user visited http://address/something/ instead of http://address/, which is very useful for module organisation. Lastly, the routes! macro (note that non-attribute macros end with a bang) does the job for us to find the index function and bind it to the server's list of response hooks.

That's it for a static page! When you run cargo run, you'll see that Rocket prints some useful information and the address your server is listening to, which should be http://127.0.0.1:8000. Try it out! You should see that it prints Hello, world!.

Working with HTML templates

Since index returns "Hello, world!" and that displays it in our browser, we could just write our plain HTML in Rust strings, but I'd rather grind runecraft for weeks. We could also use a crate like render to embed JSX into our code, but we're going to go with a more familiar approach: HTML templates.

First, we need a couple of crates:

  • rocket_dyn_templates: This crate works with Rocket to read an HTML template and fill it with the data we want to display.
  • serde: A framework for serialising and deserialising data. It's one of the most downloaded crates of all.

To install both, we're going to edit our build configuration:

Cargo.toml

[package]
name = "myweb"
version = "0.1.0"
edition = "2021"

[dependencies]
rocket = "0.5.1"
serde = "1.0.129"

[dependencies.rocket_dyn_templates]
version = "0.2.0"
features = ["tera"]

Note that we have a [dependencies] table for dependencies that just require their version and a different table for all the config we want for the rocket_dyn_templates dependency. In this case, we want to tell this crate to use the Tera engine to build and parse our templates.

Now, we can write a couple of templates. In reality, what we're writing next could be done in just one file, but I think it exemplifies well the usefulness of templates if you haven't used them before. Note that the path to the files stem from the program's root directory, not from src/.

templates/base.html.tera

<!DOCTYPE html>
<html>
<head>
    <title>My web</title>
    <link rel="stylesheet" href="/static/style/main.css">
</head>
<body>
    <h1>My web</h1>
    {% block body %}{% endblock %}
</body>
</html>

<!-- vim:ft=html -->

As you see, we use the .html.tera extension to tell Rocket that we're using Tera as our templating engine. For that matter, we add <!-- vim:ft=html --> to the end of the file to let Vim (and any other editor that understands this directive) know that we want to set the file type (ft) to html. Cool beans!

There isn't much to look for here. We've declared a <body> tag and a template {% block body %} that we can fill later on with whatever we like. In any case, whatever we add will appear after a level-1 heading reading "My web". Note that we haven't written and are not serving the main.css file. This won't result in a crash and we'll get on to it in the next section.

templates/index.html.tera

{% extends "base" %}

{% block body %}
    <form action="/" method="post" enctype="multipart/form-data">
        <h2>Select an option</h2>

        {% for o in options %}
            <input type="radio" id="{{ o.id }}" name="id" value="{{ o.id }}">
            <label for="{{ o.id }}">{{ o.name }} ({{ o.votes }} votes)</label>
            <br>
        {% endfor %}

        <br>
        <input type="submit" value="Submit" class="is-full-width" />
    </form>
{% endblock %}

<!-- vim:ft=html -->

This one's a bit more involved. We're extending the base template we wrote before so we can define our own body block. There, we're adding a simple form and iterating through a list of options to dynamically display the form. As you can see, every option object must have the fields id, name and votes. We'll see how to define that in a moment.

The form doesn't do anything yet, as we haven't defined a way to process its POST request. This will be the very last thing we do, but we're adding the whole file here because it's less convoluted and Rocket won't crash if the user asks it to do something we haven't told it how. Submitting the form will just redirect us to a 404 page.

So, how do we make this work in code?

src/main.rs

#[macro_use]
extern crate rocket;

use rocket_dyn_templates::{context, Template}; // (1)

#[derive(rocket::serde::Serialize)] // (2)
struct VoteOption {
    id: i64,
    name: String,
    votes: i64,
}

#[get("/")]
async fn index() -> Template { // (3))
    Template::render(
        "index",
        context! { // (4))
            options: [
                VoteOption { id: 1, name: String::from("Option 1"), votes: 0 }, // (5))
                VoteOption { id: 2, name: String::from("Option 2"), votes: 0 }
            ]
        },
    )
}

#[launch]
fn rocket() -> _ {
    rocket::build()
        .mount("/", routes![index])
        .attach(Template::fairing()) // (6))
}

First, we are importing the context macro and the Template struct from rocket_dyn_templates (1). Note that we're using the use keyword to explicitly import the two symbols. The ::{...} syntax lets us import more than one symbol from the same module.

Then, we define the struct VoteOption, which contains the id, name and votes fields that we expect in the template. Note that we're annotating it with #[derive(rocket::serde::Serialize)] (2), which automatically adds the necessary code to serialise an instance of the struct into a structure usable by the templating engine. This is still low-level code, It's just that serde is taking care of writing it for us!

Our index function now returns a Template object (3), which is filled with the Template::render function. Note that we're not using the return keyword and that there call to render isn't postceded by a semicolon. In Rust, semicolons are used to define statements and their absence denotes expressions. As in lisp, the last expression in a function is returned, so just calling Template::render as an expression will return its value.

This function takes the name of the template we want to use ("index") and a context, which is a serialisable structure with the data we want to build the template with. The context! macro (4) handles creating the object for us, so we can focus on defining it. This object contains an array of VoteOption called options, which we define in place by constructing the objects in sequence. Rust doesn't have constructors like C++, so we just instantiate the objects with designated initialisers for every field (5).

This context object will be passed to the templating engine every time a request is performed so that it can build and display the page with the data we're passing to it. If you go back to the template, you should be able to reason that the page will contain two radio buttons for two options called "Option 1" and "Option 2", both with zero votes.

There's one last step we need to take care of before running the server. In the rocket function, we need to call .attach(Template::fairing()) to, well, attach the Template fairing to the Rocket engine (6). I didn't get into fairings much when I wrote this, but my understanding is that they are building blocks you can use to extend and modify the way the engine performs some tasks. In this case, attaching the template fairing lets use Template objects as responses.

Now, you can re-run the program and see that your site is displaying a small form! It's not much, but it's honest work. Make no mistake, this is still a static site. We're going to make it dynamic in a moment, but I first want to take a moment to...

Adding a style sheet by serving static files

This is a short stop just to demonstrate a way we can add a CSS style sheet to your page. First, create a very simple sheet. We're going to use the same path we stated in base.html.tera.

static/style/main.css

body {
    background-color: pink;
}

That, my dear friend, is hight art. If you still see that the background colour is white, then it hasn't been applied correctly; there's no need for more. In fact, you should not see the pink background colour because you still haven't told Rocket how to process a request to a file stored in /static. To do so, we have to modify the main.rs file. Since this is going to be a small addition, I'm only going to show you the function we're adding, the imports we need and the final rocket function:

Partial src/main.rs

use rocket::fs::NamedFile;

use std::io;
use std::path::{Path, PathBuf};

#[get("/static/<file..>")] // (1)
async fn static_file(file: PathBuf) -> io::Result<NamedFile> { // (3)
    NamedFile::open(Path::new("static/").join(file)).await // (2)
}

#[launch]
fn rocket() -> _ {
    rocket::build()
        .mount("/", routes![index, static_file]) // (4)
        .attach(Template::fairing())
}

Our new function static_file receives an argument! In fact, it is a variadic argument, in the sense of how it's parsed from the URL, as defined by #[get("/static/<file..>")] (1). This means that we can as for a file that may contain slashes in between (such as style/main.css). The function itself receives a PathBuf object, which is a string to a path, which we use to find an object inside the static/ directory.

The function is async because we need to await on NamedFile::open() to open the static file (3). That function returns an io::Result<NamedFile> (3), which encodes whether the file was successfully open or an error happened in a single object so that Rocket can manage the error for us. Cool beans!

Finally, we just add the function to the routes! array (4). Note that, even though static_file is async, we don't need to do anything fancy to mount it into the file. Rocket's taking care of that for us.

So, at the end, we arrive to the final step. Let's turn the heat up a notch, because we're going to take a quick look into the thing that brings our website to true life!

Adding persistence with a SQLite database

I'm not going to show you how to set up a MariaDB, PostgreSQL or otherwise server. We want to use a very simple SQL database and the simplest one we can use is SQLite. To handle it, we're going to import our final crates:

  • sqlx: A SQL database driver that, check this out, won't let you compile if your queries aren't correct. Wow.
  • dotenv: A convenience crate to define environment variables. It's not really necessary, but it makes our lives a bit easier, which is welcome.

This will be our final build configuration:

Cargo.toml

[package]
name = "myweb"
version = "0.1.0"
edition = "2021"

[dependencies]
dotenv = "0.15.0"
rocket = "0.5.1"
serde = "1.0.129"

[dependencies.rocket_dyn_templates]
version = "0.2.0"
features = ["tera"]

[dependencies.sqlx]
version = "0.8.3"
features = [
    "runtime-tokio",
    "sqlite",
]

We have added dotenv to the [dependencies] table and a new [dependencies.sqlx] table with support for SQLite and Tokio, a runtime required for the asynchronous call we'll do to the database. As always, Cargo is the one in charge of making all of this work, so this is all the code we need.

Now, sqlx will complain that it doesn't know where the SQLite database is if we start writing code right away. To solve this, we need to set the DATABASE_URL environment variable. Instead of exporting it every time, let's make dotenv do it for us:

.env

# shellcheck disable=SC2034
DATABASE_URL="sqlite://$PWD/db.sqlite3"

This file will ensure that every time cargo, the Rust LSP server or any other tool working with our program is started, the DATABASE_URL environment variable is exported with the path to our database, which is in the program's root directory. But, we haven't created it yet! I'm going to spare you the details of how to set up the database. Just copy these directives from the database dump I made:

db.sql

PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE VoteOption (id INTEGER PRIMARY KEY, name VARCHAR[128] NOT NULL, votes INTEGER NOT NULL);
INSERT INTO VoteOption (id, name, votes) VALUES (1, 'Option 1', 0);
INSERT INTO VoteOption (id, name, votes) VALUES (2, 'Option 2', 0);
COMMIT;

You can quickly recreate the database with this one-liner:

cat db.sql | sqlite3 db.sqlite3

Easy as pie! At any time, you can dump your database with this one-liner as well:

sqlite3 db.sqlite3 --batch ".dump" > db.sql

With this out of the way, let's look at our final Rust source:

src/main.rs

#[macro_use]
extern crate rocket;

use rocket::form::Form;
use rocket::fs::NamedFile;
use rocket_dyn_templates::{context, Template};
use sqlx::SqlitePool;

use std::io;
use std::path::{Path, PathBuf};

#[derive(rocket::serde::Serialize)]
struct VoteOption {
    id: i64,
    name: String,
    votes: i64,
}

#[derive(FromForm)] // (1)
struct VoteForm {
    id: Option<i64>, // (2)
}

async fn get_db_opts() -> Result<Vec<VoteOption>, sqlx::Error> { // (4)
    let db = SqlitePool::connect(&dotenv::var("DATABASE_URL").unwrap()).await?; // (3)
    sqlx::query_as!(VoteOption, "SELECT id, name, votes FROM VoteOption") // (5)
        .fetch_all(&db)
        .await
}

#[get("/")]
async fn index() -> Template {
    Template::render(
        "index",
        context! {
            options: get_db_opts().await.unwrap() // (6)
        },
    )
}

#[post("/", data = "<vote>")] // (7)
async fn vote_for(vote: Form<VoteForm>) -> Template { // (8)
    let Some(id) = vote.id else { // (9)
        return index().await;
    };

    let db = SqlitePool::connect(&dotenv::var("DATABASE_URL").unwrap())
        .await
        .unwrap();
    sqlx::query!(
        "UPDATE VoteOption SET votes = votes + 1 WHERE id = ?", // (10)
        id
    )
    .execute(&db)
    .await
    .unwrap();

    index().await
}

#[get("/static/<file..>")]
async fn static_content(file: PathBuf) -> io::Result<NamedFile> {
    NamedFile::open(Path::new("static/").join(file)).await
}

#[launch]
fn rocket() -> _ {
    rocket::build()
        .mount("/", routes![index, vote_for, static_content]) // (11)
        .attach(Template::fairing())
}

This is the final code we're going to be working with, so feel free to just copy and paste the whole thing into your editor. Let's go over the changes:

First of all, we create the VoteForm struct, which we annotate as #[derive(FromForm)] (1) to let Rocket emit code that will allow us to use it as the request object for a web form. Note that id is of type Option<i64> (2) because the user may have pressed Submit without selecting any of the voting options. This type will force us control this possible case.

Next, the function get_db_opts gets a connection to the SQLite database (3). Remember when we said that dotenv would make our life easier? Here, we're using it to get the same DATABASE_URL environment variable we defined in .env.

The ? token after await does something neat: SqlitePool::connect() returns a Result, which can be either Ok(v) for some value v or Err(e) for some error e. If it returns an error, we want to return it to our caller, which is why get_db_opts returns a Result<..., sqlx::Error>. Since this patterns is so common, ? will check the result and return the error or unwrap the Ok(v) for us to store into the db variable.

Finally, we want query the database for the VoteOption objects stored in the "VoteOption" table (5). As you can see, we don't need to get a cursor to the database rows and parse every field with the correct type. That's...ehem... unsafe. It turns out that the sqlx::query_as! is smart enough to figure it out for us. Since the database rows have the same names as the VoteOption struct fields, it does the work for us to check that we're selecting all the fields, fill them for us, and...wait for it...refuse to compile if the types in the database differ from the types in the struct!

You read that right: sqlx ensures that you're working with the database without making mistakes at compile time! It does so by connecting to it at compile time and running many tests on its schema to be sure that the queries will work. This is absolutely brutal and one of the things that are going to make me stuck with Rust for years to come.

To continue with the code, in index we can just change the context to call get_db_opt (6). As you may have guessed by now, this is not high software engineering. We're just writing a quick demo to showcase the ease of use of this tool. Can you call it "ease of use" when this article is over four thousand words long? It's a...relative ease of use.

To process the form, we create the vote_for function annotated by #[post("/", data = "<vote>")] (7), which tells Rocket that this is the function we'll call when we receive a POST request at the index page, i.e. when we submit the form. We're also telling the macro that the argument that will receive the request data will be called vote. Note that the function's vote argument is of type Form<VoteForm> (8), the inner of which was previously annotated to derive from FromForm. It's all coming together.

Now, we need to control whether the user selected an option or not, which we can with a match expression (9) to extract the id. This code is a shorthand to tell the compiler to check whether the vote.id is Some(id). If it is (the user has selected an option), it unwraps the value into the id variable, which now has the type i64, and continue running the program. If it isn't, then we run the else block, which just returns a call to index().await. We do this because we want to reload the page every time the user submits their vote.

When we have our user vote, we do the usual thing to open the database again. Against DRY principles, this code is a bit WET (Write Everything Twice, I hope that one's original). Again, this is not a display of good software engineering. The main difference is that we're calling .unwrap() after awaiting for the connection, instead of raising the Err, because the function returns a Template. If there was an error connecting to the database, the thread will panic! and the program will end. This is not desirable for production code but, again, this is a quick demo.

Next, we call the sqlx::query! macro to run an UPDATE query on the database (10). Every time we vote for an option, the number of votes will go up by one. Again, sqlx will refuse to compile if the type of vote.id does not match the value its being bound to by the ? character in the query. This query doesn't return anything because it's just and UPDATE, so we .unwrap() the result to panic! if there were any errors and render the page again.

Finally, we mount vote_for (11) and we have a simple, but very much functional web server that can manage a simple election with a database!

Conclusion

What a journey!

As I said at the beginning, I'm not a Rust (not a Rocket, SQLX or whatever) expert. I'm sure that there are other, better and worse ways of writing the code that I did. I also understand that we went through lots of concepts to get to the end, but I'm still amazed by how short the final product code is considering Rust is a purely compiled language, just like C or C++.

The tooling available to the developer that makes this endeavour possible is, to me, otherworldly. I'm going to keep practising this language because it looks like it really delivers on the promise of making writing memory-safe programs both easy and fast without sacrificing performance.

What about you? Did I spark your interest in the language? Hit me with an email if you'd like to send a comment!