Patissiere


I want to work on a rust web app. A pastebin is a simple project that I should be able to complete before I get bored of it. (unmedicated ADHD lets gooooo!) I was told of axum, which seems to be a pretty easy way to get a webapp going.

According to the docs, our hello world with axum goes something like:

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

#[tokio::main]
async fn main() {
    // build our application with a single route
    let app = Router::new().route("/", get(|| async { "Hello, World!" }));

    // run our app with hyper, listening globally on port 3000
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

That’s kind of a lot! I’m pretty new to rust, but this doesn’t seem too bad. Let’s take a look at what that does.

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

We start off with importing two things from the axum crate: Router (the app itself) and get (a helper that says “handle this route with a GET request”).

#[tokio::main]
async fn main() {

async fn main() is pretty straightforward, we just make main an async fucntion. Rust doesn’t support that natively, so #[tokio::main] is a macro that wraps it. This spins up the Tokio async runtime and runs main inside it.

let app = Router::new().route("/", get(|| async { "Hello, World!" }));

Okay, this is the first juicy line, I think. We create a new Router and register one route: GET /. The handler is a closure that’s an async block returning a plain string.

Axum can turn a &str into a 200 reponse magically! Okay, next…

let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();

This opens a TCP socket bound to port 3000 on all interfaces (0.0.0.0). .await waits for the OS to actually bind it. .unwrap() panics if it fails.

axum::serve(listener, app).await.unwrap();

This hands over both the things we just made to axum’s serve function, which just loops forever, accepting connections and dispatching requests to routes. .await suspends here, this is the blocking “run forever” call.

Okay! So that’s simple enough, the overall flow goes define routes -> bind a port -> serve until die

That’s a lot of the magic handled for me! Routers seem like the heart of axum, so let’s take a closer look at the example currently in the docs for routing:

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

// our router
let app = Router::new()
    .route("/", get(root))
    .route("/foo", get(get_foo).post(post_foo))
    .route("/foo/bar", get(foo_bar));

// which calls one of these handlers
async fn root() {}
async fn get_foo() {}
async fn post_foo() {}
async fn foo_bar() {}

Honestly, that’s super straightforward. I’m sure there are ways of getting segments out of the path, as well. web search Okay! So, with axum you can match and capture with /:. Neat! Okay, I have a good enough idea of how to use axum to get started, I think. Now,

what do I need to do for a pastebin?

  • index
  • paste page
  • paste raw page

pastry.toasterdragon.com/ is going to be the main page / form. Then pastry.toasterdragon.com/abc123 will be how the paste is accessed. That will be a pretty page, but it’s tradition to allow access to the raw file as well. That can be at pastry.toasterdragon.com/abc123/raw. Easy enough, that’s 3 routes. Oh, actually, I’ll need two routes for the index, one for get for sending the index to the user and one for post to get the paste data from the user.

Three pages, that’s not too bad. And one of them is just a raw text file, so really it’s just two html templates to make. Not bad at all!

Okay, routes.


    let app = Router::new()
        .route("/", get(index))
        .route("/", post(create_paste))
        .route("/:id", get(get_paste))
        .route("/:id/raw", get(get_paste_raw))

That… should do it. Okay, what do our handlers need to look like? create_paste is going to be the most fun, so let’s start with that. We’re going to need to get some data (the paste) out of the request. Luckily, we can easily extract that info with axum’s extractor.

async fn create_paste(
    State(state): State<AppState>,  
    body: String,                    
) -> Response {
    let id = Uuid::new_v4().to_string()[..8].to_string();
    let path = state.paste_dir.join(&id);
    fs::write(&path, &body).await.unwrap();
    (StatusCode::SEE_OTHER, [("location", format!("/{id}"))]).into_response()
}

Get the state (to get the path for the paste) and the body of the paste, generate a uuid and shorten it to get a random 8 digit id, then write the paste body to the path we generated. SEE_OTHER is the redirect code for after a POST. It tells the browser to GET the new URL instead of resubmitting the form if the user refreshes.

This is surprisingly straightforward.

index is very simple. It’s the same for everyone, so we can bake the HTML into the binary with include_str!:

async fn index() -> Html<&'static str> {
    Html(include_str!("../templates/index.html"))
}

Almost boring. Though, this is the first time we’re going to use a template HTML file.

async fn get_paste(
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> Response {
    let path = state.paste_dir.join(&id);
    match fs::read_to_string(&path).await {
        Ok(content) => Html(format!(
            include_str!("../templates/paste.html"),
            id = id,
            content = content
        )).into_response(),
        Err(_) => (StatusCode::NOT_FOUND, "paste not found").into_response(),
    }
}

Okay, so Path(id): Path<String> is what grabs that :id from earlier. If someone visits /abc123, id will be “abc123”. This is where the other template we’re using comes in. match fs::read_to_string(&path).await {} tries to read a file. Returns a Result so that we can match on it. If the file doesn’t exist, give em a 404. Otherwise, format the paste with the template and return it. format!(include_str!("../templates/paste.html"), id = id, content = content) is neat. We embed the template at compile time with include_str!, then use format! at runtime to substitute {id} and {content} placeholders in the template with actual values. I thought create_paste was going to be more complicated, but this is actually more to consider.

Okay, next:

async fn get_paste_raw(
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> Response {
    let path = state.paste_dir.join(&id);
    match fs::read_to_string(&path).await {
        Ok(content) => (
            [("content-type", "text/plain; charset=utf-8")],
            content
        ).into_response(),
        Err(_) => (StatusCode::NOT_FOUND, "paste not found").into_response(),
    }
}

Pretty similar to before, we just don’t have to format it. Since we’re sending a raw text file, we tell the browser with content-type. Here, text/plain is perfect. We still want to 404 if there’s no paste.

Okay~ That’s pretty much it. I’ve created the HTML files and placed them where they need to go.

cargo run success

Okay! It works. It says it’s listening at 3000. Let’s check in a browser:

firefox showing patissiere

Great! Now let’s try pasting something. I’ll grab my fuzzel config for testing.

fuzzel config in patissiere

Fantastic! I’m sure it’ll work, but let’s check the raw output just in case I goofed somewhere.

raw paste output

Nice! That feels like a great stopping point for now. In the future, I’ll get this running on the server and get patissiere.toasterdragon.com pointed at it. In the meantime, you can check the code at https://git.toasterdragon.com/butter/patissiere

I’m genuinely surprised at how quickly this came together. Axum is an absolute treat!

r u gay? toasterdragon.com