diff --git a/Cargo.lock b/Cargo.lock index fd45a40..3c2ef1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -362,18 +362,18 @@ checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.218" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.218" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b" dependencies = [ "proc-macro2", "quote", @@ -403,7 +403,12 @@ version = "0.1.0" dependencies = [ "axum", "bytes", + "flate2", + "http-body-util", "static-serve-macro", + "tokio", + "tower", + "zstd", ] [[package]] diff --git a/static-serve/Cargo.toml b/static-serve/Cargo.toml index 7f85e04..2e0d68a 100644 --- a/static-serve/Cargo.toml +++ b/static-serve/Cargo.toml @@ -14,5 +14,12 @@ static-serve-macro = { path = "../static-serve-macro", version = "0.1.0" } axum = { version = "0.8", default-features = false } bytes = "1.10" +[dev-dependencies] +http-body-util = "0.1" +tokio = { version = "1.44", features = ["rt", "macros"] } +tower = { version = "0.5", features = ["util"] } +zstd = "0.13" +flate2 = "1.1" + [lints] workspace = true diff --git a/static-serve/src/lib.rs b/static-serve/src/lib.rs index faae84e..092a24d 100644 --- a/static-serve/src/lib.rs +++ b/static-serve/src/lib.rs @@ -127,66 +127,3 @@ where ), ) } - -#[cfg(test)] -mod tests { - - #[test] - fn router_created_with_test_routes_lit_str() { - embed_assets!("test_assets/small", compress = false); - let router: Router<()> = static_router(); - assert!(router.has_routes()); - } - - #[test] - fn router_created_not_compressed_because_not_worthwhile() { - embed_assets!("test_assets/small", compress = true); - let router: Router<()> = static_router(); - assert!(router.has_routes()); - } - - #[test] - fn router_created_compressed() { - embed_assets!("test_assets/big", compress = true); - let router: Router<()> = static_router(); - assert!(router.has_routes()); - } - - #[test] - fn router_created_with_test_routes_ident() { - embed_assets!(test_assets, compress = true); - let router: Router<()> = static_router(); - assert!(router.has_routes()); - } - - #[test] - fn router_created_ignore_dirs_one() { - embed_assets!(test_assets, ignore_dirs = ["test_assets/big"]); - let router: Router<()> = static_router(); - assert!(router.has_routes()); - } - - #[test] - fn router_created_ignore_dirs_two() { - embed_assets!( - test_assets, - ignore_dirs = ["test_assets/big", "test_assets/small"] - ); - let router: Router<()> = static_router(); - // all directories ignored, so router has no routes - assert!(!router.has_routes()); - } - - #[test] - fn router_created_ignore_dirs_with_defaults() { - // TODO: actually create one of the default ignore directories - // in `test_assets` to make sure this works - embed_assets!( - test_assets, - ignore_dirs = ["test_assets/big"], - use_default_ignore_dirs = true - ); - let router: Router<()> = static_router(); - assert!(router.has_routes()); - } -} diff --git a/static-serve/tests/tests.rs b/static-serve/tests/tests.rs new file mode 100644 index 0000000..fca6828 --- /dev/null +++ b/static-serve/tests/tests.rs @@ -0,0 +1,342 @@ +//! Integration tests for static-serve and macro +use std::io::Read; + +use axum::{ + body::Body, + http::{ + header::{ACCEPT_ENCODING, CONTENT_ENCODING, IF_NONE_MATCH}, + HeaderValue, Request, Response, StatusCode, + }, + Router, +}; +use http_body_util::BodyExt; +use tower::ServiceExt; + +use static_serve_macro::embed_assets; + +enum Compression { + Zstd, + Gzip, + Both, + None, +} + +async fn get_response( + router: Router<()>, + request: Request, +) -> Response { + router + .into_service() + .oneshot(request) + .await + .expect("sending request") +} + +fn create_request(route: &str, compression: &Compression) -> Request { + let accept_encoding_header = match compression { + Compression::Both => Some(HeaderValue::from_static("zstd, gzip")), + Compression::Zstd => Some(HeaderValue::from_static("zstd")), + Compression::Gzip => Some(HeaderValue::from_static("gzip")), + Compression::None => None, + }; + match accept_encoding_header { + Some(v) => Request::builder() + .uri(route) + .header(ACCEPT_ENCODING, v) + .body(Body::empty()) + .unwrap(), + None => Request::builder().uri(route).body(Body::empty()).unwrap(), + } +} + +fn decompress_zstd(compressed_body: &[u8]) -> Vec { + let mut decoder = zstd::Decoder::new(compressed_body).expect("failed to create decoder"); + let mut decompressed_body = Vec::new(); + std::io::copy(&mut decoder, &mut decompressed_body).expect("failed to decompress"); + decompressed_body +} + +fn decompress_gzip(compressed_body: &[u8]) -> Vec { + let mut decompressed_body = Vec::new(); + let mut decoder = flate2::bufread::GzDecoder::new(compressed_body); + decoder + .read_to_end(&mut decompressed_body) + .expect("can't decode body"); + decompressed_body +} + +#[tokio::test] +async fn router_created_with_lit_str() { + embed_assets!("../static-serve/test_assets/small", compress = false); + let router: Router<()> = static_router(); + assert!(router.has_routes()); + let request = create_request("/app.js", &Compression::None); + let response = get_response(router, request).await; + let (parts, body) = response.into_parts(); + assert!(parts.status.is_success()); + assert_eq!( + parts.headers.get("content-type").unwrap(), + "text/javascript" + ); + assert!(parts.headers.contains_key("etag")); + + let collected_body_bytes = body.into_data_stream().collect().await.unwrap().to_bytes(); + let expected_body_bytes = include_bytes!("../../test_assets/small/app.js"); + assert_eq!(*collected_body_bytes, *expected_body_bytes); +} + +#[tokio::test] +async fn router_created_uncompressed_because_not_worthwhile() { + embed_assets!("../static-serve/test_assets/small", compress = true); + let router: Router<()> = static_router(); + assert!(router.has_routes()); + + let request = create_request("/app.js", &Compression::Zstd); + let response = get_response(router, request).await; + let (parts, body) = response.into_parts(); + assert!(parts.status.is_success()); + assert_eq!( + parts.headers.get("content-type").unwrap(), + "text/javascript" + ); + assert!(parts.headers.contains_key("etag")); + + let collected_body_bytes = body.into_data_stream().collect().await.unwrap().to_bytes(); + // Response should not be compressed since the benefit is insignificant + let expected_body_bytes = include_bytes!("../../test_assets/small/app.js"); + assert_eq!(*collected_body_bytes, *expected_body_bytes); +} + +#[tokio::test] +async fn router_created_compressed_zstd_only() { + embed_assets!("../static-serve/test_assets/big", compress = true); + let router: Router<()> = static_router(); + assert!(router.has_routes()); + + let request = create_request("/app.js", &Compression::Zstd); + let response = get_response(router, request).await; + let (parts, body) = response.into_parts(); + assert!(parts.status.is_success()); + assert_eq!( + parts.headers.get(CONTENT_ENCODING), + Some(&HeaderValue::from_str("zstd").unwrap()) + ); + assert_eq!( + parts.headers.get("content-type").unwrap(), + "text/javascript" + ); + assert!(parts.headers.contains_key("etag")); + + let collected_body_bytes = body.into_data_stream().collect().await.unwrap().to_bytes(); + + // Decompress the response body + let decompressed_body = decompress_zstd(&collected_body_bytes); + assert_eq!( + decompressed_body, + include_bytes!("../../test_assets/big/app.js") + ); + + // Expect the compressed version + let expected_body_bytes = include_bytes!("../../test_assets/dist/app.js.zst"); + assert_eq!(*collected_body_bytes, *expected_body_bytes); +} + +#[tokio::test] +async fn router_created_compressed_gzip_only() { + embed_assets!("../static-serve/test_assets/big", compress = true); + let router: Router<()> = static_router(); + assert!(router.has_routes()); + + let request = create_request("/app.js", &Compression::Gzip); + let response = get_response(router, request).await; + let (parts, body) = response.into_parts(); + assert!(parts.status.is_success()); + assert_eq!( + parts.headers.get(CONTENT_ENCODING), + Some(&HeaderValue::from_str("gzip").unwrap()) + ); + assert_eq!( + parts.headers.get("content-type").unwrap(), + "text/javascript" + ); + assert!(parts.headers.contains_key("etag")); + + let collected_body_bytes = body.into_data_stream().collect().await.unwrap().to_bytes(); + let decompressed_body = decompress_gzip(&collected_body_bytes); + + assert_eq!( + decompressed_body, + include_bytes!("../../test_assets/big/app.js"), + "decompressed body is not as expected" + ); + + // Expect the compressed version + let expected_body_bytes = include_bytes!("../../test_assets/dist/app.js.gz"); + assert_eq!(*collected_body_bytes, *expected_body_bytes); +} + +#[tokio::test] +async fn router_created_compressed_zstd_or_gzip_accepted() { + embed_assets!("../static-serve/test_assets/big", compress = true); + let router: Router<()> = static_router(); + assert!(router.has_routes()); + + let request = create_request("/app.js", &Compression::Both); + let response = get_response(router, request).await; + let (parts, body) = response.into_parts(); + assert!(parts.status.is_success()); + assert_eq!( + parts.headers.get(CONTENT_ENCODING), + Some(&HeaderValue::from_str("zstd").unwrap()) + ); + assert_eq!( + parts.headers.get("content-type").unwrap(), + "text/javascript" + ); + assert!(parts.headers.contains_key("etag")); + + let collected_body_bytes = body.into_data_stream().collect().await.unwrap().to_bytes(); + let decompressed_body = decompress_zstd(&collected_body_bytes); + assert_eq!( + decompressed_body, + include_bytes!("../../test_assets/big/app.js") + ); + + // Expect the compressed version + let expected_body_bytes = include_bytes!("../../test_assets/dist/app.js.zst"); + assert_eq!(*collected_body_bytes, *expected_body_bytes); +} + +#[tokio::test] +async fn router_created_ignore_dirs_one() { + embed_assets!("../static-serve/test_assets", ignore_dirs = ["dist"]); + let router: Router<()> = static_router(); + assert!(router.has_routes()); + + let request = create_request("/small/app.js", &Compression::None); + let response = get_response(router, request).await; + let (parts, body) = response.into_parts(); + assert!(parts.status.is_success()); + + let collected_body_bytes = body.into_data_stream().collect().await.unwrap().to_bytes(); + let expected_body_bytes = include_bytes!("../../test_assets/small/app.js"); + + assert_eq!( + parts.headers.get("content-type").unwrap(), + "text/javascript" + ); + assert!(parts.headers.contains_key("etag")); + assert_eq!(*collected_body_bytes, *expected_body_bytes); +} + +#[tokio::test] +async fn router_created_ignore_dirs_three() { + embed_assets!( + "../static-serve/test_assets", + ignore_dirs = ["big", "small", "dist"] + ); + let router: Router<()> = static_router(); + // all directories ignored, so router has no routes + assert!(!router.has_routes()); + + let request = create_request("/app.js", &Compression::None); + let response = get_response(router, request).await; + let (parts, body) = response.into_parts(); + let collected_body_bytes = body.into_data_stream().collect().await.unwrap().to_bytes(); + + // Expect a 404 Not Found with empty body + assert_eq!(parts.status, StatusCode::NOT_FOUND); + assert!(collected_body_bytes.is_empty()); +} + +#[tokio::test] +async fn handles_conditional_requests_same_etag() { + embed_assets!("../static-serve/test_assets/big", compress = true); + let router: Router<()> = static_router(); + assert!(router.has_routes()); + + let request = create_request("/app.js", &Compression::Zstd); + let response = get_response(router.clone(), request).await; + let (parts, body) = response.into_parts(); + assert!(parts.status.is_success()); + assert_eq!( + parts.headers.get(CONTENT_ENCODING), + Some(&HeaderValue::from_str("zstd").unwrap()) + ); + assert_eq!( + parts.headers.get("content-type").unwrap(), + "text/javascript" + ); + let etag = parts + .headers + .get("etag") + .expect("no etag header when there should be one!"); + + let collected_body_bytes = body.into_data_stream().collect().await.unwrap().to_bytes(); + let decompressed_body = decompress_zstd(&collected_body_bytes); + assert_eq!( + decompressed_body, + include_bytes!("../../test_assets/big/app.js") + ); + + // Expect the compressed version + let expected_body_bytes = include_bytes!("../../test_assets/dist/app.js.zst"); + assert_eq!(*collected_body_bytes, *expected_body_bytes); + + let request = Request::builder() + .uri("/app.js") + .header(IF_NONE_MATCH, etag) + .header(ACCEPT_ENCODING, "zstd") + .body(Body::empty()) + .unwrap(); + let response = get_response(router, request).await; + let (parts, body) = response.into_parts(); + assert_eq!(parts.status, StatusCode::NOT_MODIFIED); + assert_eq!( + parts + .headers + .get("content-length") + .expect("no content-length header!"), + "0" + ); + let collected_body_bytes = body.into_data_stream().collect().await.unwrap().to_bytes(); + assert!(collected_body_bytes.is_empty()); +} + +#[tokio::test] +async fn handles_conditional_requests_different_etag() { + embed_assets!("../static-serve/test_assets/big", compress = true); + let router: Router<()> = static_router(); + assert!(router.has_routes()); + + let request = Request::builder() + .uri("/app.js") + .header(IF_NONE_MATCH, "n0t4r34l3t4g") + .header(ACCEPT_ENCODING, "zstd") + .body(Body::empty()) + .unwrap(); + let response = get_response(router, request).await; + + let (parts, body) = response.into_parts(); + assert_eq!(parts.status, StatusCode::OK); + assert_ne!( + parts + .headers + .get("content-length") + .expect("no content-length header!"), + "0", + "content length is unexpectedly zero!" + ); + + let collected_body_bytes = body.into_data_stream().collect().await.unwrap().to_bytes(); + assert!(!collected_body_bytes.is_empty()); + let decompressed_body = decompress_zstd(&collected_body_bytes); + assert_eq!( + decompressed_body, + include_bytes!("../../test_assets/big/app.js") + ); + + // Expect the compressed version + let expected_body_bytes = include_bytes!("../../test_assets/dist/app.js.zst"); + assert_eq!(*collected_body_bytes, *expected_body_bytes); +} diff --git a/test_assets/big/app.js b/test_assets/big/app.js new file mode 100644 index 0000000..2f65807 --- /dev/null +++ b/test_assets/big/app.js @@ -0,0 +1,20 @@ +console.log("Hello, world!"); +console.log("Hello, world!"); +console.log("Hello, world!"); +console.log("Hello, world!"); +console.log("Hello, world!"); +console.log("Hello, world!"); +console.log("Hello, world!"); +console.log("Hello, world!"); +console.log("Hello, world!"); +console.log("Hello, world!"); +console.log("Hello, world!"); +console.log("Hello, world!"); +console.log("Hello, world!"); +console.log("Hello, world!"); +console.log("Hello, world!"); +console.log("Hello, world!"); +console.log("Hello, world!"); +console.log("Hello, world!"); +console.log("Hello, world!"); +console.log("Hello, world!"); diff --git a/test_assets/big/styles.css b/test_assets/big/styles.css new file mode 100644 index 0000000..b3be45d --- /dev/null +++ b/test_assets/big/styles.css @@ -0,0 +1,50 @@ + + + + +body { + + + background: black ; + +} + + + + +body { + + + background: black ; + +} + + + + +body { + + + background: black ; + +} + + + + +body { + + + background: black ; + +} + + + + +body { + + + background: black ; + +} \ No newline at end of file diff --git a/test_assets/dist/app.js.gz b/test_assets/dist/app.js.gz new file mode 100644 index 0000000..9617302 Binary files /dev/null and b/test_assets/dist/app.js.gz differ diff --git a/test_assets/dist/app.js.zst b/test_assets/dist/app.js.zst new file mode 100644 index 0000000..fd35a29 Binary files /dev/null and b/test_assets/dist/app.js.zst differ diff --git a/test_assets/dist/ignore_me_plz.txt b/test_assets/dist/ignore_me_plz.txt new file mode 100644 index 0000000..12686e5 --- /dev/null +++ b/test_assets/dist/ignore_me_plz.txt @@ -0,0 +1 @@ +This file should only get included and served when the default ignore list is disabled. \ No newline at end of file diff --git a/test_assets/small/app.js b/test_assets/small/app.js new file mode 100644 index 0000000..a8141d3 --- /dev/null +++ b/test_assets/small/app.js @@ -0,0 +1 @@ +console.log("Hello, world!"); diff --git a/test_assets/small/styles.css b/test_assets/small/styles.css new file mode 100644 index 0000000..e378fbb --- /dev/null +++ b/test_assets/small/styles.css @@ -0,0 +1 @@ +body { background: black; } \ No newline at end of file