I started another web app side project which has been an exploration of both new and familiar frameworks. One of the new frameworks is warp which is a web framework for rust. I've used a few rust web frameworks before: namely iron, nickel, rocket and actix-web (the metal-like naming pattern made me chuckle). This time I was going to give it a go with warp.

The setup

The rust app is the api/hosting backend for a single page application (SPA). It will contain some api endpoints that consume and serve JSON, and it also hosts the SPA's html, js, css, and other static assets. SPAs are always a bit tricky to host because some of the assets need to be served like a file system, but the index.html file needs to be served whenever the backend gets an unfamiliar GET request. This happens because the SPA will take over and attempt to parse and route any url.

Serving SPA assets

The first step is serving the assets that the SPA produces after building. These are things like javascript, css, and images. It will also serve index.html because that is just a regular file on the file system. In warp, this is quite straightforward:

// Api routes first
let routes = warp::path("api")

    // ... snip! ...

    // Web app proxy
    .or(warp::fs::dir(WEB_APP_DIR));

In this example, I cut out the JSON api routing, but left in that last line which will serve the built SPA assets as a directory. If the rust app receives a request like GET /js/app.js, this routing will attempt to find a file at WEB_APP_DIR/js/app.js. If one isn't found then this warp filter will get rejected. If the request is rejected in this example, warp will return a 404 Not Found. That is a problem if the SPA does its own routing because maybe the request GET /animals/puppy doesn't match a file in WEB_APP_DIR, but you'd still want the SPA to consume this request.

Serving index.html unrecognized requests

Let's handle GET /animals/puppy by adding a bit to the last line in the previous example:

// Web app proxy
.or(warp::fs::dir(WEB_APP_DIR)
    .or(warp::fs::file(format!("{}/index.html", WEB_APP_DIR))));

If a file isn't found in WEB_APP_DIR then the request will fall through to this last or statement which will simply always serve up WEB_APP_DIR/index.html. Now the SPA can be responsible for handling the rest of the requests.

Don't allow any /api/* requests to fall through to SPA

I chose the special prefix /api for any request that I expect my rust code to catch and not send to the SPA. Perhaps the warp server gets a request that starts with /api but none of the api endpoints handle it. If we stick with the code above, then the SPA will be left trying to deal with it. That means an ajax call might incorrectly receive a 200 with index.html as the content instead of a 404 from the server.

To fix this, we need a catch-all at the end of the api routing which will return a 404 Not Found response.

// Api routes first
let routes = warp::path("api")
    .and(
        warp::path!("resources" / "search")
            .and_then(resource_search_handler)
            .or(warp::path!("refData")
                .and(warp::any().map(move || Arc::clone(&ref_data)))
                .map(|ref_data: Arc<RefData>| warp::reply::json(&*ref_data)))
            .recover(recover_api),
    )
    // Web app proxy
    // ... snip! ...


async fn recover_api(_: Rejection) -> Result<impl Reply, Infallible> {
    Ok(warp::http::StatusCode::NOT_FOUND)
}

Check out the .recover(recover_api) line. This will catch any rejections of previous filters and allow you to respond however you'd like. In this case, we're just going to recover with 404 Not Found.

Injecting configurable variables into index.html

Oftentimes one will want to pass certain variables through from the server to index.html. This could be done with an api call every time the SPA loads, or you could inject variables into the index.html so no additional request is needed. An example of a variable could be the environment (eg. "dev" "test" or "prod") or in my case the Google Analytics key. The rust app could find these from a database, separate api call, environment variable, or wherever.

I'm not sure I'm doing this step in the most idiomatic way, so take it with a grain of salt. When the rust app is started, it checks for a WEB_APP_DIR/index.html file and then directly injects some <meta> tags in the <head>. This is just some good ol' fashioned text file manipulation

fn insert_metadata_into_html() {
    let path = format!("{}/index.html", WEB_APP_DIR);
    let mut file_contents = match std::fs::read_to_string(&path) {
        Ok(s) => s,
        Err(_) => {
            println!("Front end assets weren't built with elm-app build command. This is fine for developing.");
            return;
        }
    };

    let index_of_start_head = match file_contents.find("<head>") {
        Some(i) => i + 6,
        None => {
            eprintln!("Index.html incorrectly built. Contains no opening <head> tag");
            return;
        }
    };

    let hidden_fields = vec![(
        "googleAnalyticsId",
        std::env::var("GOOGLE_ANALYTICS_ID").unwrap_or("unknown".to_string()),
    )];
    let formatted: String = hidden_fields
        .into_iter()
        .map(|(k, v)| format!("<meta name=\"{}\" content=\"{}\">", k, v))
        .flat_map(|i| i.chars().collect::<Vec<_>>())
        .collect();
    file_contents.insert_str(index_of_start_head, &formatted);

    match std::fs::write(&path, file_contents) {
        Ok(()) => {}
        Err(_) => eprintln!("Could not write back to index.html after inserting metadata"),
    }
}

A better way to do this might be making a custom handler for anything that routes to WEB_APP_DIR/index.html and inserting these values at runtime, this way they could change throughout the app's lifetime. With my method the values get inserted when the rust server starts up and they can't change.

My warp wisdom is not robust enough to come up with that solution. I tried a number of filter combinations, but nothing quite worked as I liked. So this method of modifying index.html upon startup works for now.