baked-spa 0.1.2

Single Page App router for Axum that uses files embeded files
Documentation
use std::marker::PhantomData;

use axum::handler::Handler;
use axum::Router;
use axum::body::{HttpBody, Body, boxed, Full};
use axum::http::{StatusCode, header, Uri};
use axum::response::{IntoResponse, Response};
use axum::routing::{get_service, get};
use rust_embed::RustEmbed;

static INDEX_PATH: &str = "index.html";

pub struct SpaRouter<A, B = Body, T = ()> where A: RustEmbed {
    path: &'static str,
    _assets: PhantomData<A>,
    _marker: PhantomData<fn() -> (B, T)>,
}

impl<A, B, T> SpaRouter<A, B, T> where
A: RustEmbed + 'static, {
    pub fn new(path: &'static str) -> Self {
        Self {
            path,
            _assets: Default::default(),
            _marker: Default::default(),
        }
    }
}

impl<A, B, T> From<SpaRouter<A, B, T>> for Router<B>
where
    B: HttpBody + Send + 'static,
    T: 'static,
    A: RustEmbed + 'static,
{
    fn from(spa: SpaRouter<A, B, T>) -> Self {
        Router::new()
            .nest(spa.path, get(assets_handler::<A>))
            .fallback(get_service(serve_index::<A>.into_service()))
    }
}
async fn serve_asset<A: RustEmbed>(path: &str) -> Response {
    dbg!(path);
    if let Some(index) = A::get(path) {
        let body = boxed(Full::from(index.data));
        let mime = mime_guess::from_path(path).first_or_octet_stream();
        let etag = base64::encode(index.metadata.sha256_hash());
        Response::builder()
            .header(header::CONTENT_TYPE, mime.as_ref())
            .header(header::ETAG, etag)
            .body(body)
            .unwrap_or_else(|_| not_found())
    } else {
        not_found()
    }
}

async fn assets_handler<A: RustEmbed>(uri: Uri) -> Response {
    let path = uri.path().trim_start_matches('/');
    serve_asset::<A>(path).await
}

async fn serve_index<A: RustEmbed>() -> Response {
    serve_asset::<A>(INDEX_PATH).await
}

fn not_found() -> Response {
    (StatusCode::NOT_FOUND, "Not found").into_response()
}


#[cfg(test)]
mod tests {
    use super::*;
    use axum_test_helper::TestClient;

    #[derive(RustEmbed)]
    #[folder = "fixture/"]
    struct TestAssets;

    #[tokio::test]
    async fn rust_embed_as_file_provider() {
        let resp = serve_index::<TestAssets>().await;
        assert_eq!(200, resp.status())
    }

    #[tokio::test]
    async fn basic() {
        let app = Router::new()
            .route("/foo", get(|| async { "GET /foo" }))
            .merge(SpaRouter::new("/assets") as SpaRouter<TestAssets>);
        let client = TestClient::new(app);

        for file in TestAssets::iter() {
            dbg!(file);
        }

        let res = client.get("/").send().await;
        dbg!(res.headers());
        assert_eq!(res.status(), StatusCode::OK);
        assert!(res.headers().get(header::ETAG).is_some());
        assert_eq!(res.headers().get(header::CONTENT_TYPE).unwrap().as_bytes(), b"text/html");
        assert_eq!(res.text().await, "<h1>Hello, World!</h1>\n");

        let res = client.get("/some/random/path").send().await;
        assert_eq!(res.status(), StatusCode::OK);
        assert!(res.headers().get(header::ETAG).is_some());
        assert_eq!(res.text().await, "<h1>Hello, World!</h1>\n");

        let res = client.get("/assets/script.js").send().await;
        assert_eq!(res.status(), StatusCode::OK);
        assert!(res.headers().get(header::ETAG).is_some());
        assert_eq!(res.headers().get(header::CONTENT_TYPE).unwrap().as_bytes(), b"application/javascript");
        assert_eq!(res.text().await, "console.log('hi')\n");

        let res = client.get("/foo").send().await;
        assert_eq!(res.status(), StatusCode::OK);
        assert_eq!(res.text().await, "GET /foo");

        let res = client.get("/assets/doesnt_exist").send().await;
        assert_eq!(res.status(), StatusCode::NOT_FOUND);
    }
}