Created: August, 24, 2023 Last Modified: September, 1, 2023

Serving Static HTML and Handlebar Template Files Using Axum

Cargo Dependencies


The first step is adding the required dependencies to Cargo.toml, add the following lines.

axum = "0.6.20"
handlebars = { version = "4.3.7", features = ["dir_source"] }
serde = { version = "1.0.183", features = ["derive"] }
tokio = { version = "1.32.0", features = ["full"] }
tower = { version = "0.4.13", features = ["full"] }
tower-http = { version = "0.4.3", features = ["full"] }

We will use Axum as our web framework. Therefore we need to import Tower and TowerHTTP for ServeDir and for the Service trait definitions. Serde is needed for the template data structures, and lastly handlebars will handle the templating.

Template And Static File Serve


Next lets setup a route that attempts serving a static file and if it fails trys serving the template.

async fn template_serve(
    uri: Uri,
    State(state): State<AppState<'_>>,
) -> Result<Response<BoxBody>, (StatusCode, String)> {
    let res = get_static_file(uri.clone()).await?;

    if res.status() == StatusCode::NOT_FOUND {
        //check if its an index path, 
        //and remove / so that it matches what handlebars expects for path 
        let path = if uri.path().ends_with('/') {
            format!("{}index", &uri.path()[1..])
        } else { 
            // slice past first /
            uri.path()[1..].to_string()
        };
        match state.handlebars.render(&path, &state.data) {
            Ok(template_html) => Ok(Html::from(template_html).into_response()),
            Err(e) => Err((StatusCode::NOT_FOUND, e.to_string())),
        }
    } else {
        Ok(res)
    }
}

async fn get_static_file(uri: Uri) -> Result<Response<BoxBody>, (StatusCode, String)> {
    let req = Request::builder().uri(uri).body(Body::empty()).unwrap();

    // `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
    match ServeDir::new("assets/").oneshot(req).await {
        Ok(res) => Ok(res.map(boxed)),
        Err(err) => Err((
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("Something went wrong: {}", err),
        )),
    }
}

Template serve will be our main Route. The default routes created by handlebars "register_templates_directory" function expects the templates to be accessed without their extension. Eg. in order to load "template.hbs" "template" should be passed to handlebars.render. This means we can first try rendering with ServeDir which will fail if it cant find the file type. Since the Uri wont include the extension ServeDir will create a 404 status code which we can act on. Lastly before attempting rendering with handle bars, we check if the path ends with /, which allows us to handle index.hbs and index.html files in directories

Creating a AppState Struct and TemplateData Struct


Next we need a shared state for our site that will share a handlebars struct and data for the templates to fill in.

#[derive(Debug, Clone, Serialize, Deserialize)]
struct TemplateData {
    name: String,
}

#[derive(Debug, Clone)]
struct AppState<'a> {
    handlebars: Handlebars<'a>,
    data: TemplateData,
}

TemplateData will hold the info that our templates can reference. We need to Derive Serialize and Deserialize from serde to enable Handlebars to use the struture for templating. The Lifetime "'a" on Appstate is needed due to the Handlebars Struct. "<'a>" specifies that the Handlebars struct should live as long as the outer AppState Struct.

Gluing It All Together


Lastly we need to create our handlebars struct and register our serve_template function as the catch all get route.

#[tokio::main]
async fn main() {
    // build our application with a single route
    let mut state = AppState{
        data: TemplateData {
            name: "Bob".to_string(),
        },
        handlebars: Handlebars::new(),
    };
    state
        .handlebars
        .register_templates_directory(".hbs", "./assets/")
        .expect("Error loading templates");
    state.handlebars.set_dev_mode(true);
    let app = Router::new().nest_service("/", get(template_serve).with_state(state));

    // run it with hyper on localhost:3000
    axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

First we create a state holding our handlebars struct and init the handlebars struct. Here we use register_templates_directory to load all templates available at that location. We Also enable Dev mode in order to reload the templates when requested, however at the time of writing this doesnt seem to work as expected. Meaning that the templates are only loaded once at startup. In order to get expected behavior register_templates_directory has to be called everytime in the route. Or the handle bar struct needs to be reconstructed everytime in the Route

You should now have a complete static file serving web server, the whole code can be found below

use axum::{
    body::{boxed, Body, BoxBody},
    extract::State,
    http::{Request, Response, StatusCode, Uri},
    response::Html,
    response::IntoResponse,
    routing::get,
    Router,
};
use handlebars::*;
use serde::{Deserialize, Serialize};
use tower::ServiceExt;
use tower_http::services::ServeDir;

#[derive(Debug, Clone, Serialize, Deserialize)]
struct TemplateData {
    name: String,
}

#[derive(Debug, Clone)]
struct AppState<'a> {
    handlebars: Handlebars<'a>,
    data: TemplateData,
}

#[tokio::main]
async fn main() {
    // build our application with a single route
    let mut state = AppState{
        data: TemplateData {
            name: "Bob".to_string(),
        },
        handlebars: Handlebars::new(),
    };
    state
        .handlebars
        .register_templates_directory(".hbs", "./assets/")
        .expect("Error loading templates");
    state.handlebars.set_dev_mode(true);
    let app = Router::new().nest_service("/", get(template_serve).with_state(state));

    // run it with hyper on localhost:3000
    axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn template_serve(
    uri: Uri,
    State(state): State<AppState<'_>>,
) -> Result<Response<BoxBody>, (StatusCode, String)> {
    let res = get_static_file(uri.clone()).await?;

    if res.status() == StatusCode::NOT_FOUND {
        //check if its an index path, 
        //and remove / so that it matches what handlebars expects for path 
        let path = if uri.path().ends_with('/') {
            format!("{}index", &uri.path()[1..])
        } else { 
            // slice past first /
            uri.path()[1..].to_string()
        };
        match state.handlebars.render(&path, &state.data) {
            Ok(template_html) => Ok(Html::from(template_html).into_response()),
            Err(e) => Err((StatusCode::NOT_FOUND, e.to_string())),
        }
    } else {
        Ok(res)
    }
}

async fn get_static_file(uri: Uri) -> Result<Response<BoxBody>, (StatusCode, String)> {
    let req = Request::builder().uri(uri).body(Body::empty()).unwrap();

    // `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
    match ServeDir::new("assets/").oneshot(req).await {
        Ok(res) => Ok(res.map(boxed)),
        Err(err) => Err((
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("Something went wrong: {}", err),
        )),
    }
}