2026-02-16 16:24:21 +00:00
|
|
|
|
use poem::{
|
|
|
|
|
|
Response, handler,
|
|
|
|
|
|
http::{StatusCode, header},
|
|
|
|
|
|
web::Path,
|
|
|
|
|
|
};
|
|
|
|
|
|
use rust_embed::RustEmbed;
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(RustEmbed)]
|
|
|
|
|
|
#[folder = "../frontend/dist"]
|
|
|
|
|
|
struct EmbeddedAssets;
|
|
|
|
|
|
|
|
|
|
|
|
fn serve_embedded(path: &str) -> Response {
|
|
|
|
|
|
let normalized = if path.is_empty() {
|
|
|
|
|
|
"index.html"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
path.trim_start_matches('/')
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let is_asset_request = normalized.starts_with("assets/");
|
|
|
|
|
|
let asset = if is_asset_request {
|
|
|
|
|
|
EmbeddedAssets::get(normalized)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
EmbeddedAssets::get(normalized).or_else(|| {
|
|
|
|
|
|
if normalized == "index.html" {
|
|
|
|
|
|
None
|
|
|
|
|
|
} else {
|
|
|
|
|
|
EmbeddedAssets::get("index.html")
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
match asset {
|
|
|
|
|
|
Some(content) => {
|
|
|
|
|
|
let body = content.data.into_owned();
|
|
|
|
|
|
let mime = mime_guess::from_path(normalized)
|
|
|
|
|
|
.first_or_octet_stream()
|
|
|
|
|
|
.to_string();
|
|
|
|
|
|
|
|
|
|
|
|
Response::builder()
|
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
|
.header(header::CONTENT_TYPE, mime)
|
|
|
|
|
|
.body(body)
|
|
|
|
|
|
}
|
|
|
|
|
|
None => Response::builder()
|
|
|
|
|
|
.status(StatusCode::NOT_FOUND)
|
|
|
|
|
|
.body("Not Found"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 16:55:59 +00:00
|
|
|
|
/// Serve a single embedded asset from the `assets/` folder.
|
2026-02-16 16:24:21 +00:00
|
|
|
|
#[handler]
|
|
|
|
|
|
pub fn embedded_asset(Path(path): Path<String>) -> Response {
|
|
|
|
|
|
let asset_path = format!("assets/{path}");
|
|
|
|
|
|
serve_embedded(&asset_path)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 16:55:59 +00:00
|
|
|
|
/// Serve an embedded file by path (falls back to `index.html` for SPA routing).
|
2026-02-16 16:24:21 +00:00
|
|
|
|
#[handler]
|
|
|
|
|
|
pub fn embedded_file(Path(path): Path<String>) -> Response {
|
|
|
|
|
|
serve_embedded(&path)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 16:55:59 +00:00
|
|
|
|
/// Serve the embedded SPA entrypoint.
|
2026-02-16 16:24:21 +00:00
|
|
|
|
#[handler]
|
|
|
|
|
|
pub fn embedded_index() -> Response {
|
|
|
|
|
|
serve_embedded("index.html")
|
|
|
|
|
|
}
|
2026-02-20 17:13:59 +00:00
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
use poem::http::StatusCode;
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn non_asset_path_spa_fallback_or_not_found() {
|
|
|
|
|
|
// Non-asset paths fall back to index.html for SPA client-side routing.
|
|
|
|
|
|
// In release builds (with embedded dist/) this returns 200.
|
|
|
|
|
|
// In debug builds without a built frontend dist/ it returns 404.
|
|
|
|
|
|
let response = serve_embedded("__nonexistent_spa_route__.html");
|
2026-02-23 22:24:29 +00:00
|
|
|
|
let status = response.status();
|
2026-02-20 17:13:59 +00:00
|
|
|
|
assert!(
|
2026-02-23 22:24:29 +00:00
|
|
|
|
status == StatusCode::OK || status == StatusCode::NOT_FOUND,
|
|
|
|
|
|
"unexpected status: {status}",
|
2026-02-20 17:13:59 +00:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn missing_asset_path_prefix_returns_not_found() {
|
|
|
|
|
|
// assets/ prefix: no SPA fallback – returns 404 if the file does not exist
|
|
|
|
|
|
let response = serve_embedded("assets/__nonexistent__.js");
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn serve_embedded_does_not_panic_on_empty_path() {
|
|
|
|
|
|
// Empty path normalises to index.html; OK in release, 404 in debug without dist/
|
|
|
|
|
|
let response = serve_embedded("");
|
2026-02-23 22:24:29 +00:00
|
|
|
|
let status = response.status();
|
2026-02-20 17:13:59 +00:00
|
|
|
|
assert!(
|
2026-02-23 22:24:29 +00:00
|
|
|
|
status == StatusCode::OK || status == StatusCode::NOT_FOUND,
|
|
|
|
|
|
"unexpected status: {status}",
|
2026-02-20 17:13:59 +00:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn embedded_assets_struct_is_iterable() {
|
|
|
|
|
|
// Verifies that rust-embed compiled the EmbeddedAssets struct correctly.
|
|
|
|
|
|
// In debug builds without a built frontend dist/ directory the iterator is empty; that is
|
|
|
|
|
|
// expected. In release builds it will contain all bundled frontend files.
|
|
|
|
|
|
let _files: Vec<_> = EmbeddedAssets::iter().collect();
|
|
|
|
|
|
// No assertion needed – the test passes as long as it compiles and does not panic.
|
|
|
|
|
|
}
|
2026-02-23 22:24:29 +00:00
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
|
async fn embedded_index_handler_returns_ok_or_not_found() {
|
|
|
|
|
|
// Route the handler through TestClient; index.html is the SPA entry point.
|
|
|
|
|
|
let app = poem::Route::new().at("/", poem::get(embedded_index));
|
|
|
|
|
|
let cli = poem::test::TestClient::new(app);
|
|
|
|
|
|
let resp = cli.get("/").send().await;
|
|
|
|
|
|
let status = resp.0.status();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
status == StatusCode::OK || status == StatusCode::NOT_FOUND,
|
|
|
|
|
|
"unexpected status: {status}",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
|
async fn embedded_file_handler_with_path_returns_ok_or_not_found() {
|
|
|
|
|
|
// Non-asset paths fall back to index.html (SPA routing) or 404.
|
|
|
|
|
|
let app = poem::Route::new().at("/*path", poem::get(embedded_file));
|
|
|
|
|
|
let cli = poem::test::TestClient::new(app);
|
|
|
|
|
|
let resp = cli.get("/__spa_route__").send().await;
|
|
|
|
|
|
let status = resp.0.status();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
status == StatusCode::OK || status == StatusCode::NOT_FOUND,
|
|
|
|
|
|
"unexpected status: {status}",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
|
async fn embedded_asset_handler_missing_file_returns_not_found() {
|
|
|
|
|
|
// The assets/ prefix disables SPA fallback; missing files must return 404.
|
|
|
|
|
|
let app = poem::Route::new().at("/assets/*path", poem::get(embedded_asset));
|
|
|
|
|
|
let cli = poem::test::TestClient::new(app);
|
|
|
|
|
|
let resp = cli.get("/assets/__nonexistent__.js").send().await;
|
|
|
|
|
|
assert_eq!(resp.0.status(), StatusCode::NOT_FOUND);
|
|
|
|
|
|
}
|
2026-02-20 17:13:59 +00:00
|
|
|
|
}
|