Axum By Example (Static File Server)

Cover Image for Axum By Example (Static File Server)
Justin Bender
Justin Bender
Contents

Axum By Example (Static File Server)

🦀 One of the best ways to understand code is to read more of it.

I would like to take a second to welcome you to this article. I hope you're having a great day. Be safe and have fun!

axum examples

Static File Server - axum examples

It seems that there is something wrong with this example. When I run the server and start the project. When there is an attempt to access localhost:3001 nothing shows up. So we will have to work with an idea. We will just start going through the code to understand it. That's the only way that we can figure it out. In this example, it is going to be simplified to one route. So we can debug a smaller project before we build it back out.

🛑 The problem was where I was starting the program. I was starting it in the axum directory. When I needed to be in the axum/example/static-file-server/ to allow for the relative path to be in the correct location. It works fine. The problem was me 👀

//! Run with
//!
//! ```not_rust
//! cd axum/examples/static-file-server
//! cargo run
//! ```

use axum::{
    extract::Request, handler::HandlerWithoutStateExt, http::StatusCode, routing::get, Router,
};
use std::net::SocketAddr;
use tower::ServiceExt;
use tower_http::{
    services::{ServeDir, ServeFile},
    trace::TraceLayer,
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

#[tokio::main]
async fn main() {
    tracing_subscriber::registry()
        .with(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "example_static_file_server=debug,tower_http=debug".into()),
        )
        .with(tracing_subscriber::fmt::layer())
        .init();

    tokio::join!(
        serve(using_serve_dir(), 3001),
    );
}

fn using_serve_dir() -> Router {
    // serve the file in the "assets" directory under `/assets`
    Router::new().nest_service("/assets", ServeDir::new("assets"))
}

async fn serve(app: Router, port: u16) {
    let addr = SocketAddr::from(([127, 0, 0, 1], port));
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    tracing::debug!("listening on {}", listener.local_addr().unwrap());
    axum::serve(listener, app.layer(TraceLayer::new_for_http()))
        .await
        .unwrap();
}

Breaking down the protect

  • imports
  • main function
  • directory service path
  • serve function

Imports

  • axum: extractors, handlers, statusCodes, get, and Router
  • SocketAddr from std::net
  • tower
  • tower_http: serving directory, and serving file
  • tracing_subscriber: Part of a larger tracing ecosystem. It provides a framework for structured, composable logging and diagnostics in Rust applications. It helps configure and set up tracing instrumentation in Rust projects. It provides various subscriber implementations, which are responsible for receiving tracing events and processing them in different ways. Printing to the console, writing to a file, or sending to a distributed tracing system. We can customize the logging behavior and choose which subscribers to use in the application.
use axum::{
    extract::Request, handler::HandlerWithoutStateExt, http::StatusCode, routing::get, Router,
};
use std::net::SocketAddr;
use tower::ServiceExt;
use tower_http::{
    services::{ServeDir, ServeFile},
    trace::TraceLayer,
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

Main function

  • #[tokio::main]
  • tracing setup
  • tokio::join! the paths together
  • calling out serve function
#[tokio::main]
async fn main() {
    tracing_subscriber::registry()
        .with(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "example_static_file_server=debug,tower_http=debug".into()),
        )
        .with(tracing_subscriber::fmt::layer())
        .init();

    tokio::join!(
        serve(using_serve_dir(), 3001),
    );
}

Setting up basic serve directory

  • Router pointing to the assets directory
  • Router::new().nest_service where we connect the /assets directory to the ServeDir::new("assets") for a simple example.
fn using_serve_dir() -> Router {
    // serve the file in the "assets" directory under `/assets`
    Router::new().nest_service("/assets", ServeDir::new("assets"))
}

Setting up our serve function

  • async
  • pulling out the addr using SocketAddr and the input port
  • Print where we are listening
  • Attach the layer to the server.
  • TraceLayer::new_for_http creates a new TraceLayer using ServerErrorAsFailures which supports classifying regular HTTP request based on the status code.
async fn serve(app: Router, port: u16) {
    let addr = SocketAddr::from(([127, 0, 0, 1], port));
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    tracing::debug!("listening on {}", listener.local_addr().unwrap());
    axum::serve(listener, app.layer(TraceLayer::new_for_http()))
        .await
        .unwrap();
}

Running the server and checking it out

You must be in the directory where /assets lives. So the folder where you can access static-file-server/src/main.rs is a good place if you're using the example. Or just in the relative directory as the /assets.

We can start the program with cargo run. Then run some sure commands to grab the files we have in the /assets directory.

Curl Request Examples

  • curl /assets/index.html
❯ curl http://localhost:3001/assets/index.html
Hi from index.html
  • curl /assets/script.js
❯ curl http://localhost:3001/assets/script.js
console.log("Hello, World!");

This is a good start. There are more examples in this we will now go over. We just wanted to focus on what makes this work at the bare minimum.

Let's add the new routes.

#[tokio::main]
async fn main() {
    // same as before we are just expanding the `tokio::join!`

    tokio::join!(
        serve(using_serve_dir(), 3001),
        serve(using_serve_dir_with_assets_fallback(), 3002),
        serve(using_serve_dir_only_from_root_via_fallback(), 3003),
        serve(using_serve_dir_with_handler_as_service(), 3004),
        serve(two_serve_dirs(), 3005),
        serve(calling_serve_dir_fron_a_handler(), 3006),
    );
}

Using serve dir with assets fallback

We are going to use a fallback of this next service. If we don't choose a path to traverse. We will go to assets/index.html.

  • Setup the serve_dir with ServeDir::new("assets")
  • Add on a not_found_service() with ServerFile::new("assets/index.html")
  • Add a new route called /foo that returns Hi from /foo
  • We nest the service with /assets and our serve_dir
  • We add out .fallback_service with the serve_dir which will handle our not found case
fn using_serve_dir_with_assets_fallback() -> Router {
    // `ServeDir` allows setting a fallback if an asset is not found
    // so with this `GET /assets/doesnt-exist.jpg` will return `index.html`
    // rather than a 404
    let serve_dir = ServeDir::new("assets").not_found_service(ServeFile::new("assets/index.html"));

    Router::new()
        .route("/foo", get(|| async { "Hi from /foo" }))
        .nest_service("/assets", serve_dir.clone())
        .fallback_service(serve_dir)

}

Curl Request Examples

We will test this one as well with some curl requests.

  • curl /foo
❯ curl http://localhost:3002/foo
Hi from /foo
  • curl the port 3002
❯ curl http://localhost:3002
Hi from index.html
  • curl /assets/script.js
❯ curl http://localhost:3002/assets/script.js
console.log("Hello, World!");

It's a very simple design for a simple file server, but is it enough? I don't think so. We have a few more route styles to check out.

Using serve dir only from root via fallback

What are we changing on this path compared to the last path. We are avoiding the nest_service section where we attach the serve_dir to the path. We only include in in the fallback_service. Which still allows us to access these files. It just does not keep the files behind the localhost:3003/assets/script.js anymore. It goes to localhost:3003/script.js.

  • The fallback is still index.html
  • /foo path works
  • /script.js works
  • /index.html works
fn using_serve_dir_only_from_root_via_fallback() -> Router {
  // you can also serve the assets directly from the root (not nested under `/assets`)
  // by only setting a `ServeDir` as the fallback
  let serve_dir = ServeDir::new("assets").not_found_service(ServeFile::new("assets/index.html"));

  Router::new()
    .route("/foo", get(|| async { "Hi from /foo" }))
    .fallback_service(serve_dir)
}

Curl Request Examples

Let's make some curl requests to show what's actually going on.

  • curl the port 3003
❯ curl http://localhost:3003
Hi from index.html
  • curl /foo
❯ curl http://localhost:3003/foo
Hi from /foo
  • curl /assets
❯ curl http://localhost:3003/assets/
Hi from index.html
  • curl /assets/script.js
❯ curl http://localhost:3003/assets/script.js
Hi from index.html
  • curl /script.js
❯ curl http://localhost:3003/script.js
console.log("Hello, World!");

Using serve dir with handler as service

  • Create the handle_404 handler
  • Convert the handle_404 into a service using into_service()
  • Create a serve_dir that set's up the assets path
  • Attach a not_found_service() with the converted handler service

The assets will work on the root path and anything not found will return the created handler.

fn using_serve_dir_with_handler_as_service() -> Router {
  async fn handle_404() -> (StatusCode, &'static str) {
    (StatusCode::NOT_FOUND, "Not found")
  }

  // you can convert handler function to service
  let service = handle_404.into_service();

  let serve_dir = ServeDir::new("assets").not_found_service(service);

  Router::new()
    .route("/foo", get(|| async { "Hi from /foo" }))
    .fallback_service(serve_dir)
}

Curl Request Examples

  • curl port 3004
❯ curl http://localhost:3004
Hi from index.html
  • curl /foo
❯ curl http://localhost:3004/foo
Hi from /foo
  • curl /assets/script.js
❯ curl http://localhost:3004/assets/script.js
Not found
  • curl /script.js
❯ curl http://localhost:3004/script.js
console.log("Hello, World!");
  • curl /assets/index.html
❯ curl http://localhost:3004/assets/index.html
Not found

Two serve dirs

  • Create serve_dir_from_assets to point to the assets too
  • Create serve_dir_from_dist to point to the dist too

There is a problem here. Can you catch it before I tell you? It's not super easy to catch because it involved to file system that we are using.

  • What is the dist relative path?
  • What is /dist connecting to?

It seems like there is nothing here for dist. We don't have the directory so we cannot really connect /dist to nothing. It's just going to respond with a 404.

We should add a /dist into our Rust🦀 example. We will also add a dist.html file inside that returns. Hello from /dist/dist.html

fn two_serve_dirs() -> Router {
  // you can also have two `ServeDir`s nested at different paths
  let serve_dir_from_assets = ServeDir::new("assets");
  let serve_dir_from_dist = ServeDir::new("dist");

  Router::new()
    .nest_service("/assets", serve_dir_from_assets)
    .nest_service("/dist", serve_dir_from_dist)
}

Curl Request Examples

Let's do those curls

  • curl /dist/dist.html
❯ curl http://localhost:3005/dist/dist.html
Hello from /dist/dist.html
  • curl /assets/script.js
❯ curl http://localhost:3005/assets/script.js
console.log("Hello, World!");
  • curl /assets/index.html
❯ curl http://localhost:3005/assets/index.html
Hi from index.html
  • curl /assets: this works because it's looking for an index.html to return
❯ curl http://localhost:3005/assets
Hi from index.html
  • curl /dist: this does not work because the html is not the index.html
❯ curl http://localhost:3005/dist

If we make a new file inside of the /dist directory called index.html

  • curl /dist again
❯ curl http://localhost:3005/dist
Hello from dist.html

Calling serve dir from a handler

  • I'm not sure what #[allow(clippy::let_and_return)] does. I'm not sure if it is necessary. The program seems to run with or without it.
  • Consume the service with oneshot with the request as the input value

Not completely sure about these different use cases. It seems as if it's the same type of setup. Except you are creating a service and returning inside of the /foo. We could get similar results for the functions previously. Although maybe we want to be able to do something inside of the nest_service get request.

#[allow(clippy::let_and_return)]
fn calling_serve_dir_from_a_handler() -> Router {
  // via `tower::Service::call`, or more conveniently `tower::ServiceExt::oneshot you can
  // call `ServeDir` yourself from a handler
  Router::new().nest_service(
    "/foo",
    get(|request: Request| async {
      let service = ServeDir::new("assets");
      let result = service.oneshot(request).await;
      result
    }),
  )
}

Curl Request Examples

  • curl our port 3006 and nothing returns
❯ curl http://localhost:3006

  • curl /foo
❯ curl http://localhost:3006/foo
Hi from index.html
  • curl /foo/script.js
❯ curl http://localhost:3006/foo/script.js
console.log("Hello, World!");
  • curl /foo/index.html which returns the same as /foo
❯ curl http://localhost:3006/foo/index.html
Hi from index.html
  • curl the non-existing /assets/index.html
❯ curl http://localhost:3006/assets/index.html


Conclusion 🦀

I enjoy axum. I do find that it's simple in the design especially in creating a simple file server. This is something I plan to use more of. May even setup something to run locally on a spare raspberry pi for testing purposes.

If we are to compare it to JavaScript's express. I would expect something as more performant. Hopefully around 5-8x, but without testing this is just educated guess based on other data points I've seen. The only way I would be able to prove this. Would be to setup these servers and really test them out.

server concurrent requests chart