Getting started with Rust backends

Tags: #rust,#axum,#web

Reading time: ~7min


How hard can it be to write a web backend in Rust? We will try to answer this question through an example.

The example will be a simple HTML contact form with the following elements:

The web server will serve this form and handle its submission. In case the form submission was not valid, the form is returned back without losing the user's input. Otherwise, the user receives a confirmation that the contact form was submitted successfully.

Landscape mode recommended on mobile devices

Disclaimer

If you are a beginner, you can take this post as a demonstration of what you can achieve with a basic Rust backend, but don't get distracted by details 😵‍💫

This is not an HTML/CSS tutorial. The HTML pages will be ugly, but functional. You can add design details later 🎨 Personally, I would recommend Tailwind CSS.

Dependencies

The example uses the following crates:

To follow the example, create a new Rust project with cargo new and add the following dependencies to Cargo.toml:

[dependencies]
askama = "0.12.1"
axum = "0.7.4"
serde = { version = "1.0.195", features = ["derive"] }
tokio = { version = "1.35.1", features = ["macros", "rt-multi-thread"] }

Imports

All imports used in the example are gathered below to keep later code snippets slim:

use askama::Template;
use axum::{
    extract::Form,
    http::StatusCode,
    response::{Html, IntoResponse, Response},
    routing::{get, post, Router},
};
use serde::Deserialize;
use std::io;
use tokio::net::TcpListener;

Server

Let's finally start building the server!

In the following snippet, we build a test router that returns "Hello world!". Then we build the server to serve the router on localhost which is equivalent to IPv4 address 127.0.0.1.

#[tokio::main]
async fn main() -> io::Result<()> {
    let router = Router::new()
        .route("/", get("Hello world!"));

    let listener = TcpListener::bind("127.0.0.1:8080").await?;
    println!("Listening on http://{}", listener.local_addr()?);

    axum::serve(listener, router).await
}

Run the program and visit http://127.0.0.1:8080 in your browser to see the "Hello world!" message.

Congratulations, you did just build your first Rust web server 🎉

Note

You need to use the socket address 0.0.0.0 (with some port) when you want to host the server in a container for example.

Form template

Now, we want to create the form template to serve it instead of "Hello world!".

First, create the directory templates where all templates will be placed (default directory for askama).

Write the following form template in the file templates/form.html:

<!DOCTYPE html>
<html lang="en">
    <body>
        <form action="/submit" method="post">
            <div>
                <label for="name">Name</label>
                <input type="text" name="name" id="name" value="{{ name }}" required>
            </div>

            <div>
                <label for="email">Email</label>
                <input type="email" name="email" id="email" value="{{ email }}" required>
            </div>

            <div>
                <div>
                    <label for="message">Message</label>
                </div>
                <textarea name="message" id="message" required>{{ message }}</textarea>
            </div>

            <button type="submit">Submit</button>
        </form>
    </body>
</html>

This template produces a form like the one below:

Did you notice the three variables inside {{ }}? These placeholders will be replaced by the values provided to the template.

Now, we define a struct representing the template in our Rust code. It has the fields that we use as placeholders in our template:

#[derive(Template, Default)]
#[template(path = "form.html")]
struct FormTemplate<'a> {
    name: &'a str,
    email: &'a str,
    message: &'a str,
}

path points to the template's path inside of the templates directory.

Note that we derive the trait Default to be able to create the template with empty strings as the default.

Rendering templates

To serve the template, it has to be rendered and returned as a response by a handler.

Because rendering a template and returning it as a response is common in a backend, we want to write a function that does that for us:

fn render_template(template: impl Template) -> Response {
    match template.render() {
        Ok(rendered) => Html(rendered).into_response(),
        Err(e) => {
            eprintln!("Failed to render a template: {e:?}");

            StatusCode::INTERNAL_SERVER_ERROR.into_response()
        }
    }
}

In case the template could not be rendered, the status code 500 is returned as a representation of an internal server error.

Now, we can use this function to render our form template.

Index handler

The handler responsible of rendering our form template is very simple:

async fn index() -> Response {
    let template = FormTemplate::default();
    render_template(template)
}

First, we initialize the templates with default values since the default of String is an empty string. Then, we return the response of our template rendering function.

Now, we have to let this handler handle GET requests to our index path. This is done by replacing the "Hello World!" string in the main function with our handler function index:

let router = Router::new()
    .route("/", get(index));

Now, run the program and visit 127.0.0.1:8080 in your browser to see the form 🎉

You can fill it, but submitting will not work because we are not handling form submissions yet.

Success template

Let's prepare for handling a form submission!

After a successful form submission, we want to give the user some feedback. Therefore, we create the template templates/success.html:

<!DOCTYPE html>
<html lang="en">
    <body>
        <p>Thank you for contacting us, {{ name }}. We will reach out to you as soon as possible.</p>
    </body>
</html>

The template takes the name of the user as a template variable. This has to be reflected in the struct of this template:

#[derive(Template)]
#[template(path = "success.html")]
struct SuccessTemplate<'a> {
    name: &'a str,
}

Note

In a real project, you should use a base template to prevent duplication!

Form deserialization

Before returning the success template, we have to receive the form. To do so, we write a deserialization struct for our form with the expected input fields:

#[derive(Deserialize)]
struct FormFields {
    name: String,
    email: String,
    message: String,
}

The deserialization struct can now be used with Axum's Form extractor that tries to deserialize a request form into this struct. This extractor will be the only argument of our form submission handler:

async fn submit(fields: Form<FormFields>) -> Response {
    if fields.name.len() < 2 || fields.email.len() < 3 || !fields.email.contains('@') {
        let template = FormTemplate {
            name: &fields.name,
            email: &fields.email,
            message: &fields.message,
        };
        return render_template(template);
    }

    println!(
        "Submission:\nName: {}\nEmail: {}\nMessage: {}\n",
        &fields.name, &fields.email, &fields.message
    );

    let template = SuccessTemplate { name: &fields.name };
    render_template(template)
}

First, we do some server side form validation. This is not a good email validation! It is just a placeholder for demonstration.

If the submitted form is not valid, we return the form template back, but this time with the fields from the form so that the user doesn't lose his form input.

If the form is valid, we print the submitted fields to stdout just as a placeholder for some proper handling of a successful contact.

Now, let's make this handler handle POST requests to the /submit path (the path that action in our form template points to):

let router = Router::new()
    .route("/", get(index))
    .route("/submit", post(submit));

Test

Now that we implemented our two important handlers, let's test our backend.

Run the program and visit http://127.0.0.1:8080 in your browser to see the form.

Fill the form and submit it to see the success feedback 🎉

Take a look at your terminal, the submission is printed there!

Now, go back and fill the form again, but this time with only one character in the name field. This will trigger the server side validation and return the form back. Although no form input is lost, the user doesn't know what he did wrong 😕

We need to show some error message after an invalid form.

Error message

Let's add an error message to the form template:

struct FormTemplate<'a> {
    // …
    error_message: &'a str,
}

Now, the Rust compiler will tell you that you have to do a modification in the submit handler:

let template = FormTemplate {
    // …
    error_message: "Invalid input!",
};

The Rust compiler will also warn you that the field error_message is not used! This is right because we didn't use it in our template file templates/form.html yet. Add the following just before the submit button:

{% if !error_message.is_empty() %}
    <p>{{ error_message }}</p>
{% endif %}

Now, run the program and test again with only one character in the name field to see the error message 🎉

Conclusion

We can't deny that Rust has a steep learning curve. In this blog post, we skipped many of the used key concepts in Rust like lifetimes and traits. But after you go through the first chapters in the Rust book, you will be able to start working on your own web backend in Rust. In fact, such a contact form was my second Rust project!

Rust will even make your life easier. The compiler shifts many bugs from runtime to compile time, serde (de)serializes for you, askama makes sure your templates are valid at compile time etc. In Rust, you have an army of tools that give you the feeling that "if it compiles, it works".

On top of that, you get all the benefits of Rust regarding performance, low resource usage etc.

I hope that this post was an icebreaker for you. What are you waiting for? Let's make the web rusty! 😃


Full code

The full code used in this post can be found here.

Credits

The blog post is inspired by the following blog post on spacedimp.com about building a blog in Rust. I recommend reading it after this post 😃

You can suggest improvements on the website's repository

Content license: CC BY-NC-SA 4.0