tower-serve-embedded 0.2.0

Embed content-hashed, cache-busted static web assets into your binary and serve them as a tower::Service, with a compile-time asset! macro for SSR templates.
Documentation
//! End-to-end tests for the `tower::Service` against a hand-built asset set.
//!
//! The real `ASSETS` static is generated by `tower-serve-embedded-build` from a `build.rs`; here
//! we construct an equivalent set by hand so these tests stay framework- and build-script-free and
//! exercise [`ServeEmbedded`] directly. See `examples/` for the full build-script workflow.

use bytes::Bytes;
use http::header::{ALLOW, CACHE_CONTROL, CONTENT_TYPE, ETAG, IF_NONE_MATCH};
use http::{Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt; // for `oneshot`
use tower_serve_embedded::{Assets, EmbeddedFile, Route};

const IMMUTABLE: &str = "public, max-age=31536000, immutable";

const CSS_BYTES: &[u8] = b"body{color:#1a1a1a}";
const JS_BYTES: &[u8] = b"console.log('hi')";
const LIB_BYTES: &[u8] = b"/* htmx */";

// Two ordinary (hashed) assets and one `immutable_dir` asset (no hashed URL, served only at its
// plain URL). File order is arbitrary; routes below are what must be sorted.
static FILES: &[EmbeddedFile] = &[
    EmbeddedFile {
        url: "/assets/css/style.css",
        hashed_url: Some("/assets/css/style.abcd1234.css"),
        logical_path: "assets/css/style.css",
        bytes: CSS_BYTES,
        content_type: "text/css",
        etag: "\"abcd1234\"",
        hash: "abcd1234",
    },
    EmbeddedFile {
        url: "/assets/js/app.js",
        hashed_url: Some("/assets/js/app.beef5678.js"),
        logical_path: "assets/js/app.js",
        bytes: JS_BYTES,
        content_type: "text/javascript",
        etag: "\"beef5678\"",
        hash: "beef5678",
    },
    EmbeddedFile {
        url: "/assets/lib/htmx-1.9.10.min.js",
        hashed_url: None,
        logical_path: "assets/lib/htmx-1.9.10.min.js",
        bytes: LIB_BYTES,
        content_type: "text/javascript",
        etag: "\"c0ffee00\"",
        hash: "c0ffee00",
    },
];

// Every served URL → (file index, Cache-Control), sorted by `url` for `Assets::resolve`'s binary
// search. Ordinary assets are served only at their hashed URLs; `immutable_dir` assets are served
// only at their plain URLs. All generated routes are immutable.
static ROUTES: &[Route] = &[
    Route {
        url: "/assets/css/style.abcd1234.css",
        file: 0,
        cache_control: Some(IMMUTABLE),
    },
    Route {
        url: "/assets/js/app.beef5678.js",
        file: 1,
        cache_control: Some(IMMUTABLE),
    },
    Route {
        url: "/assets/lib/htmx-1.9.10.min.js",
        file: 2,
        cache_control: Some(IMMUTABLE),
    },
];

static ASSETS: Assets = Assets::new(FILES, ROUTES);

#[test]
fn lookup_and_url_resolution() {
    let css = ASSETS.get_logical("assets/css/style.css").unwrap();
    assert_eq!(css.hashed_url, Some("/assets/css/style.abcd1234.css"));
    assert_eq!(css.content_type, "text/css");
    assert_eq!(css.etag, format!("\"{}\"", css.hash));

    // `url`/`asset!` prefer the cache-busted alias when there is one, else the plain URL.
    assert_eq!(ASSETS.url("assets/css/style.css"), css.hashed_url);
    assert_eq!(
        ASSETS.url("assets/lib/htmx-1.9.10.min.js"),
        Some("/assets/lib/htmx-1.9.10.min.js")
    );
    assert_eq!(ASSETS.url("does/not/exist"), None);

    // `resolve` keys on a served URL and reports the cache policy. Plain URLs are served only for
    // assets marked immutable in the builder.
    assert_eq!(
        ASSETS
            .resolve(css.hashed_url.unwrap())
            .unwrap()
            .file
            .logical_path,
        css.logical_path
    );
    assert!(ASSETS.resolve("/assets/css/style.css").is_none());
    assert_eq!(
        ASSETS
            .resolve("/assets/lib/htmx-1.9.10.min.js")
            .unwrap()
            .file
            .logical_path,
        "assets/lib/htmx-1.9.10.min.js"
    );
    assert!(ASSETS.resolve("/nope").is_none());
    assert_eq!(ASSETS.len(), 3);
}

#[tokio::test]
async fn serves_hashed_url_with_immutable_cache_and_etag() {
    let file = ASSETS.get_logical("assets/css/style.css").unwrap();

    let resp = ASSETS
        .service()
        .oneshot(Request::get(file.hashed_url.unwrap()).body(()).unwrap())
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(resp.headers()[CONTENT_TYPE], "text/css");
    assert_eq!(resp.headers()[CACHE_CONTROL], IMMUTABLE);
    assert_eq!(resp.headers()[ETAG], file.etag);

    let body = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(body, Bytes::from_static(CSS_BYTES));
}

#[tokio::test]
async fn mutable_plain_url_is_not_served() {
    let file = ASSETS.get_logical("assets/css/style.css").unwrap();

    let resp = ASSETS
        .service()
        .oneshot(Request::get(file.url).body(()).unwrap())
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    assert!(!resp.headers().contains_key(CONTENT_TYPE));
    assert!(!resp.headers().contains_key(ETAG));
    assert!(!resp.headers().contains_key(CACHE_CONTROL));
}

#[tokio::test]
async fn serves_immutable_dir_asset_at_plain_url_with_immutable_cache() {
    let file = ASSETS.get_logical("assets/lib/htmx-1.9.10.min.js").unwrap();
    assert_eq!(file.hashed_url, None);

    let resp = ASSETS
        .service()
        .oneshot(Request::get(file.url).body(()).unwrap())
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(resp.headers()[CACHE_CONTROL], IMMUTABLE);
    assert_eq!(resp.headers()[ETAG], file.etag);

    let body = resp.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(body, Bytes::from_static(LIB_BYTES));
}

#[tokio::test]
async fn head_request_omits_body_but_keeps_headers() {
    let file = ASSETS.get_logical("assets/js/app.js").unwrap();

    let resp = ASSETS
        .service()
        .oneshot(Request::head(file.hashed_url.unwrap()).body(()).unwrap())
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(resp.headers()[CONTENT_TYPE], "text/javascript");
    let body = resp.into_body().collect().await.unwrap().to_bytes();
    assert!(body.is_empty());
}

#[tokio::test]
async fn conditional_request_returns_304() {
    let file = ASSETS.get_logical("assets/js/app.js").unwrap();

    let resp = ASSETS
        .service()
        .oneshot(
            Request::get(file.hashed_url.unwrap())
                .header(IF_NONE_MATCH, file.etag)
                .body(())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
    assert_eq!(resp.headers()[ETAG], file.etag);
    assert_eq!(resp.headers()[CACHE_CONTROL], IMMUTABLE);
    let body = resp.into_body().collect().await.unwrap().to_bytes();
    assert!(body.is_empty());
}

#[tokio::test]
async fn conditional_request_on_mutable_plain_url_returns_404() {
    let file = ASSETS.get_logical("assets/js/app.js").unwrap();

    let resp = ASSETS
        .service()
        .oneshot(
            Request::get(file.url)
                .header(IF_NONE_MATCH, file.etag)
                .body(())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    assert!(!resp.headers().contains_key(ETAG));
    assert!(!resp.headers().contains_key(CACHE_CONTROL));
}

#[tokio::test]
async fn missing_asset_returns_404() {
    let resp = ASSETS
        .service()
        .oneshot(
            Request::get("/assets/css/style.deadbeef.css")
                .body(())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn non_get_method_returns_405_with_allow() {
    let file = ASSETS.get_logical("assets/css/style.css").unwrap();

    let resp = ASSETS
        .service()
        .oneshot(Request::post(file.hashed_url.unwrap()).body(()).unwrap())
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
    assert_eq!(resp.headers()[ALLOW], "GET, HEAD");
}