Introduction

axum is a web framework to implement HTTP servers. It is to Rust what Flask is to Python which means a toolbox rather than a ready-made shed.

These notes give you examples how to approach common tasks either directly with axum or best-in-class third-party dependencies.

Handling Ctrl-C gracefully

axum re-exports hyper's Server which provides the with_graceful_shutdown method that is easily combined with tokio's signal::ctrl_c function (available under the signal feature flag):

axum::Server::bind(&addr)
    .serve(app.into_make_service())
    .with_graceful_shutdown(async {
        tokio::signal::ctrl_c().await.expect("failed to listen to ctrl-c");
    })
    .await?;

Offloading computation

Dependencies

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

Code

use axum::extract::{Extension, Path};
use axum::response::Json;
use axum::routing::get;
use tokio::sync::{mpsc, oneshot};

// Commands send to the processor. We use the oneshot sender to "call back" to the peer who send us
// the command.
enum Command {
    Sleep {
        secs: u64,
        // Using oneshot::Sender<Result<_>> we can propagate errors back to the caller
        tx: oneshot::Sender<Result<u64, ()>>,
    },
}

async fn process_compute_request(mut rx: mpsc::Receiver<Command>) {
    // Note, that this serializes incoming requests. If you want to handle requests in parallel,
    // you have to spawn tasks once more.
    while let Some(command) = rx.recv().await {
        match command {
            Command::Sleep { secs, tx } => {
                let _ = tx.send(Ok(secs + 23));
            }
        }
    }
}

async fn compute_complex(
    Path(secs): Path<u64>,
    Extension(command_tx): Extension<mpsc::Sender<Command>>,
) -> Json<u64> {
    // Construct a oneshot channel to receive the result from the processor.
    let (tx, rx) = oneshot::channel();

    // Send the command carrying the payload as well as the result sender.
    let _ = command_tx.send(Command::Sleep { secs, tx }).await;

    // Wait for the result to be returned by the processor.
    let result = rx.await.unwrap().unwrap();

    Json(result)
}

async fn compute_simple(Path(secs): Path<u64>) -> Json<u64> {
    println!("asked to sleep for {secs} secs");

    // Spawn an async task on a separate thread to avoid blocking the async run-time.
    let result = tokio::task::spawn_blocking(move || {
        // Unlike tokio::time::sleep, this one blocks the current thread.
        std::thread::sleep(std::time::Duration::from_secs(secs));
        secs + 42
    })
    .await
    .unwrap();

    println!("returned after {secs} secs");

    Json(result)
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let (tx, rx) = mpsc::channel(128);

    let app = axum::Router::new()
        .route("/compute/simple/:secs", get(compute_simple))
        .route("/compute/complex/:secs", get(compute_complex))
        .layer(Extension(tx));

    let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 3000));

    let server = tokio::task::spawn(async move {
        axum::Server::bind(&addr)
            .serve(app.into_make_service())
            .await
            .unwrap();
    });

    let processor = tokio::task::spawn(async move {
        process_compute_request(rx).await;
    });

    let (_, _) = tokio::join!(server, processor);

    Ok(())
}

Run

Start the server with

cargo run --bin offloading_computation

Templating using askama

Askama implements a type-safe, Jinja-like template engine. While the templating engine itself is independent of any particular application, the askama_axum crate provides additional integration points by converting template results to response objects. Because Askama evaluates templates at compile time, it is both safe to use and fast to evaluate. On the other hand, it cannot be fed with templates by users at run-time.

Dependencies

[dependencies]
askama = { git = "https://github.com/djc/askama", features = ["with-axum"] }
askama_axum = { git = "https://github.com/djc/askama" }
axum = "0.5"
tokio = { version = "1", features = ["full"] }

Code

First create a new sibling directory templates next to the src directory and add an index.html:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Askama</title>
  </head>

  <body>
    <p>Hello {{ name }}, you are {{ age }} years old.</p>
  </body>
</html>

Note that we refer to the two template variables name and age. Thanks to the compile-time guarantees of askama, the compiler will complain if we do not derive a template that contains these variables:

use askama::Template;
use axum::extract::Path;
use axum::routing::get;
use axum::Server;

#[derive(Template)]
#[template(path = "index.html")]
struct HtmlTemplate {
    name: String,
    age: u8,
}

async fn index(Path((name, age)): Path<(String, u8)>) -> HtmlTemplate {
    HtmlTemplate { name, age }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = axum::Router::new().route("/:name/:age", get(index));

    Server::bind(&"0.0.0.0:8080".parse()?)
        .serve(app.into_make_service())
        .await?;

    Ok(())
}

Run

Start the server with

cargo run --bin templating-with-askama

Now, lets try to get /john/32:

$ curl -i http://127.0.0.1:8080/john/32
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
content-length: 167
date: Tue, 08 Mar 2022 20:13:10 GMT

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Askama</title>
  </head>

  <body>
    <p>Hello john, you are 32 years old.</p>
  </body>
</html>

Database access with SQLx

SQLx is an async-first SQL crate to access various SQL databases. While compile-time verification of queries is one of the outstanding features, we will use ordinary queries here to show how to use it together with axum.

Dependencies

[dependencies]
axum = { version = "0" }
serde = "1"
serde_json = "1"
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "sqlite", "macros"] }
tokio = { version = "1", features = ["full"] }

Code

In this example, we will implement a super simple blog backend, consisting of posts with a title and some content string. We accept two routes /api/post and /api/posts/, the latter used to query all existing posts and to add a new one.

As usual, import some used modules first:

use axum::extract::{Extension, Path};
use axum::response::Json;
use axum::routing::get;
use serde::{Deserialize, Serialize};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use sqlx::{ConnectOptions, FromRow};
use std::str::FromStr;
use std::sync::Arc;

Now, we define a small helper struct that keeps the connection pool alive as well as an implementation to set up the (in-memory SQLite) database:

/// Database object encapsulating the connection pool and providing convenience functions.
struct Database {
    pool: SqlitePool,
}

impl Database {
    pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
        let db_options = SqliteConnectOptions::from_str(":memory:")?
            .create_if_missing(true)
            .disable_statement_logging()
            .to_owned();

        let pool = SqlitePoolOptions::new().connect_with(db_options).await?;

        sqlx::query(
            "CREATE TABLE IF NOT EXISTS posts (
                id INTEGER PRIMARY KEY,
                title TEXT NOT NULL,
                content NOT NULL
            );",
        )
        .execute(&pool)
        .await?;

        Ok(Self { pool })
    }
}

Let's define a struct to represent a post and add the handlers to insert and query posts:

#[derive(FromRow, Serialize, Deserialize)]
struct Post {
    title: String,
    content: String,
}

async fn get_post(Path(id): Path<i64>, Extension(db): Extension<Arc<Database>>) -> Json<Post> {
    Json(
        sqlx::query_as::<_, Post>("SELECT title, content FROM posts WHERE id=?")
            .bind(id)
            .fetch_one(&db.pool)
            .await
            .unwrap(),
    )
}

async fn add_post(Extension(db): Extension<Arc<Database>>, Json(post): Json<Post>) {
    sqlx::query("INSERT INTO posts (title, content) VALUES (?, ?);")
        .bind(post.title)
        .bind(post.content)
        .execute(&db.pool)
        .await
        .unwrap();
}

async fn posts(Extension(db): Extension<Arc<Database>>) -> Json<Vec<Post>> {
    Json(
        sqlx::query_as::<_, Post>("SELECT title, content FROM posts")
            .fetch_all(&db.pool)
            .await
            .unwrap(),
    )
}

As you can see we can re-use the same struct (and benefit from type-safety guarantees) for both serialization and deserialization in the database as well as for sending and receiving posts to and from the client by deriving the appropriate traits.

All that's left is setting up the server itself:

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let state = Arc::new(Database::new().await?);

    let app = axum::Router::new()
        .route("/api/posts", get(posts).post(add_post))
        .route("/api/posts/:id", get(get_post))
        .layer(Extension(state));

    let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 3000));

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

    Ok(())
}

Run

Start the server with

cargo run --bin with-sqlx

and add posts with

curl -X POST -H 'Content-Type: application/json' -d '{"title": "Hello", "content": "World"}' http://127.0.0.1:3000/api/posts

Database access with axum-sqlx-tx

axum-sqlx-tx is an alternative, layer-based approach to using SQLx directly. One big advantage is that transactions are committed or rolled back automatically if any of the inner requests succeed or return an error.

Dependencies

[dependencies]
axum = { version = "0.5" }
axum-sqlx-tx = { git = "https://github.com/wasdacraic/axum-sqlx-tx", features = ["sqlite"] }
serde = "1"
serde_json = "1"
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "sqlite", "macros"] }
tokio = { version = "1", features = ["full"] }

Code

Unlike before, we do not have to manage the SQL pool in an std::Arc<> ourselves, hence it is enough to just create the pool

async fn new_pool() -> Result<SqlitePool, Box<dyn std::error::Error>> {
    let db_options = SqliteConnectOptions::from_str(":memory:")?
        .create_if_missing(true)
        .disable_statement_logging()
        .to_owned();

    let pool = SqlitePoolOptions::new().connect_with(db_options).await?;

    sqlx::query(
        "CREATE TABLE IF NOT EXISTS posts (
            id INTEGER PRIMARY KEY,
            title TEXT NOT NULL,
            content NOT NULL
        );",
    )
    .execute(&pool)
    .await?;

    Ok(pool)
}

and add the layer to the routes:

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = axum::Router::new()
        .route("/api/posts", get(posts).post(add_post))
        .route("/api/posts/:id", get(get_post))
        .layer(axum_sqlx_tx::Layer::new(new_pool().await?));

    let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 3000));

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

    Ok(())
}

Because the Tx type implements sqlx' executor interface, all we need to change is the type of the parameter:

async fn get_post(Path(id): Path<i64>, mut tx: Tx<Sqlite>) -> Json<Post> {
    Json(
        sqlx::query_as::<_, Post>("SELECT title, content FROM posts WHERE id=?")
            .bind(id)
            .fetch_one(&mut tx)
            .await
            .unwrap(),
    )
}

async fn add_post(mut tx: Tx<Sqlite>, Json(post): Json<Post>) {
    sqlx::query("INSERT INTO posts (title, content) VALUES (?, ?);")
        .bind(post.title)
        .bind(post.content)
        .execute(&mut tx)
        .await
        .unwrap();
}

async fn posts(mut tx: Tx<Sqlite>) -> Json<Vec<Post>> {
    Json(
        sqlx::query_as::<_, Post>("SELECT title, content FROM posts")
            .fetch_all(&mut tx)
            .await
            .unwrap(),
    )
}

Run

Start the server with

cargo run --bin with-sqlx-tx

and add posts with

curl -X POST -H 'Content-Type: application/json' -d '{"title": "Hello", "content": "World"}' http://127.0.0.1:3000/api/posts

Authorization using tower-http

In this example we will use tower-http's auth module to add a custom authorization layer. There are also two builtin bearer and password layers however since both bearer token and username and password combo are set at compile time, the use is somewhat limited.

In this case we implement the AuthorizeRequest trait for our custom Auth struct, which requires to set the appropriate ResponseBody (which in axum's case must be axum::body::BoxBody) as well as an authorize method. Here, based on the request and any data on the struct itself we can make authorization decisions. In this example we extract the Authorization header field and compare it with a pre-defined value. If it matches we can return Ok(()) and the route is taken otherwise we return an error response.

Dependencies

[dependencies]
axum = "0.5"
tower-http = { version = "0.2", features = ["auth"] }
tokio = { version = "1", features = ["full"] }

Code

use axum::body::BoxBody;
use axum::http::{header, Request, StatusCode};
use axum::response::Response;
use axum::routing::get;
use axum::{Router, Server};
use tower_http::auth::{AuthorizeRequest, RequireAuthorizationLayer};

#[derive(Clone)]
struct Auth {
    expected: String,
}

impl<B> AuthorizeRequest<B> for Auth {
    type ResponseBody = BoxBody;

    fn authorize(&mut self, request: &mut Request<B>) -> Result<(), Response<Self::ResponseBody>> {
        if let Some(header) = request.headers().get(header::AUTHORIZATION) {
            if header == &self.expected {
                return Ok(());
            }
        }

        let response = Response::builder()
            .status(StatusCode::UNAUTHORIZED)
            .body(BoxBody::default())
            .unwrap();

        Err(response)
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Router::new().route("/", get(|| async { "hello" })).layer(
        RequireAuthorizationLayer::custom(Auth {
            expected: "lol".to_string(),
        }),
    );

    Server::bind(&"0.0.0.0:8080".parse()?)
        .serve(app.into_make_service())
        .await?;

    Ok(())
}

Run

Start the server with

cargo run --bin auth-with-tower-http

Trying to get the root route without setting an authorization header results in a 401:

$ curl -i http://127.0.0.1:8080
HTTP/1.1 401 Unauthorized
content-length: 0
date: Tue, 08 Mar 2022 19:57:15 GMT

On the other hand, setting Authorization appropriately succeeds:

$ curl -i -H "Authorization: lol" http://127.0.0.1:8080
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 5
date: Tue, 08 Mar 2022 19:59:15 GMT

hello

Serve static data from the binary

For very small (micro) services it can come in handy to just distribute a single binary containing both code and data such as CSS or Javascript and avoid the hassle of dealing with paths, permissions, deployment etc. In this example we use the handy include_dir crate to bundle a directory of data within the compiled binary and the mime_guess crate to guess a MIME type based on the served file.

Dependencies

[dependencies]
axum = "0.5"
include_dir = "0"
mime_guess = "2"
tokio = { version = "1", features = ["full"] }

Code

First of all create a static directory next to the src directory and add this sample CSS file foo.css:

body {
    background-color: #ccc;
}

The interesting part of the code are the route which matches any path prefixed /static and the handler. In the handler we first strip the initial slash (because that would not match with get_file) and then just try to load the file. If we have a match, we try to guess a suitable MIME type and return it, otherwise we just return a 404:

use axum::body::{self, Empty, Full};
use axum::extract::Path;
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Router, Server};
use include_dir::{include_dir, Dir};

static STATIC_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static");

async fn static_path(Path(path): Path<String>) -> impl IntoResponse {
    let path = path.trim_start_matches('/');
    let mime_type = mime_guess::from_path(path).first_or_text_plain();

    match STATIC_DIR.get_file(path) {
        None => Response::builder()
            .status(StatusCode::NOT_FOUND)
            .body(body::boxed(Empty::new()))
            .unwrap(),
        Some(file) => Response::builder()
            .status(StatusCode::OK)
            .header(
                header::CONTENT_TYPE,
                HeaderValue::from_str(mime_type.as_ref()).unwrap(),
            )
            .body(body::boxed(Full::from(file.contents())))
            .unwrap(),
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Router::new().route("/static/*path", get(static_path));

    Server::bind(&"0.0.0.0:8080".parse()?)
        .serve(app.into_make_service())
        .await?;

    Ok(())
}

Run

Start the server with

cargo run --bin serve-static-from-binary

and it will serve /static/foo.css as expected

$ curl http://127.0.0.1:8080/static/foo.css
body {
    background-color: #ccc;
}