Can we move fast building rust web applications? Being pragmatic matters, this is my attempt to show how (or if?) we can take Rust's recent ecosystem growth to practical applications.

First things first

If you are unfamiliar with Rust, it's a fun and long journey that starts here: https://rustup.rs/. Follow along to install Rust and Cargo.

The remaining content assumes you are a beginner like me, master of copy/paste, but knows what Cargo is.

We'll use a bunch of Rust libraries to write our API.

axum is a web application framework that focuses on ergonomics and modularity.

Standing on the shoulders of:

tokio is a runtime for writing reliable network applications without compromising speed.

hyper is a fast and correct HTTP implementation written in and for Rust.

tower Tower is a library of modular and reusable components for building robust networking clients and servers.

As you can read, lots of nice adjectives.

What are we doing anyway?

We'll build a Hello World, with a test. 🥱 so boring. Structuring the test is the most important thing here.

Getting started

> cargo new --bin api_project
> cd api_project
> mkdir tests
> touch src/lib.rs

By the way, cargo new will add the Cargo.toml file where we manage dependencies and a main.rs file that is the binary entrypoint.

The first thing we want is the ability to create integration tests to ensure our application is doing what is supposed to, as opposed to a simple it compiles, the lib.rs is what makes the magic for our binary project.

Let's add some dependencies to Cargo.toml, we'll keep tokio with full features for the sake of this experiment, same with tower as we need it sooner then we think.

[dependencies]
axum = "0.4"
tokio = { version = "1", features = ["full"] }
tower = { version = "0.4", features = ["full"] }

Our lib.rs initially will have a public function to retrieve an axum router.

use axum::{routing::get, Router};

pub fn application() -> Router {
    // Here we are creating a new Router with a route /health, that has a get handler
    Router::new().route("/health", get(health))
}

// This is the handler for the /health endpoint, the return type is a string slice that I purposely pronounce 'string'.
async fn health() -> &'static str {
    "OK"
}

Cool, now let's write a test.

> cd tests
> mkdir shared
> touch shared/mod.rs
> touch health.rs

The shared/mod.rs will look like this, and this is so we can share between many test cases.

use axum::Router;

pub fn test_app() -> Router {
    api_project::application()
}

Now, back to tests/health.rs, we'll make this compile and comment line by line.

use axum::{http::{StatusCode, Request}, body::Body};
use tower::ServiceExt;

mod shared;

#[tokio::test]
async fn test_health_endpoint() {
    let app = shared::test_app();
    let request = Request::get("/health");
    
    let request = request.body(Body::empty()).unwrap();
    let response = app.oneshot(request).await.unwrap();
    
    assert_eq!(response.status(), StatusCode::OK);
}

We are adding axum re-exports of http Request, Body and StatusCode that are used throughout the code. The #[tokio::test] is a macro to assign the async function to run using tokio runtime.

We're getting an instance of the Router through our shared module;

Then we build a GET request to /health;

We rebind (because we no longer need the builder) with let request making our request builder become a request to /health with an empty body.

Then things can get a bit interesting, we need to run the request through the router and get a response. Wat?

If you see again, we added a use tower::ServiceExt; at the top, which applies a bit of extra functionality to structs implementing tower::Service, and axum::Router does it. So we can use the Oneshot utility function.

With oneshot, we can now have a response like if it was ran throuh an actual server and we can play with its result.

Back to the terminal, let's run cargo test and see how it goes:

> cargo test

     Running tests/health.rs (target/debug/deps/health-a801109dcc6aab35)

running 1 test
test test_health_endpoint ... ok

Now, we know it works, but for the sake of clarity, we should expand a little bit more on this specific line:

let request = request.body(Body::empty()).unwrap();
let response = app.oneshot(request).await.unwrap();

In Rust, handling errors is mainly(or maybe not, it depends) done by the Result type, that contains a successful value or an error. .unwrap() consumes the success or terminates the current thread. Quite reasonable for a test suite, right?

let request = request.body(Body::empty()).unwrap(); is creating a Request<Body> that can be consumed by oneshot.

let response = app.oneshot(request).await.unwrap(); is calling oneshot with the request, in return it will give a future, and to briefly explain what that is, it is something that can run asynchronously. A future by itself does nothing unless we await on them, that's the keyword kicking in to execute the future (inside tokio because of that nice macro at the top of the test), then unwrapping its Ok value.

Phew, finally we have the contents of the response.

This is really basic, but we can now replicate the same thing to other test cases and start building easier abstractions to make tests a more pleasant experience.

But where is the borrow checker, and and lifetimes? 😱! I left out several Rust concepts from this because what I would like to show is that we can do things that are somewhat easy to explain.

Running the server

So far, we worked in the lib.rs and tests folder.

Le's make use of our application in the main.rs and run the server:

use std::net::SocketAddr;

#[tokio::main]
async fn main() -> Result<(), tower::BoxError> {
    let app = api_project::application();

    let address: SocketAddr = "[::0]:4000".parse()?;

    println!("Listening on {}", address);

    axum::Server::bind(&address)
        .serve(app.into_make_service())
        .await?;

    Ok(())
}

The question mark operator (?) unwraps valid values or returns errornous values. This is an elegant way of improving readability as long as the code is prepared to handle the Result type.

Back to the terminal, on the root folder where Cargo.toml is, run cargo run and we should see something like:

Finished dev [unoptimized + debuginfo] target(s) in 0.03s
     Running `target/debug/api_project`
Listening on [::]:4000

Cool, you can hit the endpoint in the browser or other client.

Error handling

A very important piece of an API is how we give back information to API consumers of something that did not go well with the request.

Let's dig a bit how Axum helps in that front.