DEV Community

Cover image for Oops, I Did It Again...I Made A Rust Web API And It Was Not That Difficult
Ben Lovy
Ben Lovy

Posted on • Edited on

Oops, I Did It Again...I Made A Rust Web API And It Was Not That Difficult

Over two years ago (oof), I posted a walkthrough of my Rust implementation of todo-mvp by @gypsydave5 demonstrating how to build a simple Rust API without a framework. The core functionality was built using hyper, a lower-level HTTP library instead of a full-blown framework.

It turns out I wrote that post about six months too early. I published it in May 2019, and in November, Rust released 1.39.0 containing async/await syntax. Womp womp.

My intent here was to simply upgrade the existing application to use the new syntax where applicable and call it a day. But, you know that thing that happens when you go back and review code you wrote years ago? Yeah, that thing happened. We're starting from scratch.

What's New

Our result functions almost identically to the implementation in the previous post, so some of this code will look very similar. Here's a quick overview of the new stuff you'll find in this post not present in the previous version:

  • anyhow - Error handling for humans.
  • async/await - New syntax for expressing asynchronous computation - like a webserver!
  • catch_unwind - A panicking task shouldn't crash your whole server! Gracefully catch the panic and keep on servin'.
  • compression - Every response will be DEFLATE compressed, simply because we can.
  • Rust 2021 - the future of Rust.
  • state management - Instead of a global variable, we'll use protocol extensions to access the app state.
  • tracing - The log crate is old news. All the cool kids use tracing now.
  • unit testing - We didn't have any last time - tsk tsk! Learn how to write async unit tests for your handlers.

Setup

To follow along, you'll need a stable Rust toolchain. See the install page for instructions to install rustup for your platform. You should prefer this method to your distribution's package manager. If you're a NixOS freak, I recommend fenix.

Once your environment is set up, start a new project and make sure you can build it:

$ cargo new simple-todo
   Created binary (application) `simple-todo` package
$ cd simple-todo
$ cargo run
   Compiling simple-todo v0.1.0 (/home/deciduously/code/simple-todo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.19s
     Running `target/debug/simple-todo
Hello, world!

$
Enter fullscreen mode Exit fullscreen mode

Open up your Cargo.toml and make it look like this:

[package]
authors = ["Cool Person <cool.person@yourcoolsite.neato>"]
edition = "2021"
rust-version = "1.56"
name = "simple-todo"
version = "0.1.0"

[dependencies]
anyhow = "1"
backtrace = "0.3"
clap = {version = "3.0.0-beta.5", features = ["color"] }
flate2 = "1"
futures = "0.3"
hyper = { version = "0.14", features = ["full"] }
lazy_static = "1.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tera = "1"
tokio = { version = "1", features = ["full"] }
uuid = { version = "0.8", features = ["serde", "v4"] }

[dev-dependencies]

pretty_assertions = "0.7"
select = "0.5"
Enter fullscreen mode Exit fullscreen mode

There are some new elements here already, up in the package section. Rust 2021 was recently released, and along with it, the rust-version metadata key, allowing you to specify the Minimum Supported Rust Version (MSRV) directly in the package.

This post is only concerned with Rust and will use assets identical to the last post. Create a folder called templates at the project's top-level, and place this index.html inside. You will also need to create a directory at src/resource and fill it with these files. There is a stylesheet and a handful of SVG files. Your structure should look like this:

$ tree
.
├── Cargo.lock
├── Cargo.toml
├── src
│  ├── main.rs
│  └── resource
│     ├── check.svg
│     ├── plus.svg
│     ├── tick.png
│     ├── todo.css
│     ├── trashcan.svg
│     └── x.svg
└── templates
   └── index.html
Enter fullscreen mode Exit fullscreen mode

Good to go!

Entrypoint

This whole app will live in src/main.rs. Start off by adding the imports:

use anyhow::Result;
use backtrace::Backtrace;
use clap::Parser;
use flate2::{write::ZlibEncoder, Compression};
use futures::{future::FutureExt, Future};
use hyper::http;
use lazy_static::lazy_static;
use serde::Serialize;
use std::{
    cell::RefCell,
    convert::Infallible,
    io::Write,
    panic::AssertUnwindSafe,
    path::PathBuf,
    sync::{Arc, RwLock},
};
use tera::Tera;
use uuid::Uuid;
Enter fullscreen mode Exit fullscreen mode

It's nice to allow the user to specify where to run their app. The clap library provides a convenient way to specify command-line arguments in a struct. This feature is still in beta but will stabilize soon. We can create a struct and use the Parser feature to generate the options:

#[derive(Parser)]
#[clap(version = concat!(env!("CARGO_PKG_VERSION")), about = "Serve a TODO list application.")]
struct Args {
    #[clap(
        short,
        long,
        about = "Address to bind the server to.",
        env,
        default_value = "0.0.0.0"
    )]
    address: String,
    #[clap(short, long, about = "Port to listen on.", env, default_value = "3000")]
    port: u16,
}
Enter fullscreen mode Exit fullscreen mode

When run without any arguments, the server will bind to 0.0.0.0:3000, a reasonable default. This allows users to either use the ADDRESS and PORT environment variables or -a/--address and -p/--port command-line arguments. It also provides a nice --help implementation:

simple-todo 0.1.0

Serve a TODO list application.

USAGE:
    todo-mvp-rust [OPTIONS]

OPTIONS:
    -a, --address <ADDRESS>    Address to bind the server to. [env: ADDRESS=] [default: 0.0.0.0]
    -h, --help                 Print help information
    -p, --port <PORT>          Port to listen on. [env: PORT=] [default: 3000]
    -V, --version              Print version information
Enter fullscreen mode Exit fullscreen mode

Our main() function will just parse these arguments and pass them off to our app routine:

fn main() -> Result<()> {
    let args = Args::parse();
    app(args)?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

I've brought anyhow::Result into scope, making error handling super easy to use. We don't need to specify all our Error types. It can automatically convert any errors that implement std::error::Error, which should be all of them. If an error propagates all the way up to main(), we'll get all the info it's captured printed to stdout.

The app() function is our real entrypoint:

#[tokio::main]
async fn app(args: Args) -> Result<()> {
    tracing_subscriber::fmt::init();

    let addr = std::net::SocketAddr::new(args.address.parse()?, args.port);

    let todos = Todos::new();
    let context = Arc::new(RwLock::new(todos));

    serve(addr, context, handle).await?;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

This function is tagged with #[tokio::main], meaning it will execute an async runtime. All the functions we're using now can be marked async, meaning we can await the result. Under the hood, Rust converts these functions into constructs called Futures, and in the previous iteration we built these manually. Futures can either be Ready or Waiting. You can call the poll() method on a future, which just asks: "are you ready, or are you waiting?" If the future is ready to resolve, it will pass up the response, and if it's waiting, it will just respond with Pending. It can return a wake() callback, letting the caller know there's a value. When wake is executed, the caller will know to poll() this future again, resolving it to a value.

This all involved a lot of ceremony. This async/.await syntax abstracts these concepts for us. We just write functions like normal, except we mark them async, and when control flow hits a point where we need to wait for a result to resolve, we can use await. If these are used within an executor like #[tokio::main], the runtime will cover all the details. Instead of blocking control flow, each of these await points will yield to other running tasks on this executor until the underlying Future returns from a poll() request as Ready<T>. This makes our types easier to reason about and our code much more straightforward to write.

This top-level function reads our argument struct to build the SocketAddr to bind to, starts up the logging system with tracing_subscriber, and builds our state management. Then, we call the serve() async function and await on the result. This executor will run forever until the user kills the process and can handle multiple concurrent connections for us seamlessly.

For a sneak preview, this is the signature of serve():

async fn serve<C, H, F>(
    addr: std::net::SocketAddr,
    context: Arc<C>,
    handler: H,
) -> hyper::Result<()>
where
    C: 'static + Send + Sync,
    H: 'static + Fn(Request) -> F + Send + Sync,
    F: Future<Output = Response> + Send,
{
    // ...
}
Enter fullscreen mode Exit fullscreen mode

It requires we pass an address, then an Arc<C>. That C type is our app state, and for this to work, it must implement the Send and Sync traits. Send means the value can be sent to another thread, and Sync means it can be shared between threads simultaneously.

In our case, we don't need to think too hard about this. We're using an Arc<RwLock<T>> to allow mutation to provide a type that can be accessed from multiple tasks and safely mutated as needed. As long as each task grabs the proper lock, we don't need to worry about concurrent tass clobbering each other. Only one task will be able to write to this type at a time, so every new reader will always have the correct data.

Finally, we need to add a handler with type H. These types start to peel back a little of what async is doing for us. Stripping out the Send + Sync trait bounds, this function satisfies the trait bound Fn(Request) -> Future<Output = Response>. Because we're in an async environment, we can just write async fn handle(request: Request) -> Response - it's an asynchronous function from a request to a response. Sounds like a web server to me! Using Rust's async/.await, we get to simply write what we mean.

We'll come back to the handler shortly - first, we have a little bit of setup to take care of.

Templates

This application only consists of a single page that will be refreshed whenever the state changes. We placed markup at templates/index.html, an HTML file using Jinja-style templating. We'll use Tera to handle this in Rust.

The templates need to be compiled before use, but this only needs to happen once. We can use lazy_static to ensure this compilation happens the first time the templates are accessed, and then reuse the compiled result for all subsequent access:

lazy_static! {
    pub static ref TERA: Tera = match Tera::new("templates/**/*") {
        Ok(t) => t,
        Err(e) => {
            eprintln!("Unable to parse templates: {}", e);
            std::process::exit(1);
        }
    };
}
Enter fullscreen mode Exit fullscreen mode

Now we can use the TERA global. If Tera could not compile the templates for any reason, the process will exit here and display the error. Our server only works if this step completes successfully.

State

Next, we need to define the app state. The application revolves around a Todo type with a name, an ID, and a boolean tracking whether it's been completed:

#[derive(Debug, Serialize)]
pub struct Todo {
    done: bool,
    name: String,
    id: Uuid,
}

impl Todo {
    fn new(name: &str) -> Self {
        Self {
            done: false,
            name: String::from(name),
            id: Uuid::new_v4(),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We just need to provide a string name, like Todos::new("Task"), and this type will generate a new unique ID and set it to incomplete.

Storage is pretty simple:

#[derive(Debug, Default)]
struct Todos(Vec<Todo>);
Enter fullscreen mode Exit fullscreen mode

We need methods to add new todos, remove existing todos, and toggle the done boolean:

impl Todos {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn push(&mut self, todo: Todo) {
        self.0.push(todo);
    }

    pub fn remove(&mut self, id: Uuid) -> Option<Todo> {
        let mut idx = self.0.len();
        for (i, todo) in self.0.iter().enumerate() {
            if todo.id == id {
                idx = i;
            }
        }
        if idx < self.0.len() {
            let ret = self.0.remove(idx);
            Some(ret)
        } else {
            None
        }
    }

    pub fn todos(&self) -> &[Todo] {
        &self.0
    }

    pub fn toggle(&mut self, id: Uuid) {
        for todo in &mut self.0 {
            if todo.id == id {
                todo.done = !todo.done;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Notably, we don't have to write any unique code to make this thread safe. We can write it exactly as we would in a single-threaded, synchronous context and trust that Rust won't let us mutably access this unsafely. This is what got instantiated up in app() with these lines:

let todos = Todos::new();
let context = Arc::new(RwLock::new(todos));
Enter fullscreen mode Exit fullscreen mode

Wrapping this all in an Arc means that any task accessing this value can get their own reference to it, and the RwLock will allow multiple concurrent readers or exactly one writer at a time. When the lock is released, the next waiting task will be able to take control.

Handler

We're finally ready to take a look at the handler. Per the signature up above, we know we need a function with the following signature: async fn handle(request: Request) -> Response. Each time our web server receives an HTTP request, it will call this function to produce a Response to send back. First, add type aliases for the request and response:

type Request = http::Request<hyper::Body>;
type Response = http::Response<hyper::Body>;
Enter fullscreen mode Exit fullscreen mode

The hyper crate defines all the types we need. In both cases, the request and response will carry a hyper::Body. Every single handler will use this type signature, so defining these aliases saves us a lot of typing.

The request contains information about how it was sent. We can use Rust's match construct to read both the incoming URI and HTTP method to correctly dispatch a response. Here's the whole body:

async fn handle(request: Request) -> Response {
    // pattern match for both the method and the path of the request
    match (request.method(), request.uri().path()) {
        // GET handlers
        // Index page handler
        (&hyper::Method::GET, "/") | (&hyper::Method::GET, "/index.html") => index(request).await,
        // Style handler
        (&hyper::Method::GET, "/static/todo.css") => stylesheet().await,
        // Image handler
        (&hyper::Method::GET, path_str) => image(path_str).await,
        // POST handlers
        (&hyper::Method::POST, "/done") => toggle_todo_handler(request).await,
        (&hyper::Method::POST, "/not-done") => toggle_todo_handler(request).await,
        (&hyper::Method::POST, "/delete") => remove_todo_handler(request).await,
        (&hyper::Method::POST, "/") => add_todo_handler(request).await,
        // Anything else handler
        _ => four_oh_four().await,
    }
}
Enter fullscreen mode Exit fullscreen mode

Each match arm will match with a specific combination of HTTP verb and path. For example, GET /static/todo.css will properly dispatch the stylesheet() handler, but POST /static/todo.css is not supported and will fall through to four_oh_four(). Each of these handlers is itself an async function, but we don't want to return up to the caller until they've been polled as ready, and return an actual Response. Remember, Rust is doing this for us - when we write async fn() -> Response, we actually get a Fn() -> impl Future<Output = Response>. We can't use that return type until the future resolves! That's what the .await syntax signifies. Once the future is ready, we'll use the resulting Response output but not before.

The most straightforward handler is four_oh_four():

async fn four_oh_four() -> Response {
    html_str_handler("<h1>NOT FOUND!</h1>", http::StatusCode::NOT_FOUND).await
}
Enter fullscreen mode Exit fullscreen mode

This response doesn't depend on the request - the request didn't make any sense to us, after all! There's no input parameter, but like all handlers, it produces a Response. Because all of our routes need to build responses, I pulled this logic out into a series of building-block functions.

Response Builders

Most of our Response building shares a lot of logic. We usually send back some form of string, and we want to attach a content type and a status code. Because we care about our user's bandwidth usage, we also want to compress our responses, ensuring as little as possible is sent over the wire. The most common case is a successful response containing HTML:

async fn ok_html_handler(html: &str) -> Response {
    html_str_handler(html, http::StatusCode::OK).await
}
Enter fullscreen mode Exit fullscreen mode

This, in turn, calls the HTML string handler:

async fn html_str_handler(html: &str, status_code: http::StatusCode) -> Response {
    string_handler(html, "text/html", status_code).await
}
Enter fullscreen mode Exit fullscreen mode

Our four_oh_four() handler used this directly to include a different status code. Ultimately, though, it's all just strings:

async fn string_handler(body: &str, content_type: &str, status_code: http::StatusCode) -> Response {
    bytes_handler(body.as_bytes(), content_type, status_code).await
}

async fn ok_string_handler(body: &str, content_type: &str) -> Response {
    string_handler(body, content_type, hyper::StatusCode::OK).await
}
Enter fullscreen mode Exit fullscreen mode

These helpers allow for other content types as long as the body is still passed as a string. This covers all the body types we need for this application. At the bottom, we get to bytes_handler:

async fn bytes_handler(body: &[u8], content_type: &str, status_code: http::StatusCode) -> Response {
    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
    encoder.write_all(body).unwrap();
    let compressed = encoder.finish().unwrap();
    hyper::Response::builder()
        .status(status_code)
        .header(hyper::header::CONTENT_TYPE, content_type)
        .header(hyper::header::CONTENT_ENCODING, "deflate")
        .body(hyper::Body::from(compressed))
        .unwrap()
}
Enter fullscreen mode Exit fullscreen mode

This function takes a byte slice (&[u8]), and DEFLATE compresses it. It adds the proper Content-Encoding header so that any connected client can uncompress the payload before presenting it back to the user. This is a rapid operation, and the smaller your payloads, the better. Every response that contains a body will eventually pass through this function before bubbling back up to the top-level handle() function and back to the client.

There's one more response type for this application, and this doesn't use a body at all. Whenever our app state changes, we will use the 301 HTTP status code to trigger the client to redirect back to the index. Every time the index is rendered, it reads the current app state, meaning any mutation executed in the handler will be automatically reflected on the refresh. This function calls Response::builder() directly:

async fn redirect_home() -> Response {
    hyper::Response::builder()
        .status(hyper::StatusCode::SEE_OTHER)
        .header(hyper::header::LOCATION, "/")
        .body(hyper::Body::empty())
        .unwrap()
}
Enter fullscreen mode Exit fullscreen mode

Main page

Now, we have everything we need to render the app. Our index function is the first handler that requires state:

async fn index(request: Request) -> Response {
    // Set up index page template rendering context
    let mut tera_ctx = tera::Context::new();
    let todos_ctx: Arc<RwLock<Todos>> = Arc::clone(request.extensions().get().unwrap());
    {
        let lock = todos_ctx.read().unwrap();
        let todos = lock.todos();
        let len = todos.len();
        tera_ctx.insert("todos", todos);
        tera_ctx.insert("todosLen", &len);
    }
    let html = TERA.render("index.html", &tera_ctx).unwrap().to_string();
    ok_html_handler(&html).await
}
Enter fullscreen mode Exit fullscreen mode

In the previous iteration of this app, the Todos struct was instantiated alongside our TERA templates in a global static variable. A better solution is to thread it through to our handler using the Request itself. We'll look at the implementation lower down, but by the time we get here, there's a fresh Arc containing the context ready to be read. We can use request.extensions().get() and Arc::clone() to grab our very own reference to the app state to use for building this response. The request extensions use the type of what's stored for access, so we need to explicitly add the type of todos_ctx to indicate what we're looking for.

Next, we build the index page using the current state of the app. This handler won't perform any mutation, so we can use todos_ctx.read(). By introducing a child scope, we ensure our read lock gets dropped when we're done with it, allowing any writers waiting for access to grab their own locks. If we needed to wait, no problem! We're in an async function, the caller can poll us any time, and we'll just return Pending until we're ready to go. Nice and neat.

Once we've received our handle to the app state, we can pass it through to Tera. TERA.render() will return an HTML string with all template values resolved using our app state. Then, we can use our trusty ok_html_handler() response builder to tag it with a proper content type and status code and compress the result before returning to the caller.

The index.html template will request a style sheet when it loads from /static/todo.css. That's a pretty simple handler:

async fn stylesheet() -> Response {
    let body = include_str!("resource/todo.css");
    ok_string_handler(body, "text/css").await
}
Enter fullscreen mode Exit fullscreen mode

The include_str!() macro actually bundles the string content directly in your compiled binary. These files need to be present at compile-time but do not need to be distributed in production. The compiled binary already includes everything it needs.

SVG

All the image assets in this application are SVG, which is represented as XML. This means we just need to read these strings and pass the proper content-type:

async fn image(path_str: &str) -> Response {
    let path_buf = PathBuf::from(path_str);
    let file_name = path_buf.file_name().unwrap().to_str().unwrap();
    let ext = match path_buf.extension() {
        Some(e) => e.to_str().unwrap(),
        None => return four_oh_four().await,
    };

    match ext {
        "svg" => {
            // build the response
            let body = match file_name {
                "check.svg" => include_str!("resource/check.svg"),
                "plus.svg" => include_str!("resource/plus.svg"),
                "trashcan.svg" => include_str!("resource/trashcan.svg"),
                "x.svg" => include_str!("resource/x.svg"),
                _ => "",
            };
            ok_string_handler(body, "image/svg+xml").await
        }
        _ => four_oh_four().await,
    }
}
Enter fullscreen mode Exit fullscreen mode

I just used a catch-all - any GET request that's not for the index or the stylesheet is assumed to be for an image. At the top, there's a little extra logic to make sure we're looking for an image file. If there's no extension, i.e. /nonsense, this handler will dispatch a four_oh_four(). Otherwise, we press forward and try to find the actual SVG file. If we succeed, we just pass the string back, and if not, we also four_oh_four().

State Handlers

The remaining handlers are involved with mutating the state. All of them will pass a request body with an item. For a new todo, it will be item=Task, and for toggling or removing, it will hold the id: item=e2104f6a-624d-498f-a553-29e559e78d33. In either case, we just need to extract the value after the equals sign:

async fn extract_payload(request: Request) -> String {
    let body = request.into_body();
    let bytes_buf = hyper::body::to_bytes(body).await.unwrap();
    let str_body = String::from_utf8(bytes_buf.to_vec()).unwrap();
    let words: Vec<&str> = str_body.split('=').collect();
    words[1].to_owned()
}
Enter fullscreen mode Exit fullscreen mode

The body may come in chunks, so we use hyper::body::to_bytes() to produce a single Bytes value with everything concatenated. We can then convert the bytes to a UTF-8 string and split on the = to grab the actual payload. All of our state mutation handlers call this function on the incoming request:

async fn add_todo_handler(request: Request) -> Response {
    let todos_ctx: Arc<RwLock<Todos>> = Arc::clone(request.extensions().get().unwrap());
    let payload = extract_payload(request).await;
    {
        let mut lock = todos_ctx.write().unwrap();
        (*lock).push(Todo::new(&payload));
    }
    redirect_home().await
}

async fn remove_todo_handler(request: Request) -> Response {
    let todos_ctx: Arc<RwLock<Todos>> = Arc::clone(request.extensions().get().unwrap());
    let payload = extract_payload(request).await;
    {
        let mut lock = todos_ctx.write().unwrap();
        (*lock).remove(Uuid::parse_str(&payload).unwrap());
    }
    redirect_home().await
}

async fn toggle_todo_handler(request: Request) -> Response {
    let todos_ctx: Arc<RwLock<Todos>> = Arc::clone(request.extensions().get().unwrap());
    let payload = extract_payload(request).await;
    {
        let mut lock = todos_ctx.write().unwrap();
        (*lock).toggle(Uuid::parse_str(&payload).unwrap());
    }
    redirect_home().await
}
Enter fullscreen mode Exit fullscreen mode

Each handler grabs its own unique reference to the app state, then extracts the payload. Like we did in index(), we open a new scope to interact with the RwLock mutex, and in this case, we use todos_ctx.write() to request a mutable lock. This blocks all other tasks until the mutation is complete. Then, we just redirect_home(). This prompts the client to send a GET / request, which leads our to-level handler to call index(), which reads the newly-mutated app state to build the page.

Groovy! That's a full-fledged TODO app.

Serve

There's one missing piece. We defined our handle() function, but we haven't talked about serve() beyond the type signature. This one is pretty beefy:

async fn serve<C, H, F>(
    addr: std::net::SocketAddr,
    context: Arc<C>,
    handler: H,
) -> hyper::Result<()>
where
    C: 'static + Send + Sync,
    H: 'static + Fn(Request) -> F + Send + Sync,
    F: Future<Output = Response> + Send,
{
    // Create a task local that will store the panic message and backtrace if a panic occurs.
    tokio::task_local! {
        static PANIC_MESSAGE_AND_BACKTRACE: RefCell<Option<(String, Backtrace)>>;
    }
    async fn service<C, H, F>(
        handler: Arc<H>,
        context: Arc<C>,
        mut request: http::Request<hyper::Body>,
    ) -> Result<http::Response<hyper::Body>, Infallible>
    where
        C: Send + Sync + 'static,
        H: Fn(http::Request<hyper::Body>) -> F + Send + Sync + 'static,
        F: Future<Output = http::Response<hyper::Body>> + Send,
    {
        let method = request.method().clone();
        let path = request.uri().path_and_query().unwrap().path().to_owned();
        tracing::info!(path = %path, method = %method, "request");
        request.extensions_mut().insert(context);
        let result = AssertUnwindSafe(handler(request)).catch_unwind().await;
        let start = std::time::SystemTime::now();
        let response = result.unwrap_or_else(|_| {
            let body = PANIC_MESSAGE_AND_BACKTRACE.with(|panic_message_and_backtrace| {
                let panic_message_and_backtrace = panic_message_and_backtrace.borrow();
                let (message, backtrace) = panic_message_and_backtrace.as_ref().unwrap();
                tracing::error!(
                    method = %method,
                    path = %path,
                    backtrace = ?backtrace,
                    "500"
                );
                format!("{}\n{:?}", message, backtrace)
            });
            http::Response::builder()
                .status(http::StatusCode::INTERNAL_SERVER_ERROR)
                .body(hyper::Body::from(body))
                .unwrap()
        });
        tracing::info!(
            "Response generated in {}μs",
            start.elapsed().unwrap_or_default().as_micros()
        );
        Ok(response)
    }
    // Install a panic hook that will record the panic message and backtrace if a panic occurs.
    let hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(|panic_info| {
        let value = (panic_info.to_string(), Backtrace::new());
        PANIC_MESSAGE_AND_BACKTRACE.with(|panic_message_and_backtrace| {
            panic_message_and_backtrace.borrow_mut().replace(value);
        })
    }));
    // Wrap the request handler and context with Arc to allow sharing a reference to it with each task.
    let handler = Arc::new(handler);
    let service = hyper::service::make_service_fn(|_| {
        let handler = handler.clone();
        let context = context.clone();
        async move {
            Ok::<_, Infallible>(hyper::service::service_fn(move |request| {
                let handler = handler.clone();
                let context = context.clone();
                PANIC_MESSAGE_AND_BACKTRACE.scope(RefCell::new(None), async move {
                    service(handler, context, request).await
                })
            }))
        }
    });
    let server = hyper::server::Server::try_bind(&addr)?;
    tracing::info!("🚀 serving at {}", addr);
    server.serve(service).await?;
    std::panic::set_hook(hook);
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

I know, I know. There's a lot here. The meat of this function happens right at the end:

let server = hyper::server::Server::try_bind(&addr)?;
tracing::info!("🚀 serving at {}", addr);
server.serve(service).await?;
Enter fullscreen mode Exit fullscreen mode

We build a hyper::Server, bind it to the address we constructed from the Args struct, and serve this service thing. The service is built right above that:

let handler = Arc::new(handler);
let service = hyper::service::make_service_fn(|_| {
    let handler = handler.clone();
    let context = context.clone();
    async move {
        Ok::<_, Infallible>(hyper::service::service_fn(move |request| {
            let handler = handler.clone();
            let context = context.clone();
            PANIC_MESSAGE_AND_BACKTRACE.scope(RefCell::new(None), async move {
                service(handler, context, request).await
            })
        }))
    }
});
Enter fullscreen mode Exit fullscreen mode

We also wrap the handler function in an Arc. Our context is already wrapped, so we clone both to get a local reference within this closure. This allows the executor to spin up multiple concurrent versions of our handler service that all have access to the same state and logic.

The beefy stuff happens in this service() closure above. This is where we take an incoming request and match it up with our handler and context. A new instance is executed for each incoming request, and all this ceremony allows this to happen without interfering with other simultaneous requests.

For one, this is where we add our context to the request:

request.extensions_mut().insert(context);
Enter fullscreen mode Exit fullscreen mode

When we call request.extensions().get() in our mutating handlers, we're pulling out the context added at this stage.

There's also some logging added. We trace the request's specifics and start a timer that reports how long the request took. To see this logging in action, set the environment variable RUST_LOG=info when executing the server process.

Catching Panics

The most exciting part (to me, at least) is the panic handler. We always want our requests to succeed. However, there are situations where we may encounter a panic. This will cause the entire Rust program to crash and print out a stack trace in normal usage. However, this is a web service. A panic situation in one request handling process shouldn't prevent other requests from executing. We don't want the whole server to crash; we still want to handle these situations gracefully. We can intercept the normal panic behavior and instead simply produce a different response containing the details.

At the top, we create a task-local storage location:

tokio::task_local! {
    static PANIC_MESSAGE_AND_BACKTRACE: RefCell<Option<(String, Backtrace)>>;
}
Enter fullscreen mode Exit fullscreen mode

This is local to just the currently executing Tokio task, not the whole program. Then, we replace the default panic logic with our own:

let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|panic_info| {
    let value = (panic_info.to_string(), Backtrace::new());
    PANIC_MESSAGE_AND_BACKTRACE.with(|panic_message_and_backtrace| {
        panic_message_and_backtrace.borrow_mut().replace(value);
    })
}));

// ...

std::panic::set_hook(hook);
Enter fullscreen mode Exit fullscreen mode

First, we grab the existing hook and store it to the hook variable. Then, we overwrite our own. At the end of the function, we make sure to reset the global panic hook back to what it was. If the task panics inside this function - for example, one of our unwrap() statements encounter a non-success, we'll store the panic message and backtrace to this task-local location. However, we will not abort the process.

Up above, in the service location, we can catch this happening:

let result = AssertUnwindSafe(handler(request)).catch_unwind().await;
Enter fullscreen mode Exit fullscreen mode

We attempt to build the response, but this result will not be a success if anything happens. If it was a success, great, we pass it back up. However, if we find an error value here, we can dispatch different logic:

let response = result.unwrap_or_else(|_| {
    let body = PANIC_MESSAGE_AND_BACKTRACE.with(|panic_message_and_backtrace| {
        let panic_message_and_backtrace = panic_message_and_backtrace.borrow();
        let (message, backtrace) = panic_message_and_backtrace.as_ref().unwrap();
        tracing::error!(
            method = %method,
            path = %path,
            backtrace = ?backtrace,
            "500"
        );
        format!("{}\n{:?}", message, backtrace)
    });
    http::Response::builder()
        .status(http::StatusCode::INTERNAL_SERVER_ERROR)
        .body(hyper::Body::from(body))
        .unwrap()
});
Enter fullscreen mode Exit fullscreen mode

For most requests, that result.unwrap() went fine, and we just stored our response to Response. However, if it was an error, we can read the result of the panic from this task-local area. We emit an error trace on the server-side and then build a new response with status code INTERNAL_SERVER_EROR. This response includes the full backtrace as the body. This means our server can keep handling other requests without interruption, but the specific client that caused the panic gets a complete log of the problem, and our server has logged the backtrace as well. We can diagnose the issue without losing uptime for any other client.

Now, no matter what happened while processing the request, we've stored a valid hyper::Response to this response value, and we can pass that back to the caller even if something catastrophic occurs. We can safely use Ok::<_, Infallible>, signifying that there is no possible way for control to fail to hit this point. Our server will always generate a response and continue, even if something terrible happens. Good stuff.

Tests

Finally, we want to ensure we can automate tests. I'll just demonstrate a test of our 404 handler, which includes all the pieces needed to build a robust test suite:

#[cfg(test)]
mod test {
    use super::*;
    use flate2::write::ZlibDecoder;
    use pretty_assertions::assert_eq;
    use select::{document::Document, predicate::Name};

    #[tokio::test]
    async fn test_four_oh_four() {
        let mut request = hyper::Request::builder()
            .method(http::Method::GET)
            .uri("/nonsense")
            .body(hyper::Body::empty())
            .unwrap();
        let context = Arc::new(RwLock::new(Todos::new()));
        request.extensions_mut().insert(Arc::clone(&context));
        let response = handle(request).await;

        assert_eq!(response.status(), http::status::StatusCode::NOT_FOUND);

        let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
        let mut decoder = ZlibDecoder::new(Vec::new());
        decoder.write_all(&body).unwrap();
        let uncompressed = decoder.finish().unwrap();
        let result = String::from_utf8(uncompressed).unwrap();

        let document = Document::from(result.as_str());
        let message = document.find(Name("h1")).next().unwrap().text();
        assert_eq!(message, "NOT FOUND!".to_owned());
    }
}
Enter fullscreen mode Exit fullscreen mode

Tokio provides a #[tokio::test] macro for building async tests. We can use hyper::Request to construct a request and build our context the same way we did in the server. Because our handler is just a function from a Request to a Response, we can test it very simply: let response = handle(request).await;.

We first assert the status code matches what we expect, then we have to decode the body. We use the ZlibDecoder to read the response body and decompress it back to a string.

Once we have our string response, we can use the select.rs library to ensure the structure matches our intent. In this case, we are asserting we've received an h1 element with a text body matching the string NOT FOUND!.

Fin

This implementation is an improvement over the previous in several fundamental ways. The async/.await syntax allows us to write code closely matching our intent without getting bogged down with Boxes and Futures. We avoid polluting the global scope and use the Request itself to handle concurrent access to the app state and even catastrophic request handling errors gracefully without affecting other clients. Our handler is straightforward to test. This application provides a solid, performant base for building more complex applications and keeps the dependencies and therefore compile times and bundle sizes to a minimum.

While there are many different frameworks for building web applications in Rust, it's worth asking yourself whether you actually need that level of abstraction. For many web service needs, putting the building blocks together yourself isn't much more complicated, and you retain control over how you build your application. If we want to deconstruct the request URI, we can do that already. If we return JSON, we just need to create a struct that implements serde::Serialize.

The conclusion here is the same as before: when the end goal is simple enough, why not use simple tools?

Cover photo by Danny Howe on Unsplash

Top comments (13)

Collapse
 
kmack profile image
k-mack

Excellent post! Even though you've demonstrated how using a web framework isn't always necessary, a fun follow-up post would be to show how to write this same application on top of warp (since it is also built on hyper).

Collapse
 
deciduously profile image
Ben Lovy

That's a good idea! I quite like Tokio's axum too.

Collapse
 
nabbisen profile image
nabbisen

Wow, really wonderful😲
std::net::SocketAddr is directly used. Must be lightweight and fast.
I have ever used just f/w such as Rocket and Actix. hyper, a bit.
Thank you for your great article and code😃 Now I feel like learning more about Rust and network... to wholly understand them at first...

Collapse
 
lexiebkm profile image
Alexander B.K.

Recently I started to consider a compiled language for backend. Currently I am still learning Go aka Golang. Actually I want to use Java, but it will take longer time to learn if I have to proceed to J2EE aka JEE, although Spring framework (Spring Boot) will help quick development.
I have read about Rust a couple years ago, but didn't really want to get started, seeing that it would only fit to system or embedded programming like C/C++.
But when I read an article from qvault.io/rust/rust-vs-go-in-backe...
my mind was open again. Still, I think I don't have time to learn Rust. Maybe, after sufficient learning and working with Golang, I will spend time to read its documentation again.

Collapse
 
deciduously profile image
Ben Lovy • Edited

Go is a tough competitor in terms of web servers. In my limited experience, Go really excels in that space above anything else I've used. I think Rust will ultimately provide better low-level control and general expressiveness, but pragmatically Go might be more than sufficient for a wide swath of web service use cases. Java fits the bill too. There's a lot of types of applications for which Rust is probably overkill.
Once you've invested the time and energy to get over the hump it's likely capable of comparable productivity, but the learning curve is definitely steeper, so you have to decide up front if it's worth the time and energy.

If you're just curious, I mean, yeah. Come on over :)

Collapse
 
lexiebkm profile image
Alexander B.K.

I have visited the Book again. And like the last time I read a bit about it, I jumped and read a bit about its unique thing : Ownership. I like reading the section that discussed Stack and Heap; this at the same time reminded me of C++ that I wanted to relearn in the correct way (like reading from Stanley Lippman's book of C++ Primer).
I am also glad seeing that Rust provides Struct like in C/C++, and how it defines methods in the context of Struct, Enum or Trait, although it doesn't provide Classes and Inheritances via classes. This way of defining methods immediately reminds me of how Go defines methods using Receiver argument in the context of certain types such as struct.
Therefore, I feel like at the same home when trying to learn Rust in parallel with Go, and actually C/C++ too, since they provide structs and pointers.

Collapse
 
qm3ster profile image
Mihail Malo • Edited

I took the liberty of messing with this project.
Please take a look at the individual commits, who knows, maybe you will like something: github.com/deciduously/todo-mvp/pu...

I know you didn't want JavaScript, but I provided a fallback for the bad people :v (Yes, the fallback is worse)

But the real gains would be caching the CSS and SVGs, of course.

Collapse
 
rduggan profile image
Robert Duggan

Umm, honestly this stuff is way above my head right now. But I do have a question, one you probably get a lot. Which font are you using in your terminal?

Collapse
 
deciduously profile image
Ben Lovy

Yep, I'm definitely assuming familiarity with Rust for this post :) If you're curious, I recommend starting with The Rust Programming Language, available for free!

I currently use JetBrains Mono in both my terminal and my text editor. I also like Source Code Pro.

Collapse
 
peerreynders profile image
peerreynders

I recommend starting with The Rust Programming Language, available for free!

This recommendation is very common but the typical audience will usually be disappointed about the level of competence that will be attained after working through a ~500 page tome.

I think this old tweet gives a clearer account of the journey to basic Rust competence:

IMPORTANT: when you are done reading this book you might think “I get it now” but really, you don’t. The official book does not go nearly deep enough — it’s a great intro but after this you will be smack in the middle of the “got the gist” phase.

1 The Rust Programming Language
2 Rust By Example
3 Programming Rust (Another ~600 pages)
4 This is where you start writing your own code.

Also:

Thread Thread
 
deciduously profile image
Ben Lovy • Edited

Sure, Rust is a complicated topic, but I still recommend starting there. You have to start somewhere. It worked for me, at least.

Collapse
 
qm3ster profile image
Mihail Malo

Pls don't call "spawned tasks" "threads".
Think of the children, this will absolutely destroy them.

Collapse
 
deciduously profile image
Ben Lovy

Whoops, absolutely correct thanks! I'll fix the post :)