Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion crates/server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,9 @@ tracing.workspace = true
anyhow.workspace = true
axum.workspace = true
sha2.workspace = true
url.workspace = true
url.workspace = true

[dev-dependencies]
tokio.workspace = true
tower = { version = "0.5", features = ["util"] }
tempfile = "3"
115 changes: 111 additions & 4 deletions crates/server/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ use sha2::Digest as _;
use tower_http::{
services::{ServeDir, ServeFile},
set_header::SetResponseHeaderLayer,
set_status::SetStatus,
};
use tracing::warn;

Expand All @@ -23,7 +22,7 @@ use tracing::warn;
pub fn serve_static_ui(
ui_path: &str,
force_no_cache: bool,
) -> ServeDir<SetStatus<Router>> {
) -> ServeDir<Router> {
let directory = PathBuf::from(ui_path);
let index = directory.join("index.html");

Expand All @@ -32,7 +31,7 @@ pub fn serve_static_ui(

if force_no_cache {
return ServeDir::new(directory)
.not_found_service(add_no_cache_layer(index_router));
.fallback(add_no_cache_layer(index_router));
}

let index = match hash_encode_contents(&index) {
Expand All @@ -53,7 +52,7 @@ pub fn serve_static_ui(
}
};

ServeDir::new(directory).not_found_service(index)
ServeDir::new(directory).fallback(index)
}

fn hash_encode_contents(path: &Path) -> anyhow::Result<HeaderValue> {
Expand All @@ -74,3 +73,111 @@ fn add_no_cache_layer(router: Router) -> Router {
HeaderValue::from_static("no-cache"),
))
}

#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt as _;

fn make_ui_dir(
index: &'static str,
) -> (tempfile::TempDir, &'static str) {
let dir = tempfile::tempdir().expect("Failed to create temp dir");
std::fs::write(dir.path().join("index.html"), index)
.expect("Failed to write index.html");
std::fs::write(dir.path().join("asset.txt"), "asset-body")
.expect("Failed to write asset.txt");
(dir, index)
}

/// Wraps `serve_static_ui` in an axum `Router` fallback, exactly the way
/// downstream consumers (e.g. komodo) attach it.
fn make_app(dir: &std::path::Path, force_no_cache: bool) -> Router {
Router::new().fallback_service(serve_static_ui(
dir.to_str().expect("Valid path"),
force_no_cache,
))
}

async fn get(app: &Router, path: &str) -> axum::response::Response {
app
.clone()
.oneshot(
Request::builder()
.uri(path)
.body(Body::empty())
.expect("Valid request"),
)
.await
.expect("Service should not fail")
}

async fn body_bytes(res: axum::response::Response) -> Vec<u8> {
axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.expect("Failed to read response body")
.to_vec()
}

#[tokio::test]
async fn root_returns_index_html_with_etag() {
let (dir, index) = make_ui_dir("<html><body>index</body></html>");
let service = make_app(dir.path(), false);

let res = get(&service, "/").await;
assert_eq!(res.status(), StatusCode::OK);
assert!(
res.headers().contains_key(header::ETAG),
"Expected ETag header on index.html"
);
assert_eq!(body_bytes(res).await, index.as_bytes());
}

#[tokio::test]
async fn spa_route_returns_index_html_with_200() {
let (dir, index) = make_ui_dir("<html><body>index</body></html>");
let service = make_app(dir.path(), false);

let res = get(&service, "/login").await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(body_bytes(res).await, index.as_bytes());
}

#[tokio::test]
async fn deep_spa_route_returns_index_html_with_200() {
let (dir, index) = make_ui_dir("<html><body>index</body></html>");
let service = make_app(dir.path(), false);

let res = get(&service, "/stacks/abc").await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(body_bytes(res).await, index.as_bytes());
}

#[tokio::test]
async fn existing_static_file_is_served() {
let (dir, _index) =
make_ui_dir("<html><body>index</body></html>");
let service = make_app(dir.path(), false);

let res = get(&service, "/asset.txt").await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(body_bytes(res).await, b"asset-body");
}

#[tokio::test]
async fn force_no_cache_spa_route_returns_200_with_no_cache() {
let (dir, index) = make_ui_dir("<html><body>index</body></html>");
let service = make_app(dir.path(), true);

let res = get(&service, "/login").await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers().get(header::CACHE_CONTROL),
Some(&HeaderValue::from_static("no-cache")),
"Expected Cache-Control: no-cache header"
);
assert_eq!(body_bytes(res).await, index.as_bytes());
}
}