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; 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 */";
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",
},
];
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));
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);
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");
}