From 96ef0fa3758a8b2ae2db01a17eb614967e8ad1ac Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:18:47 -0800 Subject: [PATCH 1/4] Added extractor for host header with x-forwarded-host support --- .gitignore | 6 +- .tool-versions | 4 +- Cargo.lock | 670 +++++++++++++----- Cargo.toml | 2 +- README.md | 23 + TODO.md | 88 +++ crates/edgezero-adapter-axum/src/cli.rs | 252 +++++++ .../edgezero-adapter-axum/src/dev_server.rs | 291 +++++++- crates/edgezero-adapter-axum/src/proxy.rs | 239 +++++++ crates/edgezero-core/Cargo.toml | 1 + crates/edgezero-core/src/app.rs | 16 + crates/edgezero-core/src/body.rs | 63 ++ crates/edgezero-core/src/context.rs | 80 ++- crates/edgezero-core/src/error.rs | 47 +- crates/edgezero-core/src/extractor.rs | 419 ++++++++++- crates/edgezero-core/src/manifest.rs | 570 ++++++++++++++- crates/edgezero-core/src/middleware.rs | 60 +- crates/edgezero-core/src/proxy.rs | 315 ++++++++ crates/edgezero-core/src/response.rs | 21 + crates/edgezero-core/src/router.rs | 295 +++++++- crates/edgezero-macros/Cargo.toml | 3 + scripts/run_coverage.sh | 85 +++ 22 files changed, 3286 insertions(+), 264 deletions(-) create mode 100755 scripts/run_coverage.sh diff --git a/.gitignore b/.gitignore index b99f0fe..0187e1e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,10 +9,12 @@ target/ # OS .DS_Store -# IDE - VSCode +# Editors +.claude +.idea +.specstory .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json -.specstory diff --git a/.tool-versions b/.tool-versions index dbf830f..c5ad365 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -fasltly v13.0.0 -rust 1.90.0 +fasltly v13.3.0 +rust 1.91.1 diff --git a/Cargo.lock b/Cargo.lock index 8c028f5..3fe75c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,22 +73,22 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.10" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -148,9 +148,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.32" +version = "0.4.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0" +checksum = "98ec5f6c2f8bc326c994cb9e241cc257ddaba9afa8555a43cffbb5dd86efaa37" dependencies = [ "compression-codecs", "compression-core", @@ -178,7 +178,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -189,7 +189,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -204,11 +204,33 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", "axum-macros", @@ -240,9 +262,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", @@ -265,7 +287,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -327,26 +349,40 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.44" +version = "1.2.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" +checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -374,9 +410,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.51" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" dependencies = [ "clap_builder", "clap_derive", @@ -384,9 +420,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.51" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" dependencies = [ "anstream", "anstyle", @@ -403,7 +439,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -412,6 +448,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -427,11 +472,21 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compression-codecs" -version = "0.4.31" +version = "0.4.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23" +checksum = "b0f7ac3e5b97fdce45e8922fb05cae2c37f7bbd63d30dd94821dacfd8f3f2bf2" dependencies = [ "brotli", "compression-core", @@ -441,9 +496,19 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.29" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] [[package]] name = "core-foundation-sys" @@ -481,9 +546,9 @@ dependencies = [ [[package]] name = "ctor" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ffc71fcdcdb40d6f087edddf7f8f1f8f79e6cf922f555a9ee8779752d4819bd" +checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" dependencies = [ "ctor-proc-macro", "dtor", @@ -516,7 +581,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -527,7 +592,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -558,7 +623,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -568,7 +633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -598,7 +663,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -622,6 +687,12 @@ version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "edgezero-adapter" version = "0.1.0" @@ -737,10 +808,11 @@ dependencies = [ "http", "http-body", "log", - "matchit 0.9.0", + "matchit 0.9.1", "serde", "serde_json", "serde_urlencoded", + "tempfile", "thiserror 2.0.17", "toml", "tower-service", @@ -756,7 +828,8 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.108", + "syn 2.0.114", + "tempfile", "toml", "validator", ] @@ -795,9 +868,9 @@ dependencies = [ [[package]] name = "fastly" -version = "0.11.9" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac590af69cdea42ebbbaa566d0e603c6c0d7d6f53a507fe82cea65260419ab88" +checksum = "71bbe202b3ec57a5af4beeeb8012d1fe7b058bc1532077fa3625abacfa9cdc95" dependencies = [ "anyhow", "bytes", @@ -823,9 +896,9 @@ dependencies = [ [[package]] name = "fastly-macros" -version = "0.11.9" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b012bd5c924ede9a1363ad29a232c4e95c9eb520a124979ad06043a6e44025dc" +checksum = "b4595b318e91b27b4924fec250fd456479808618071854d23a4748a05e711dd0" dependencies = [ "proc-macro2", "quote", @@ -834,9 +907,9 @@ dependencies = [ [[package]] name = "fastly-shared" -version = "0.11.9" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe8aaf17b8c0b689ce8370052e129c7722f3bd9c5ca27790db7624cf64b8c9b1" +checksum = "7588a6d13dfab9e3c8ea5602fea17436fc2bcad14de980c18a50a441a49fe4b2" dependencies = [ "bitflags 1.3.2", "http", @@ -844,9 +917,9 @@ dependencies = [ [[package]] name = "fastly-sys" -version = "0.11.9" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a784af8ed4e5f3d32aac54f687b6a2dd844af304390d3bc70d50cbe6a772c1a7" +checksum = "932e5d862257fa58bde67264532d91da2603396331a82bfb7b75a89b3a670de8" dependencies = [ "bitflags 1.3.2", "fastly-shared", @@ -871,9 +944,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" [[package]] name = "flate2" @@ -900,6 +973,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.31" @@ -956,7 +1035,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -1028,9 +1107,9 @@ dependencies = [ [[package]] name = "handlebars" -version = "6.3.2" +version = "6.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "759e2d5aea3287cb1190c8ec394f42866cb5bf74fcbf213f354e3c856ea26098" +checksum = "9b3f9296c208515b87bd915a2f5d1163d4b3f863ba83337d7713cf478055948e" dependencies = [ "derive_builder", "log", @@ -1044,9 +1123,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" @@ -1056,12 +1135,11 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -1102,9 +1180,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", @@ -1136,14 +1214,13 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] name = "hyper-util" -version = "0.1.17" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ "base64", "bytes", @@ -1235,9 +1312,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -1249,9 +1326,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -1297,9 +1374,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown", @@ -1313,9 +1390,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.8" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ "memchr", "serde", @@ -1338,15 +1415,47 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -1360,9 +1469,15 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "linux-raw-sys" @@ -1378,15 +1493,15 @@ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "log-fastly" -version = "0.11.9" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c67b1d4ff825b027926a385cea5a9f43d0b5a900030d48736833a552120e2b59" +checksum = "9fc98cdb87b71bb8808549dae92b5f4915bfaed44339f1134b62f3bf145c0ad7" dependencies = [ "fastly", "log", @@ -1413,9 +1528,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "matchit" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea5f97102eb9e54ab99fb70bb175589073f554bdadfb74d9bd656482ea73e2a" +checksum = "b3eede3bdf92f3b4f9dc04072a9ce5ab557d5ec9038773bf9ffcd5588b3cc05b" [[package]] name = "memchr" @@ -1431,9 +1546,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "minicov" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f27fe9f1cc3c22e1687f9446c2083c4c5fc7f0bcf1c7a86bdbded14985895b4b" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" dependencies = [ "cc", "walkdir", @@ -1451,15 +1566,24 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -1488,6 +1612,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1511,12 +1636,24 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opaque-debug" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-probe" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1525,9 +1662,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.3" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" dependencies = [ "memchr", "ucd-trie", @@ -1535,9 +1672,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.3" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" dependencies = [ "pest", "pest_generator", @@ -1545,22 +1682,22 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.3" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] name = "pest_meta" -version = "2.8.3" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" dependencies = [ "pest", "sha2 0.10.9", @@ -1583,7 +1720,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -1641,14 +1778,14 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" dependencies = [ "unicode-ident", ] @@ -1679,6 +1816,7 @@ version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -1710,9 +1848,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.41" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ "proc-macro2", ] @@ -1783,9 +1921,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.12.24" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" dependencies = [ "base64", "bytes", @@ -1803,9 +1941,7 @@ dependencies = [ "quinn", "rustls", "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", + "rustls-platform-verifier", "sync_wrapper", "tokio", "tokio-rustls", @@ -1816,7 +1952,6 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", ] [[package]] @@ -1841,9 +1976,9 @@ checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags 2.10.0", "errno", @@ -1854,34 +1989,74 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.34" +version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ + "aws-lc-rs", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" -version = "1.13.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1895,9 +2070,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "same-file" @@ -1908,6 +2083,38 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.228" @@ -1946,20 +2153,20 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -1981,14 +2188,14 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] name = "serde_spanned" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ "serde_core", ] @@ -2037,18 +2244,19 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "simple_logger" @@ -2115,9 +2323,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.108" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -2141,14 +2349,14 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] name = "tempfile" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", "getrandom 0.3.4", @@ -2183,7 +2391,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -2194,7 +2402,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -2257,9 +2465,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", @@ -2279,7 +2487,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -2294,9 +2502,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.8" +version = "0.9.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" dependencies = [ "indexmap", "serde_core", @@ -2309,27 +2517,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tower" @@ -2349,9 +2557,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.10.0", "bytes", @@ -2379,9 +2587,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -2391,20 +2599,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] @@ -2441,9 +2649,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -2490,7 +2698,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -2535,9 +2743,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", @@ -2548,9 +2756,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" dependencies = [ "cfg-if", "js-sys", @@ -2561,9 +2769,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2571,34 +2779,42 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-bindgen-test" -version = "0.3.55" +version = "0.3.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfc379bfb624eb59050b509c13e77b4eb53150c350db69628141abce842f2373" +checksum = "25e90e66d265d3a1efc0e72a54809ab90b9c0c515915c67cdf658689d2c22c6c" dependencies = [ + "async-trait", + "cast", "js-sys", + "libm", "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", "wasm-bindgen", "wasm-bindgen-futures", "wasm-bindgen-test-macro", @@ -2606,13 +2822,13 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.55" +version = "0.3.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "085b2df989e1e6f9620c1311df6c996e83fe16f57792b272ce1e024ac16a90f1" +checksum = "7150335716dce6028bead2b848e72f47b45e7b9422f64cccdc23bedca89affc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -2630,9 +2846,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" dependencies = [ "js-sys", "wasm-bindgen", @@ -2649,10 +2865,10 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "1.0.4" +name = "webpki-root-certs" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" dependencies = [ "rustls-pki-types", ] @@ -2687,7 +2903,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -2698,7 +2914,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -2725,6 +2941,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2761,6 +2986,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -2794,6 +3034,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2806,6 +3052,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -2818,6 +3070,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2842,6 +3100,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -2854,6 +3118,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -2866,6 +3136,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -2878,6 +3154,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2892,9 +3174,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" [[package]] name = "wit-bindgen" @@ -2944,7 +3226,7 @@ dependencies = [ "async-trait", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", "wasm-bindgen", "wasm-bindgen-futures", "wasm-bindgen-macro-support", @@ -2988,28 +3270,28 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] [[package]] @@ -3029,7 +3311,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", "synstructure", ] @@ -3069,5 +3351,11 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.114", ] + +[[package]] +name = "zmij" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" diff --git a/Cargo.toml b/Cargo.toml index 107a8d2..2ae60dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,4 +63,4 @@ tracing = "0.1" validator = { version = "0.20", features = ["derive"] } walkdir = { version = "2" } worker = { version = "0.6", features = ["http"] } -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls"] } diff --git a/README.md b/README.md index 459d56f..e564d20 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,29 @@ Unit tests live next to the modules they exercise. Run the entire suite with The adapter crates include lightweight host-side tests that validate context insertion and URI parsing without needing the Wasm toolchains. +### Coverage (host-only) + +Host-only coverage uses `cargo llvm-cov` and writes LCOV output under +`target/coverage/`. Install the tooling and run the helper script: + +```bash +cargo install cargo-llvm-cov +rustup component add llvm-tools-preview +./scripts/run_coverage.sh +``` + +If `genhtml` (from `lcov`) is available, the script also writes HTML output +under `target/coverage/-html/`. + +When `EDGEZERO_COVERAGE_PACKAGES` is unset, the script auto-discovers workspace +packages that expose a `lib` or `proc-macro` target. Set +`EDGEZERO_COVERAGE_INCLUDE_BINS=1` to include binary-only packages. To target +specific crates, set `EDGEZERO_COVERAGE_PACKAGES`: + +```bash +EDGEZERO_COVERAGE_PACKAGES="edgezero-core edgezero-adapter-axum" ./scripts/run_coverage.sh +``` + ### Wasm runners (Fastly / WASI) Some adapter tests target `wasm32-wasip1`; Cargo needs a Wasm runtime to execute diff --git a/TODO.md b/TODO.md index 700e47b..7058458 100644 --- a/TODO.md +++ b/TODO.md @@ -488,3 +488,91 @@ High-level backlog and decisions to drive the next milestones. - Summary: Extended `#[action]` to accept a single `RequestContext` parameter (with duplicate detection and helper tests) and applied the attribute to `proxy_demo`, relying on the original handler logic. - Assumptions: `RequestContext` parameters are owned values (no reference variants needed) and only one is expected per handler. - Outstanding: None; `cargo test` across the workspace passed after the macro test adjustment. + +## Codex Plan (2026-01-09 - Review new tests + coverage) + +- [x] Locate recently added/changed tests and note their intent and scope. +- [x] Review test quality for gaps, brittle assertions, or missing scenarios. +- [x] Evaluate whether coverage measurement is warranted and how to run it here. +- [x] Record findings and recommendations in the Review section with timestamp. + +## Review (2026-01-09 05:47 UTC) + +- Summary: Reviewed the recent test additions across `edgezero-core`, `edgezero-macros`, and adapter modules (notably the Axum dev-server/proxy integration tests) to assess coverage intent, reliability, and gaps; no code changes were made. +- Assumptions: The tests reviewed reflect the “recently added” scope the request referenced; wasm adapter coverage remains out-of-scope for host-only runs. +- Outstanding: Potential flakiness risks in Axum dev-server integration tests (fixed ports + sleep); coverage gaps noted in response for streaming preservation, invalid manifest cases, and macro output brittleness. + +## Codex Plan (2026-01-09 - Harden axum dev-server tests + add coverage helper) + +- [x] Update Axum dev-server integration tests to use ephemeral ports and a readiness check instead of fixed ports + sleep. +- [x] Add a `cargo llvm-cov` helper (script and/or CI step) scoped to host-capable crates, documenting the command path. +- [x] Run `cargo test` after code changes. +- [x] Record changes, assumptions, and any remaining gaps in the Review section with timestamp. + +## Review (2026-01-09 05:57 UTC) + +- Summary: Reworked the Axum dev-server integration tests to bind an ephemeral listener and retry requests instead of relying on fixed ports + sleeps, added a host-only coverage helper script (`scripts/run_coverage.sh`) with README instructions, and confirmed `cargo test` passes. +- Assumptions: Coverage runs remain host-only (defaulting to `edgezero-core`) unless `EDGEZERO_COVERAGE_PACKAGES` is provided; wasm-only crates are still excluded from coverage runs. +- Outstanding: None. + +## Codex Plan (2026-01-09 - Add HTML coverage output) + +- [x] Update `scripts/run_coverage.sh` to emit an HTML report (LCOV -> HTML) when `genhtml` is available. +- [x] Document the HTML output location and fallback behavior in `README.md`. +- [x] Run `cargo test` after code changes. +- [x] Record changes, assumptions, and any remaining gaps in the Review section with timestamp. + +## Review (2026-01-09 06:05 UTC) + +- Summary: Updated `scripts/run_coverage.sh` to emit per-crate HTML reports when `genhtml` is available and documented the output location in `README.md`; `cargo test` remains green. +- Assumptions: HTML generation depends on `genhtml` (lcov); without it the script still writes LCOV files and prints install guidance. +- Outstanding: None. + +## Codex Plan (2026-01-09 - Auto-discover crates for coverage) + +- [x] Update `scripts/run_coverage.sh` to auto-discover workspace packages (e.g., via `cargo metadata`) when `EDGEZERO_COVERAGE_PACKAGES` is unset. +- [x] Ensure the script skips non-library targets that cannot be covered on host (and document behavior). +- [x] Update README coverage instructions accordingly. +- [x] Run `cargo test` after code changes. +- [x] Record changes, assumptions, and any remaining gaps in the Review section with timestamp. + +## Review (2026-01-09 06:10 UTC) + +- Summary: `scripts/run_coverage.sh` now auto-discovers workspace packages via `cargo metadata` when `EDGEZERO_COVERAGE_PACKAGES` is unset, defaulting to lib/proc-macro crates unless `EDGEZERO_COVERAGE_INCLUDE_BINS=1` is set; README coverage guidance updated and `cargo test` still passes. +- Assumptions: Auto-discovery relies on `python3` for JSON parsing; binary-only coverage remains opt-in to avoid host-incompatible targets. +- Outstanding: None. + +## Review (2026-01-09 06:19 UTC) + +- Summary: Fixed `scripts/run_coverage.sh` auto-discovery to pipe `cargo metadata` into `python -c` correctly, ran the script successfully, and re-ran `cargo test` after the change. +- Assumptions: `python3` remains available for JSON parsing; `genhtml` is installed for HTML output. +- Outstanding: None. + +## Review (2026-01-09 16:22 UTC) + +- Summary: Added a buffered form test for `RequestContext::form::` to cover the remaining `context.rs` line gap; `cargo test -p edgezero-core` and `cargo llvm-cov -p edgezero-core --show-missing-lines` now report 100% line coverage for `edgezero-core`. +- Assumptions: Line coverage (not region coverage) is the target for this pass. +- Outstanding: None. + +## Codex Plan (2026-01-09 - Edgezero-core 100% coverage push) + +- [x] Re-run `cargo llvm-cov -p edgezero-core --no-deps --show-missing-lines` to confirm the current uncovered lines. +- [x] Add targeted unit tests in `edgezero-core` to cover uncovered branches (App, Body, Context, Error, Manifest, Middleware, Response, Router, Handler/Http if needed) and refactor test-only branches that artificially lower coverage. +- [x] Fix test compilation errors surfaced by the coverage run (expect_err Debug bounds in extractor/manifest tests; middleware type inference/lifetime issues). +- [x] Identify the remaining uncovered `edgezero-core` lines (currently in `context.rs`) and add minimal coverage for them. +- [x] Re-run coverage and `cargo test` to verify 100% line coverage for `edgezero-core`. +- [x] Document changes, assumptions, and any remaining gaps in the Review section with timestamp. + +## Codex Plan (2026-01-09 - Investigate cargo test failure) + +- [x] Run `cargo test` to capture the failing crate/test output. +- [x] Inspect the failing module(s) to identify the smallest fix or adjustment. +- [x] Apply the minimal code/test change needed to resolve the failure. +- [x] Re-run the relevant tests (`cargo test` and/or `cargo test -p `) to confirm. +- [x] Record the outcome, assumptions, and any remaining issues in the Review section with timestamp. + +## Review (2026-01-09 16:28 UTC) + +- Summary: Added `tempfile` as a dev-dependency for `edgezero-macros` so the included manifest tests compile, and `cargo test` now passes across the workspace. +- Assumptions: Keeping manifest tests in the macro crate is acceptable for now; we rely on workspace-level `tempfile` versioning. +- Outstanding: None. diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index ec79d45..f35e6c4 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -353,6 +353,258 @@ mod tests { .unwrap(); let result = read_axum_project(&root.join("axum.toml")); + match result { + Ok(_) => panic!("expected error"), + Err(e) => assert!(e.contains("must be between 1 and 65535")), + } + } + + #[test] + fn read_axum_project_rejects_zero_port() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nport = 0\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let result = read_axum_project(&root.join("axum.toml")); + assert!(result.is_err()); + } + + #[test] + fn read_axum_project_rejects_negative_port() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nport = -1\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let result = read_axum_project(&root.join("axum.toml")); + assert!(result.is_err()); + } + + #[test] + fn read_axum_project_rejects_missing_adapter_table() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("axum.toml"), "[other]\nkey = \"value\"\n").unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let result = read_axum_project(&root.join("axum.toml")); + match result { + Ok(_) => panic!("expected error"), + Err(e) => assert!(e.contains("adapter table missing")), + } + } + + #[test] + fn read_axum_project_rejects_missing_crate_dir() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("axum.toml"), "[adapter]\ncrate = \"demo\"\n").unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let result = read_axum_project(&root.join("axum.toml")); + match result { + Ok(_) => panic!("expected error"), + Err(e) => assert!(e.contains("crate_dir missing")), + } + } + + #[test] + fn read_axum_project_rejects_missing_cargo_toml() { + let dir = tempdir().unwrap(); + let root = dir.path(); + let subdir = root.join("subdir"); + fs::create_dir_all(&subdir).unwrap(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \"subdir\"\n", + ) + .unwrap(); + // No Cargo.toml in subdir + + let result = read_axum_project(&root.join("axum.toml")); + match result { + Ok(_) => panic!("expected error"), + Err(e) => assert!(e.contains("Cargo.toml missing")), + } + } + + #[test] + fn read_axum_project_falls_back_to_package_name() { + let dir = tempdir().unwrap(); + let root = dir.path(); + // No crate key in adapter table + fs::write(root.join("axum.toml"), "[adapter]\ncrate_dir = \".\"\n").unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"my-package\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let project = read_axum_project(&root.join("axum.toml")).expect("project"); + assert_eq!(project.crate_name, "my-package"); + } + + #[test] + fn read_axum_project_with_relative_crate_dir() { + let dir = tempdir().unwrap(); + let root = dir.path(); + let adapter_dir = root.join("crates/my-adapter"); + fs::create_dir_all(&adapter_dir).unwrap(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"my-adapter\"\ncrate_dir = \"crates/my-adapter\"\n", + ) + .unwrap(); + fs::write( + adapter_dir.join("Cargo.toml"), + "[package]\nname = \"my-adapter\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let project = read_axum_project(&root.join("axum.toml")).expect("project"); + assert_eq!(project.crate_name, "my-adapter"); + assert_eq!(project.crate_dir, adapter_dir); + } + + #[test] + fn read_axum_project_accepts_max_valid_port() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nport = 65535\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let project = read_axum_project(&root.join("axum.toml")).expect("project"); + assert_eq!(project.port, 65535); + } + + #[test] + fn read_axum_project_accepts_min_valid_port() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nport = 1\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let project = read_axum_project(&root.join("axum.toml")).expect("project"); + assert_eq!(project.port, 1); + } + + #[test] + fn find_axum_manifest_returns_error_when_not_found() { + let dir = tempdir().unwrap(); + let root = dir.path(); + // Create an empty directory with a Cargo.toml but no axum.toml + fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); + + let result = find_axum_manifest(root); assert!(result.is_err()); + assert!(result.unwrap_err().contains("could not locate axum.toml")); + } + + #[test] + fn find_axum_manifest_finds_in_current_dir() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\n", + ) + .unwrap(); + + let found = find_axum_manifest(root).expect("manifest"); + assert_eq!(found, root.join("axum.toml")); + } + + #[test] + fn find_axum_manifest_finds_closest() { + let dir = tempdir().unwrap(); + let root = dir.path(); + let nested = root.join("level1/level2"); + fs::create_dir_all(&nested).unwrap(); + + // Create axum.toml at root + fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"root\"\ncrate_dir = \".\"\n", + ) + .unwrap(); + + // Create axum.toml at level1 + fs::write( + root.join("level1/Cargo.toml"), + "[package]\nname = \"level1\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + fs::write( + root.join("level1/axum.toml"), + "[adapter]\ncrate = \"level1\"\ncrate_dir = \".\"\n", + ) + .unwrap(); + + // Search from level2, should find level1's axum.toml (closer) + let found = find_axum_manifest(&nested).expect("manifest"); + assert_eq!(found, root.join("level1/axum.toml")); + } + + #[test] + fn deploy_returns_error() { + let result = deploy(&[]); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("does not define a deploy command")); + } + + #[test] + fn adapter_name_is_axum() { + assert_eq!(AXUM_ADAPTER.name(), "axum"); + } + + #[test] + fn blueprint_has_correct_id() { + assert_eq!(AXUM_BLUEPRINT.id, "axum"); + assert_eq!(AXUM_BLUEPRINT.display_name, "Axum"); } } diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index ea73567..7170003 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -70,31 +70,45 @@ impl AxumDevServer { let listener = tokio::net::TcpListener::from_std(listener) .context("failed to adopt std listener into tokio")?; - let service = EdgeZeroAxumService::new(router); - let router = Router::new().fallback_service(service_fn(move |req| { - let mut svc = service.clone(); - async move { svc.call(req).await } - })); - let make_service = router.into_make_service_with_connect_info::(); - - let shutdown = if config.enable_ctrl_c { - Some(async { - let _ = signal::ctrl_c().await; - }) - } else { - None - }; + serve_with_listener(router, listener, config.enable_ctrl_c).await + } - let server = axum::serve(listener, make_service); - if let Some(shutdown) = shutdown { - let server = server.with_graceful_shutdown(shutdown); - server.await.context("axum server error")?; - } else { - server.await.context("axum server error")?; - } + #[cfg(test)] + async fn run_with_listener(self, listener: tokio::net::TcpListener) -> anyhow::Result<()> { + let AxumDevServer { router, config } = self; + serve_with_listener(router, listener, config.enable_ctrl_c).await + } +} + +async fn serve_with_listener( + router: RouterService, + listener: tokio::net::TcpListener, + enable_ctrl_c: bool, +) -> anyhow::Result<()> { + let service = EdgeZeroAxumService::new(router); + let router = Router::new().fallback_service(service_fn(move |req| { + let mut svc = service.clone(); + async move { svc.call(req).await } + })); + let make_service = router.into_make_service_with_connect_info::(); + + let shutdown = if enable_ctrl_c { + Some(async { + let _ = signal::ctrl_c().await; + }) + } else { + None + }; - Ok(()) + let server = axum::serve(listener, make_service); + if let Some(shutdown) = shutdown { + let server = server.with_graceful_shutdown(shutdown); + server.await.context("axum server error")?; + } else { + server.await.context("axum server error")?; } + + Ok(()) } pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { @@ -115,3 +129,236 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { AxumDevServer::new(router).run() } + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr}; + + #[test] + fn default_config_uses_expected_address() { + let config = AxumDevServerConfig::default(); + assert_eq!(config.addr.ip(), IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); + assert_eq!(config.addr.port(), 8787); + } + + #[test] + fn default_config_enables_ctrl_c() { + let config = AxumDevServerConfig::default(); + assert!(config.enable_ctrl_c); + } + + #[test] + fn config_can_be_cloned() { + let config = AxumDevServerConfig::default(); + let cloned = config.clone(); + assert_eq!(cloned.addr, config.addr); + assert_eq!(cloned.enable_ctrl_c, config.enable_ctrl_c); + } + + #[test] + fn config_with_custom_address() { + let addr = SocketAddr::from(([0, 0, 0, 0], 3000)); + let config = AxumDevServerConfig { + addr, + enable_ctrl_c: false, + }; + assert_eq!(config.addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(config.addr.port(), 3000); + assert!(!config.enable_ctrl_c); + } + + #[test] + fn dev_server_new_uses_default_config() { + use edgezero_core::router::RouterService; + + let router = RouterService::builder().build(); + let server = AxumDevServer::new(router); + assert_eq!(server.config.addr.port(), 8787); + assert!(server.config.enable_ctrl_c); + } + + #[test] + fn dev_server_with_config_uses_custom_config() { + use edgezero_core::router::RouterService; + + let router = RouterService::builder().build(); + let config = AxumDevServerConfig { + addr: SocketAddr::from(([127, 0, 0, 1], 9000)), + enable_ctrl_c: false, + }; + let server = AxumDevServer::with_config(router, config); + assert_eq!(server.config.addr.port(), 9000); + assert!(!server.config.enable_ctrl_c); + } +} + +#[cfg(test)] +mod integration_tests { + use super::*; + use edgezero_core::context::RequestContext; + use edgezero_core::error::EdgeError; + use edgezero_core::router::RouterService; + use std::time::{Duration, Instant}; + + struct TestServer { + base_url: String, + handle: tokio::task::JoinHandle<()>, + } + + async fn start_test_server(router: RouterService) -> TestServer { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test server"); + let addr = listener.local_addr().expect("local addr"); + let config = AxumDevServerConfig { + addr, + enable_ctrl_c: false, + }; + let server = AxumDevServer::with_config(router, config); + + let handle = tokio::spawn(async move { + let _ = server.run_with_listener(listener).await; + }); + + TestServer { + base_url: format!("http://{}", addr), + handle, + } + } + + async fn send_with_retry( + client: &reqwest::Client, + mut make_request: F, + ) -> reqwest::Response + where + F: FnMut(&reqwest::Client) -> reqwest::RequestBuilder, + { + let start = Instant::now(); + let timeout = Duration::from_secs(2); + + loop { + match make_request(client).send().await { + Ok(response) => return response, + Err(err) => { + if start.elapsed() >= timeout { + panic!("server did not respond before timeout: {}", err); + } + } + } + + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn server_responds_to_requests() { + async fn handler(_ctx: RequestContext) -> Result<&'static str, EdgeError> { + Ok("hello from dev server") + } + + let router = RouterService::builder().get("/test", handler).build(); + let server = start_test_server(router).await; + + let client = reqwest::Client::new(); + let url = format!("{}/test", server.base_url); + let response = send_with_retry(&client, |client| client.get(url.as_str())).await; + + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!(response.text().await.unwrap(), "hello from dev server"); + + server.handle.abort(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn server_returns_404_for_unknown_routes() { + let router = RouterService::builder().build(); + let server = start_test_server(router).await; + + let client = reqwest::Client::new(); + let url = format!("{}/nonexistent", server.base_url); + let response = send_with_retry(&client, |client| client.get(url.as_str())).await; + + assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND); + + server.handle.abort(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn server_returns_method_not_allowed() { + async fn handler(_ctx: RequestContext) -> Result<&'static str, EdgeError> { + Ok("ok") + } + + let router = RouterService::builder().post("/submit", handler).build(); + let server = start_test_server(router).await; + + let client = reqwest::Client::new(); + let url = format!("{}/submit", server.base_url); + let response = send_with_retry(&client, |client| client.get(url.as_str())).await; + + assert_eq!(response.status(), reqwest::StatusCode::METHOD_NOT_ALLOWED); + + server.handle.abort(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn server_forwards_headers() { + async fn handler(ctx: RequestContext) -> Result { + let value = ctx + .request() + .headers() + .get("x-custom") + .and_then(|v| v.to_str().ok()) + .unwrap_or("missing"); + Ok(value.to_string()) + } + + let router = RouterService::builder().get("/headers", handler).build(); + let server = start_test_server(router).await; + + let client = reqwest::Client::new(); + let url = format!("{}/headers", server.base_url); + let response = send_with_retry(&client, |client| { + client.get(url.as_str()).header("x-custom", "my-value") + }) + .await; + + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!(response.text().await.unwrap(), "my-value"); + + server.handle.abort(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn server_fails_to_bind_to_used_port() { + // First bind to a port + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind first"); + let addr = listener.local_addr().expect("listener addr"); + + // Try to start server on same port + let router = RouterService::builder().build(); + let config = AxumDevServerConfig { + addr, + enable_ctrl_c: false, + }; + let server = AxumDevServer::with_config(router, config); + + // Run in blocking mode to capture the error + let result = tokio::task::spawn_blocking(move || server.run()).await; + + match result { + Ok(Err(e)) => { + let err_str = e.to_string(); + assert!( + err_str.contains("bind") || err_str.contains("address"), + "expected bind error, got: {}", + err_str + ); + } + _ => panic!("expected bind error"), + } + + drop(listener); + } +} diff --git a/crates/edgezero-adapter-axum/src/proxy.rs b/crates/edgezero-adapter-axum/src/proxy.rs index 4b17e5f..6014955 100644 --- a/crates/edgezero-adapter-axum/src/proxy.rs +++ b/crates/edgezero-adapter-axum/src/proxy.rs @@ -85,4 +85,243 @@ mod tests { let req = reqwest_method(&method).expect("reqwest method"); assert_eq!(req, reqwest::Method::POST); } + + #[test] + fn converts_all_methods_to_reqwest() { + let cases = [ + (Method::GET, reqwest::Method::GET), + (Method::POST, reqwest::Method::POST), + (Method::PUT, reqwest::Method::PUT), + (Method::DELETE, reqwest::Method::DELETE), + (Method::PATCH, reqwest::Method::PATCH), + (Method::HEAD, reqwest::Method::HEAD), + (Method::OPTIONS, reqwest::Method::OPTIONS), + ]; + for (input, expected) in cases { + let result = reqwest_method(&input).expect("method conversion"); + assert_eq!(result, expected); + } + } + + #[test] + fn default_client_creates_successfully() { + let client = AxumProxyClient::default(); + // Just verify it builds without panicking + assert!(std::mem::size_of_val(&client) > 0); + } +} + +#[cfg(test)] +mod integration_tests { + use super::*; + use axum::{routing::get, routing::post, Router}; + use edgezero_core::http::Uri; + use edgezero_core::proxy::ProxyClient; + use tokio::net::TcpListener; + + async fn start_test_server(router: Router) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + format!("http://{}", addr) + } + + #[tokio::test] + async fn proxy_client_sends_get_request() { + let app = Router::new().route("/test", get(|| async { "hello from server" })); + let base_url = start_test_server(app).await; + + let client = AxumProxyClient::default(); + let uri: Uri = format!("{}/test", base_url).parse().unwrap(); + let request = ProxyRequest::new(Method::GET, uri); + + let response = client.send(request).await.expect("response"); + assert_eq!(response.status(), StatusCode::OK); + + match response.body() { + Body::Once(bytes) => assert_eq!(bytes.as_ref(), b"hello from server"), + _ => panic!("expected buffered body"), + } + } + + #[tokio::test] + async fn proxy_client_sends_post_with_body() { + let app = Router::new().route("/echo", post(|body: axum::body::Bytes| async move { body })); + let base_url = start_test_server(app).await; + + let client = AxumProxyClient::default(); + let uri: Uri = format!("{}/echo", base_url).parse().unwrap(); + let mut request = ProxyRequest::new(Method::POST, uri); + *request.body_mut() = Body::from("request body data"); + + let response = client.send(request).await.expect("response"); + assert_eq!(response.status(), StatusCode::OK); + + match response.body() { + Body::Once(bytes) => assert_eq!(bytes.as_ref(), b"request body data"), + _ => panic!("expected buffered body"), + } + } + + #[tokio::test] + async fn proxy_client_forwards_request_headers() { + let app = Router::new().route( + "/headers", + get(|headers: axum::http::HeaderMap| async move { + headers + .get("x-custom-header") + .and_then(|v| v.to_str().ok()) + .unwrap_or("missing") + .to_string() + }), + ); + let base_url = start_test_server(app).await; + + let client = AxumProxyClient::default(); + let uri: Uri = format!("{}/headers", base_url).parse().unwrap(); + let mut request = ProxyRequest::new(Method::GET, uri); + request + .headers_mut() + .insert("x-custom-header", HeaderValue::from_static("custom-value")); + + let response = client.send(request).await.expect("response"); + assert_eq!(response.status(), StatusCode::OK); + + match response.body() { + Body::Once(bytes) => assert_eq!(bytes.as_ref(), b"custom-value"), + _ => panic!("expected buffered body"), + } + } + + #[tokio::test] + async fn proxy_client_receives_response_headers() { + let app = Router::new().route( + "/with-headers", + get(|| async { + ( + [(axum::http::header::CONTENT_TYPE, "application/json")], + "{}", + ) + }), + ); + let base_url = start_test_server(app).await; + + let client = AxumProxyClient::default(); + let uri: Uri = format!("{}/with-headers", base_url).parse().unwrap(); + let request = ProxyRequest::new(Method::GET, uri); + + let response = client.send(request).await.expect("response"); + assert_eq!(response.status(), StatusCode::OK); + + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()); + assert_eq!(content_type, Some("application/json")); + } + + #[tokio::test] + async fn proxy_client_handles_404() { + let app = Router::new(); + let base_url = start_test_server(app).await; + + let client = AxumProxyClient::default(); + let uri: Uri = format!("{}/nonexistent", base_url).parse().unwrap(); + let request = ProxyRequest::new(Method::GET, uri); + + let response = client.send(request).await.expect("response"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn proxy_client_handles_500() { + let app = Router::new().route( + "/error", + get(|| async { (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "error") }), + ); + let base_url = start_test_server(app).await; + + let client = AxumProxyClient::default(); + let uri: Uri = format!("{}/error", base_url).parse().unwrap(); + let request = ProxyRequest::new(Method::GET, uri); + + let response = client.send(request).await.expect("response"); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[tokio::test] + async fn proxy_client_handles_various_methods() { + let app = Router::new() + .route("/method", get(|| async { "GET" })) + .route("/method", post(|| async { "POST" })) + .route("/method", axum::routing::put(|| async { "PUT" })) + .route("/method", axum::routing::delete(|| async { "DELETE" })) + .route("/method", axum::routing::patch(|| async { "PATCH" })); + let base_url = start_test_server(app).await; + + let client = AxumProxyClient::default(); + + for (method, expected_body) in [ + (Method::GET, "GET"), + (Method::POST, "POST"), + (Method::PUT, "PUT"), + (Method::DELETE, "DELETE"), + (Method::PATCH, "PATCH"), + ] { + let uri: Uri = format!("{}/method", base_url).parse().unwrap(); + let request = ProxyRequest::new(method, uri); + let response = client.send(request).await.expect("response"); + assert_eq!(response.status(), StatusCode::OK); + match response.body() { + Body::Once(bytes) => assert_eq!(bytes.as_ref(), expected_body.as_bytes()), + _ => panic!("expected buffered body"), + } + } + } + + #[tokio::test] + async fn proxy_client_handles_connection_refused() { + let client = AxumProxyClient::default(); + // Use a port that's unlikely to have anything running + let uri: Uri = "http://127.0.0.1:1".parse().unwrap(); + let request = ProxyRequest::new(Method::GET, uri); + + let result = client.send(request).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn proxy_client_sends_streaming_body() { + use bytes::Bytes; + use futures::stream; + + let app = Router::new().route( + "/stream-echo", + post(|body: axum::body::Bytes| async move { body }), + ); + let base_url = start_test_server(app).await; + + let client = AxumProxyClient::default(); + let uri: Uri = format!("{}/stream-echo", base_url).parse().unwrap(); + let mut request = ProxyRequest::new(Method::POST, uri); + + // Create a streaming body - Body::stream expects Stream + let chunks = vec![ + Bytes::from("chunk1"), + Bytes::from("chunk2"), + Bytes::from("chunk3"), + ]; + let stream = stream::iter(chunks); + *request.body_mut() = Body::stream(stream); + + let response = client.send(request).await.expect("response"); + assert_eq!(response.status(), StatusCode::OK); + + match response.body() { + Body::Once(bytes) => assert_eq!(bytes.as_ref(), b"chunk1chunk2chunk3"), + _ => panic!("expected buffered body"), + } + } } diff --git a/crates/edgezero-core/Cargo.toml b/crates/edgezero-core/Cargo.toml index 11838f7..70a38ea 100644 --- a/crates/edgezero-core/Cargo.toml +++ b/crates/edgezero-core/Cargo.toml @@ -30,3 +30,4 @@ log = { workspace = true } [dev-dependencies] brotli = { workspace = true } flate2 = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/edgezero-core/src/app.rs b/crates/edgezero-core/src/app.rs index a09ec0f..9b193ef 100644 --- a/crates/edgezero-core/src/app.rs +++ b/crates/edgezero-core/src/app.rs @@ -134,4 +134,20 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.body().as_bytes(), b"ok"); } + + struct DefaultHooks; + + impl Hooks for DefaultHooks { + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[test] + fn default_hooks_use_default_name_and_into_router() { + let app = DefaultHooks::build_app(); + assert_eq!(app.name(), App::default_name()); + let router = app.into_router(); + assert!(router.routes().is_empty()); + } } diff --git a/crates/edgezero-core/src/body.rs b/crates/edgezero-core/src/body.rs index e75bb26..1600453 100644 --- a/crates/edgezero-core/src/body.rs +++ b/crates/edgezero-core/src/body.rs @@ -144,6 +144,7 @@ mod tests { use super::*; use futures::executor::block_on; use futures_util::StreamExt; + use std::io; #[test] fn collect_stream_body() { @@ -164,6 +165,24 @@ mod tests { assert_eq!(collected, b"ab"); } + #[test] + fn from_stream_maps_errors() { + let stream = futures_util::stream::iter(vec![ + Ok(Bytes::from_static(b"ok")), + Err(io::Error::new(io::ErrorKind::Other, "boom")), + ]); + let body = Body::from_stream(stream); + let mut stream = body.into_stream().expect("stream"); + let (first, second) = block_on(async { + let first = stream.next().await.expect("first").expect("ok"); + let second = stream.next().await.expect("second"); + (first, second) + }); + assert_eq!(first, Bytes::from_static(b"ok")); + let err = second.expect_err("error"); + assert!(err.to_string().contains("boom")); + } + #[test] fn to_json_fails_for_streaming_body() { let body = Body::stream(futures_util::stream::iter(vec![ @@ -181,4 +200,48 @@ mod tests { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body.into_bytes())); assert!(result.is_err()); } + + #[test] + fn as_bytes_panics_for_stream() { + let body = Body::stream(futures_util::stream::iter(vec![Bytes::from_static( + b"data", + )])); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body.as_bytes())); + assert!(result.is_err()); + } + + #[test] + fn into_stream_returns_none_for_buffered_body() { + let body = Body::from("payload"); + assert!(body.into_stream().is_none()); + } + + #[test] + fn is_stream_returns_false_for_buffered_body() { + let body = Body::from("payload"); + assert!(!body.is_stream()); + } + + #[test] + fn default_body_is_empty() { + let body = Body::default(); + assert!(body.as_bytes().is_empty()); + } + + #[test] + fn debug_formats_both_body_variants() { + let buffered = Body::from("payload"); + let buffered_debug = format!("{:?}", buffered); + assert!(buffered_debug.contains("Body::Once")); + + let stream = Body::stream(futures_util::stream::iter(vec![Bytes::from_static(b"chunk")])); + let stream_debug = format!("{:?}", stream); + assert!(stream_debug.contains("Body::Stream")); + } + + #[test] + fn from_vec_u8_builds_buffered_body() { + let body = Body::from(vec![1u8, 2u8, 3u8]); + assert_eq!(body.as_bytes(), &[1u8, 2u8, 3u8]); + } } diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index 910455d..69d2ab8 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -88,7 +88,7 @@ impl RequestContext { #[cfg(test)] mod tests { use super::*; - use crate::http::{request_builder, Method, StatusCode}; + use crate::http::{request_builder, HeaderValue, Method, StatusCode, Uri}; use crate::params::PathParams; use crate::proxy::{ProxyClient, ProxyHandle, ProxyRequest, ProxyResponse}; use async_trait::async_trait; @@ -124,6 +124,8 @@ mod tests { let ctx = ctx("/items/42", Body::empty(), params(&[("id", "42")])); let parsed: PathData = ctx.path().expect("path parameters"); assert_eq!(parsed, PathData { id: "42".into() }); + let serialized = serde_json::to_string(&parsed).expect("serialize"); + assert!(serialized.contains("42")); } #[test] @@ -133,11 +135,10 @@ mod tests { struct NumericPath { id: u32, } + let debug = format!("{:?}", NumericPath { id: 0 }); + assert!(debug.contains('0')); let ctx = ctx("/items/foo", Body::empty(), params(&[("id", "foo")])); - let err = match ctx.path::() { - Ok(_) => panic!("expected error"), - Err(err) => err, - }; + let err = ctx.path::().expect_err("expected error"); assert_eq!(err.status(), StatusCode::BAD_REQUEST); assert!(err.message().contains("invalid path parameters")); } @@ -153,6 +154,17 @@ mod tests { assert_eq!(parsed, Query { page: 5 }); } + #[test] + fn query_defaults_to_empty_when_missing() { + #[derive(Debug, Deserialize, PartialEq)] + struct Query { + page: Option, + } + let ctx = ctx("/items", Body::empty(), PathParams::default()); + let parsed: Query = ctx.query().expect("query"); + assert_eq!(parsed.page, None); + } + #[test] fn invalid_query_returns_bad_request() { #[allow(dead_code)] @@ -160,6 +172,8 @@ mod tests { struct Query { page: u8, } + let debug = format!("{:?}", Query { page: 0 }); + assert!(debug.contains('0')); let ctx = ctx("/items?page=foo", Body::empty(), PathParams::default()); let err = ctx.query::().expect_err("expected error"); assert_eq!(err.status(), StatusCode::BAD_REQUEST); @@ -210,6 +224,33 @@ mod tests { name: "demo".into() } ); + let debug = format!("{:?}", parsed); + assert!(debug.contains("demo")); + } + + #[test] + fn invalid_form_returns_bad_request() { + #[allow(dead_code)] + #[derive(Deserialize)] + struct FormData { + age: u8, + } + let body = Body::from("age=not-a-number"); + let ctx = ctx("/submit", body, PathParams::default()); + let err = ctx + .form::() + .err() + .expect("expected error"); + assert_eq!(err.status(), StatusCode::BAD_REQUEST); + assert!(err.message().contains("invalid form payload")); + } + + #[test] + fn form_value_deserialises_successfully() { + let body = Body::from("name=demo"); + let ctx = ctx("/submit", body, PathParams::default()); + let parsed: serde_json::Value = ctx.form().expect("form data"); + assert_eq!(parsed.get("name").and_then(|value| value.as_str()), Some("demo")); } #[test] @@ -247,4 +288,33 @@ mod tests { let ctx = RequestContext::new(request, PathParams::default()); assert!(ctx.proxy_handle().is_some()); } + + #[test] + fn request_context_accessors_return_expected_values() { + let mut ctx = ctx("/items/123", Body::from("payload"), params(&[("id", "123")])); + assert_eq!(ctx.request().uri().path(), "/items/123"); + ctx.request_mut() + .headers_mut() + .insert("x-test", HeaderValue::from_static("value")); + assert_eq!( + ctx.request() + .headers() + .get("x-test") + .and_then(|v| v.to_str().ok()), + Some("value") + ); + assert_eq!(ctx.path_params().get("id"), Some("123")); + assert_eq!(ctx.body().as_bytes(), b"payload"); + + let request = ctx.into_request(); + assert_eq!(request.uri().path(), "/items/123"); + } + + #[test] + fn proxy_handle_forwards_with_dummy_client() { + let handle = ProxyHandle::with_client(DummyClient); + let request = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); + let response = futures::executor::block_on(handle.forward(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + } } diff --git a/crates/edgezero-core/src/error.rs b/crates/edgezero-core/src/error.rs index 4b27626..448bd8c 100644 --- a/crates/edgezero-core/src/error.rs +++ b/crates/edgezero-core/src/error.rs @@ -1,4 +1,5 @@ use anyhow::Error as AnyError; +use serde::Serialize; use serde_json::json; use thiserror::Error; @@ -97,6 +98,10 @@ impl EdgeError { } } +fn json_or_text(payload: &T) -> Body { + Body::json(payload).unwrap_or_else(|_| Body::text("internal error")) +} + impl IntoResponse for EdgeError { fn into_response(self) -> Response { let payload = json!({ @@ -106,7 +111,7 @@ impl IntoResponse for EdgeError { } }); - let body = Body::json(&payload).unwrap_or_else(|_| Body::text("internal error")); + let body = json_or_text(&payload); let mut response = response_with_body(self.status(), body); response .headers_mut() @@ -119,6 +124,7 @@ impl IntoResponse for EdgeError { mod tests { use super::*; use crate::http::Method; + use serde::ser; #[test] fn bad_request_sets_status_and_message() { @@ -142,6 +148,45 @@ mod tests { assert!(err.source().is_some()); } + #[test] + fn not_found_sets_status_and_message() { + let err = EdgeError::not_found("/missing"); + assert_eq!(err.status(), StatusCode::NOT_FOUND); + assert!(err.message().contains("/missing")); + } + + #[test] + fn validation_sets_status_and_message() { + let err = EdgeError::validation("invalid input"); + assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(err.message(), "invalid input"); + assert!(err.source().is_none()); + } + + #[test] + fn method_not_allowed_handles_empty_allowed_list() { + let err = EdgeError::method_not_allowed(&Method::GET, &[]); + assert_eq!(err.status(), StatusCode::METHOD_NOT_ALLOWED); + assert!(err.message().contains("(none)")); + } + + #[test] + fn json_or_text_falls_back_on_serialization_error() { + struct FailingSerialize; + + impl Serialize for FailingSerialize { + fn serialize(&self, _serializer: S) -> Result + where + S: serde::Serializer, + { + Err(ser::Error::custom("boom")) + } + } + + let body = json_or_text(&FailingSerialize); + assert_eq!(body.as_bytes(), b"internal error"); + } + #[test] fn into_response_sets_json_payload() { let response = EdgeError::bad_request("invalid").into_response(); diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index e89448d..db4654c 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -1,6 +1,7 @@ use std::ops::{Deref, DerefMut}; use async_trait::async_trait; +use http::header; use serde::de::DeserializeOwned; use validator::Validate; @@ -110,6 +111,50 @@ impl Headers { } } +/// Extracts the effective host from the request. +/// +/// Checks headers in this order: +/// 1. `X-Forwarded-Host` - set by reverse proxies/load balancers +/// 2. `Host` - standard HTTP host header +/// 3. Falls back to "localhost" if neither is present +/// +/// # Example +/// ```ignore +/// #[action] +/// pub async fn handler(Host(host): Host) -> Response { +/// // host contains the effective hostname +/// } +/// ``` +pub struct Host(pub String); + +#[async_trait(?Send)] +impl FromRequest for Host { + async fn from_request(ctx: &RequestContext) -> Result { + let headers = ctx.request().headers(); + let host = headers + .get("x-forwarded-host") + .or_else(|| headers.get(header::HOST)) + .and_then(|v| v.to_str().ok()) + .unwrap_or("localhost") + .to_string(); + Ok(Host(host)) + } +} + +impl Deref for Host { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Host { + pub fn into_inner(self) -> String { + self.0 + } +} + pub struct Query(pub T); #[async_trait(?Send)] @@ -373,10 +418,9 @@ mod tests { #[test] fn json_extractor_propagates_errors() { let ctx = ctx(Body::from("not json"), PathParams::default()); - let err = match block_on(Json::::from_request(&ctx)) { - Ok(_) => panic!("expected error"), - Err(err) => err, - }; + let err = block_on(Json::::from_request(&ctx)) + .err() + .expect("expected error"); assert_eq!(err.status(), StatusCode::BAD_REQUEST); } @@ -384,10 +428,9 @@ mod tests { fn validated_json_rejects_invalid_payloads() { let body = Body::json(&ValidatedPayload { name: "".into() }).expect("json"); let ctx = ctx(body, PathParams::default()); - let err = match block_on(ValidatedJson::::from_request(&ctx)) { - Ok(_) => panic!("expected validation error"), - Err(err) => err, - }; + let err = block_on(ValidatedJson::::from_request(&ctx)) + .err() + .expect("expected validation error"); assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY); } @@ -410,4 +453,364 @@ mod tests { "value" ); } + + // Query extractor tests + #[derive(Debug, Deserialize, PartialEq)] + struct QueryParams { + page: Option, + q: Option, + } + + fn ctx_with_query(query: &str) -> RequestContext { + let uri = format!("/test?{}", query); + let request = request_builder() + .method(Method::GET) + .uri(uri) + .body(Body::empty()) + .expect("request"); + RequestContext::new(request, PathParams::default()) + } + + #[test] + fn query_extractor_parses_params() { + let ctx = ctx_with_query("page=5&q=hello"); + let query = block_on(Query::::from_request(&ctx)).expect("query"); + assert_eq!(query.page, Some(5)); + assert_eq!(query.q.as_deref(), Some("hello")); + } + + #[test] + fn query_extractor_handles_missing_optional_params() { + let ctx = ctx_with_query("page=1"); + let query = block_on(Query::::from_request(&ctx)).expect("query"); + assert_eq!(query.page, Some(1)); + assert_eq!(query.q, None); + } + + #[test] + fn query_extractor_handles_empty_query() { + let request = request_builder() + .method(Method::GET) + .uri("/test") + .body(Body::empty()) + .expect("request"); + let ctx = RequestContext::new(request, PathParams::default()); + let query = block_on(Query::::from_request(&ctx)).expect("query"); + assert_eq!(query.page, None); + assert_eq!(query.q, None); + } + + #[derive(Debug, Deserialize, Validate)] + struct ValidatedQueryParams { + #[validate(range(min = 1, max = 100))] + page: u32, + } + + #[test] + fn validated_query_accepts_valid_params() { + let ctx = ctx_with_query("page=50"); + let query = + block_on(ValidatedQuery::::from_request(&ctx)).expect("query"); + assert_eq!(query.page, 50); + } + + #[test] + fn validated_query_rejects_invalid_params() { + let ctx = ctx_with_query("page=200"); + let err = block_on(ValidatedQuery::::from_request(&ctx)) + .err() + .expect("expected validation error"); + assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY); + } + + // Form extractor tests + fn ctx_with_form(body: &str) -> RequestContext { + let request = request_builder() + .method(Method::POST) + .uri("/test") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from(body.to_string())) + .expect("request"); + RequestContext::new(request, PathParams::default()) + } + + #[derive(Debug, Deserialize, PartialEq)] + struct FormData { + username: String, + age: Option, + } + + #[test] + fn form_extractor_parses_urlencoded_body() { + let ctx = ctx_with_form("username=alice&age=30"); + let form = block_on(Form::::from_request(&ctx)).expect("form"); + assert_eq!(form.username, "alice"); + assert_eq!(form.age, Some(30)); + } + + #[test] + fn form_extractor_handles_missing_optional_fields() { + let ctx = ctx_with_form("username=bob"); + let form = block_on(Form::::from_request(&ctx)).expect("form"); + assert_eq!(form.username, "bob"); + assert_eq!(form.age, None); + } + + #[derive(Debug, Deserialize, Validate)] + struct ValidatedFormData { + #[validate(length(min = 3))] + username: String, + } + + #[test] + fn validated_form_accepts_valid_data() { + let ctx = ctx_with_form("username=alice"); + let form = block_on(ValidatedForm::::from_request(&ctx)).expect("form"); + assert_eq!(form.username, "alice"); + } + + #[test] + fn validated_form_rejects_invalid_data() { + let ctx = ctx_with_form("username=ab"); + let err = block_on(ValidatedForm::::from_request(&ctx)) + .err() + .expect("expected validation error"); + assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY); + } + + // ValidatedPath tests + #[derive(Debug, Deserialize, Validate)] + struct ValidatedPathParams { + #[validate(length(min = 1, max = 10))] + id: String, + } + + #[test] + fn validated_path_accepts_valid_params() { + let ctx = ctx(Body::empty(), params(&[("id", "abc123")])); + let path = + block_on(ValidatedPath::::from_request(&ctx)).expect("path"); + assert_eq!(path.id, "abc123"); + } + + #[test] + fn validated_path_rejects_invalid_params() { + let ctx = ctx(Body::empty(), params(&[("id", "this-id-is-way-too-long")])); + let err = block_on(ValidatedPath::::from_request(&ctx)) + .err() + .expect("expected validation error"); + assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY); + } + + // Deref/DerefMut and into_inner tests + #[test] + fn json_deref_and_into_inner() { + let json = Json(Payload { + name: "test".into(), + }); + assert_eq!(json.name, "test"); // Deref + let inner = json.into_inner(); + assert_eq!(inner.name, "test"); + } + + #[test] + fn json_deref_mut() { + let mut json = Json(Payload { name: "old".into() }); + json.name = "new".into(); // DerefMut + assert_eq!(json.name, "new"); + } + + #[test] + fn query_deref_and_into_inner() { + let query = Query(QueryParams { + page: Some(1), + q: None, + }); + assert_eq!(query.page, Some(1)); // Deref + let inner = query.into_inner(); + assert_eq!(inner.page, Some(1)); + } + + #[test] + fn query_deref_mut() { + let mut query = Query(QueryParams { + page: Some(1), + q: None, + }); + query.page = Some(2); // DerefMut + assert_eq!(query.page, Some(2)); + } + + #[test] + fn path_deref_and_into_inner() { + let path = Path(PathPayload { id: "123".into() }); + assert_eq!(path.id, "123"); // Deref + let inner = path.into_inner(); + assert_eq!(inner.id, "123"); + } + + #[test] + fn path_deref_mut() { + let mut path = Path(PathPayload { id: "old".into() }); + path.id = "new".into(); // DerefMut + assert_eq!(path.id, "new"); + } + + #[test] + fn form_deref_and_into_inner() { + let form = Form(FormData { + username: "alice".into(), + age: Some(25), + }); + assert_eq!(form.username, "alice"); // Deref + let inner = form.into_inner(); + assert_eq!(inner.username, "alice"); + } + + #[test] + fn form_deref_mut() { + let mut form = Form(FormData { + username: "alice".into(), + age: None, + }); + form.age = Some(30); // DerefMut + assert_eq!(form.age, Some(30)); + } + + #[test] + fn headers_deref_and_into_inner() { + let mut map = HeaderMap::new(); + map.insert("x-custom", HeaderValue::from_static("value")); + let headers = Headers(map); + assert!(headers.get("x-custom").is_some()); // Deref + let inner = headers.into_inner(); + assert!(inner.get("x-custom").is_some()); + } + + #[test] + fn headers_deref_mut() { + let mut headers = Headers(HeaderMap::new()); + headers.insert("x-new", HeaderValue::from_static("value")); // DerefMut + assert!(headers.get("x-new").is_some()); + } + + #[test] + fn validated_json_deref_and_into_inner() { + let json = ValidatedJson(ValidatedPayload { + name: "test".into(), + }); + assert_eq!(json.name, "test"); // Deref + let inner = json.into_inner(); + assert_eq!(inner.name, "test"); + } + + #[test] + fn validated_json_deref_mut() { + let mut json = ValidatedJson(ValidatedPayload { name: "old".into() }); + json.name = "new".into(); // DerefMut + assert_eq!(json.name, "new"); + } + + #[test] + fn validated_query_into_inner() { + let query = ValidatedQuery(ValidatedQueryParams { page: 10 }); + assert_eq!(query.page, 10); // Deref + let inner = query.into_inner(); + assert_eq!(inner.page, 10); + } + + #[test] + fn validated_query_deref_mut() { + let mut query = ValidatedQuery(ValidatedQueryParams { page: 10 }); + query.page = 20; // DerefMut + assert_eq!(query.page, 20); + } + + #[test] + fn validated_path_into_inner() { + let path = ValidatedPath(ValidatedPathParams { id: "abc".into() }); + assert_eq!(path.id, "abc"); // Deref + let inner = path.into_inner(); + assert_eq!(inner.id, "abc"); + } + + #[test] + fn validated_path_deref_mut() { + let mut path = ValidatedPath(ValidatedPathParams { id: "old".into() }); + path.id = "new".into(); // DerefMut + assert_eq!(path.id, "new"); + } + + #[test] + fn validated_form_into_inner() { + let form = ValidatedForm(ValidatedFormData { + username: "alice".into(), + }); + assert_eq!(form.username, "alice"); // Deref + let inner = form.into_inner(); + assert_eq!(inner.username, "alice"); + } + + #[test] + fn validated_form_deref_mut() { + let mut form = ValidatedForm(ValidatedFormData { + username: "old".into(), + }); + form.username = "new".into(); // DerefMut + assert_eq!(form.username, "new"); + } + + // Host extractor tests + #[test] + fn host_extractor_uses_x_forwarded_host_first() { + let mut request = request_builder() + .method(Method::GET) + .uri("/test") + .body(Body::empty()) + .expect("request"); + request + .headers_mut() + .insert("host", HeaderValue::from_static("internal.local")); + request + .headers_mut() + .insert("x-forwarded-host", HeaderValue::from_static("example.com")); + let ctx = RequestContext::new(request, PathParams::default()); + let host = block_on(Host::from_request(&ctx)).expect("host"); + assert_eq!(host.0, "example.com"); + } + + #[test] + fn host_extractor_falls_back_to_host_header() { + let mut request = request_builder() + .method(Method::GET) + .uri("/test") + .body(Body::empty()) + .expect("request"); + request + .headers_mut() + .insert("host", HeaderValue::from_static("example.com")); + let ctx = RequestContext::new(request, PathParams::default()); + let host = block_on(Host::from_request(&ctx)).expect("host"); + assert_eq!(host.0, "example.com"); + } + + #[test] + fn host_extractor_uses_default_when_no_headers() { + let request = request_builder() + .method(Method::GET) + .uri("/test") + .body(Body::empty()) + .expect("request"); + let ctx = RequestContext::new(request, PathParams::default()); + let host = block_on(Host::from_request(&ctx)).expect("host"); + assert_eq!(host.0, "localhost"); + } + + #[test] + fn host_deref_and_into_inner() { + let host = Host("example.com".to_string()); + assert_eq!(&*host, "example.com"); // Deref + let inner = host.into_inner(); + assert_eq!(inner, "example.com"); + } } diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index eb7ae81..3fb7874 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -28,12 +28,7 @@ impl ManifestLoader { let mut manifest: Manifest = toml::from_str(&contents) .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; let cwd = std::env::current_dir()?; - let root_path = match path.parent() { - Some(parent) if parent.as_os_str().is_empty() => cwd.clone(), - Some(parent) if parent.is_relative() => cwd.join(parent), - Some(parent) => parent.to_path_buf(), - None => cwd, - }; + let root_path = resolve_root_path(path, &cwd); manifest.root = Some(root_path); manifest .validate() @@ -49,6 +44,15 @@ impl ManifestLoader { } } +fn resolve_root_path(path: &Path, cwd: &Path) -> PathBuf { + match path.parent() { + Some(parent) if parent.as_os_str().is_empty() => cwd.to_path_buf(), + Some(parent) if parent.is_relative() => cwd.join(parent), + Some(parent) => parent.to_path_buf(), + None => cwd.to_path_buf(), + } +} + #[derive(Debug, Deserialize, Validate)] pub struct Manifest { #[serde(default)] @@ -406,7 +410,7 @@ impl<'de> Deserialize<'de> for HttpMethod { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum BodyMode { Buffered, Stream, @@ -429,10 +433,11 @@ impl<'de> Deserialize<'de> for BodyMode { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] pub enum LogLevel { Trace, Debug, + #[default] Info, Warn, Error, @@ -465,12 +470,6 @@ impl From for LevelFilter { } } -impl Default for LogLevel { - fn default() -> Self { - Self::Info - } -} - impl<'de> Deserialize<'de> for LogLevel { fn deserialize(deserializer: D) -> Result where @@ -495,6 +494,9 @@ impl<'de> Deserialize<'de> for LogLevel { #[cfg(test)] mod tests { use super::*; + use std::fs; + use std::path::PathBuf; + use tempfile::{tempdir, tempdir_in, NamedTempFile}; const SAMPLE: &str = r#" [app] @@ -550,6 +552,100 @@ env = "APP_TOKEN" assert!(cloudflare.variables.is_empty()); assert_eq!(cloudflare.secrets.len(), 1); assert_eq!(cloudflare.secrets[0].env, "APP_TOKEN"); + + let env = manifest.environment(); + assert_eq!(env.variables.len(), 1); + } + + #[test] + fn manifest_from_path_sets_root_for_absolute_parent() { + let dir = tempdir().unwrap(); + let path = dir.path().join("edgezero.toml"); + fs::write(&path, "").unwrap(); + + let loader = ManifestLoader::from_path(&path).expect("manifest"); + assert_eq!(loader.manifest().root(), Some(dir.path())); + } + + #[test] + fn manifest_from_path_handles_relative_parent() { + let cwd = std::env::current_dir().unwrap(); + let dir = tempdir_in(&cwd).unwrap(); + let path = dir.path().join("edgezero.toml"); + fs::write(&path, "").unwrap(); + + let relative = path.strip_prefix(&cwd).unwrap().to_path_buf(); + let loader = ManifestLoader::from_path(&relative).expect("manifest"); + let expected = cwd.join(relative.parent().unwrap()); + assert_eq!(loader.manifest().root(), Some(expected.as_path())); + } + + #[test] + fn manifest_from_path_uses_cwd_for_empty_parent() { + let cwd = std::env::current_dir().unwrap(); + let file = NamedTempFile::new_in(&cwd).unwrap(); + fs::write(file.path(), "").unwrap(); + let file_name = file.path().file_name().unwrap(); + let path = PathBuf::from(file_name); + + let loader = ManifestLoader::from_path(&path).expect("manifest"); + assert_eq!(loader.manifest().root(), Some(cwd.as_path())); + } + + #[test] + fn manifest_from_path_uses_cwd_when_parent_is_none() { + let cwd = std::env::current_dir().unwrap(); + let file_name = format!("edgezero-test-manifest-{}.toml", std::process::id()); + let path = cwd.join(&file_name); + fs::write(&path, "").unwrap(); + + let loader = ManifestLoader::from_path(&PathBuf::from(&file_name)).expect("manifest"); + assert_eq!(loader.manifest().root(), Some(cwd.as_path())); + + fs::remove_file(&path).unwrap(); + } + + #[test] + fn manifest_from_path_reports_missing_file() { + let dir = tempdir().unwrap(); + let path = dir.path().join("missing.toml"); + + let err = ManifestLoader::from_path(&path) + .err() + .expect("missing manifest"); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn manifest_from_path_reports_invalid_data() { + let dir = tempdir().unwrap(); + let path = dir.path().join("edgezero.toml"); + fs::write(&path, "[[triggers.http]]\npath = \"\"").unwrap(); + + let err = ManifestLoader::from_path(&path) + .err() + .expect("invalid manifest"); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + } + + #[test] + fn resolve_root_path_uses_cwd_when_parent_is_none() { + let dir = tempdir().unwrap(); + let cwd = dir.path(); + let root = resolve_root_path(Path::new(""), cwd); + assert_eq!(root, cwd); + } + + #[test] + fn manifest_from_path_reports_invalid_toml() { + let dir = tempdir().unwrap(); + let path = dir.path().join("edgezero.toml"); + fs::write(&path, "not = [").unwrap(); + + let err = ManifestLoader::from_path(&path) + .err() + .expect("invalid manifest"); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); } #[test] @@ -567,4 +663,450 @@ env = "APP_TOKEN" assert_eq!(log::LevelFilter::from(level), expected); } } + + // HttpMethod parsing tests + #[test] + fn http_method_parses_all_variants() { + let manifest = r#" +[[triggers.http]] +path = "/get" +methods = ["GET"] + +[[triggers.http]] +path = "/post" +methods = ["POST"] + +[[triggers.http]] +path = "/put" +methods = ["PUT"] + +[[triggers.http]] +path = "/delete" +methods = ["DELETE"] + +[[triggers.http]] +path = "/patch" +methods = ["PATCH"] + +[[triggers.http]] +path = "/options" +methods = ["OPTIONS"] + +[[triggers.http]] +path = "/head" +methods = ["HEAD"] +"#; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + assert_eq!(m.triggers.http.len(), 7); + assert_eq!(m.triggers.http[0].methods(), vec!["GET"]); + assert_eq!(m.triggers.http[1].methods(), vec!["POST"]); + assert_eq!(m.triggers.http[2].methods(), vec!["PUT"]); + assert_eq!(m.triggers.http[3].methods(), vec!["DELETE"]); + assert_eq!(m.triggers.http[4].methods(), vec!["PATCH"]); + assert_eq!(m.triggers.http[5].methods(), vec!["OPTIONS"]); + assert_eq!(m.triggers.http[6].methods(), vec!["HEAD"]); + } + + #[test] + fn http_method_rejects_invalid_value() { + let err = toml::from_str::( + "path = \"/\"\nmethods = [\"FETCH\"]", + ) + .expect_err("invalid method"); + assert!(err.to_string().contains("unsupported HTTP method")); + } + + #[test] + fn http_method_rejects_non_string_value() { + let err = + toml::from_str::("path = \"/\"\nmethods = [1]") + .expect_err("invalid method"); + assert!(err.to_string().contains("invalid type")); + } + + #[test] + fn http_method_is_case_insensitive() { + let manifest = r#" +[[triggers.http]] +path = "/test" +methods = ["get", "Post", "PUT"] +"#; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + assert_eq!(m.triggers.http[0].methods(), vec!["GET", "POST", "PUT"]); + } + + #[test] + fn http_trigger_defaults_to_get() { + let manifest = r#" +[[triggers.http]] +path = "/test" +"#; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + assert_eq!(m.triggers.http[0].methods(), vec!["GET"]); + } + + // BodyMode parsing tests + #[test] + fn body_mode_parses_buffered() { + let manifest = r#" +[[triggers.http]] +path = "/test" +body-mode = "buffered" +"#; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + assert_eq!(m.triggers.http[0].body_mode, Some(BodyMode::Buffered)); + } + + #[test] + fn body_mode_parses_stream() { + let manifest = r#" +[[triggers.http]] +path = "/test" +body-mode = "stream" +"#; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + assert_eq!(m.triggers.http[0].body_mode, Some(BodyMode::Stream)); + } + + #[test] + fn body_mode_rejects_invalid_value() { + let err = toml::from_str::( + "path = \"/\"\nbody-mode = \"chunked\"", + ) + .expect_err("invalid body mode"); + assert!(err.to_string().contains("unsupported body mode")); + } + + #[test] + fn body_mode_rejects_non_string_value() { + let err = + toml::from_str::("path = \"/\"\nbody-mode = 1") + .expect_err("invalid body mode"); + assert!(err.to_string().contains("invalid type")); + } + + // LogLevel parsing tests + #[test] + fn log_level_parses_all_variants() { + let manifest = r#" +[logging.adapter1] +level = "trace" + +[logging.adapter2] +level = "debug" + +[logging.adapter3] +level = "info" + +[logging.adapter4] +level = "warn" + +[logging.adapter5] +level = "error" + +[logging.adapter6] +level = "off" +"#; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + assert_eq!(m.logging_for("adapter1").unwrap().level, LogLevel::Trace); + assert_eq!(m.logging_for("adapter2").unwrap().level, LogLevel::Debug); + assert_eq!(m.logging_for("adapter3").unwrap().level, LogLevel::Info); + assert_eq!(m.logging_for("adapter4").unwrap().level, LogLevel::Warn); + assert_eq!(m.logging_for("adapter5").unwrap().level, LogLevel::Error); + assert_eq!(m.logging_for("adapter6").unwrap().level, LogLevel::Off); + } + + #[test] + fn log_level_rejects_invalid_value() { + let err = + toml::from_str::("level = \"loud\"") + .expect_err("invalid log level"); + assert!(err + .to_string() + .contains("logging level must be trace, debug, info, warn, error, or off")); + } + + #[test] + fn log_level_rejects_non_string_value() { + let err = + toml::from_str::("level = 123") + .expect_err("invalid log level"); + assert!(err.to_string().contains("invalid type")); + } + + #[test] + fn log_level_as_str() { + assert_eq!(LogLevel::Trace.as_str(), "trace"); + assert_eq!(LogLevel::Debug.as_str(), "debug"); + assert_eq!(LogLevel::Info.as_str(), "info"); + assert_eq!(LogLevel::Warn.as_str(), "warn"); + assert_eq!(LogLevel::Error.as_str(), "error"); + assert_eq!(LogLevel::Off.as_str(), "off"); + } + + #[test] + fn log_level_default_is_info() { + assert_eq!(LogLevel::default(), LogLevel::Info); + } + + // Logging configuration tests + #[test] + fn logging_or_default_returns_default_when_missing() { + let manifest = r#" +[app] +name = "test" +"#; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + let logging = m.logging_or_default("unknown"); + assert_eq!(logging.level, LogLevel::Info); + assert!(logging.endpoint.is_none()); + assert!(logging.echo_stdout.is_none()); + } + + #[test] + fn resolved_logging_config_applies_level() { + let cfg = ManifestLoggingConfig { + level: Some(LogLevel::Warn), + ..Default::default() + }; + let resolved = ResolvedLoggingConfig::from_manifest(&cfg); + assert_eq!(resolved.level, LogLevel::Warn); + } + + #[test] + fn logging_config_with_endpoint_and_echo() { + let manifest = r#" +[logging.axum] +level = "debug" +endpoint = "https://logs.example.com" +echo_stdout = true +"#; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + let logging = m.logging_for("axum").unwrap(); + assert_eq!(logging.level, LogLevel::Debug); + assert_eq!( + logging.endpoint.as_deref(), + Some("https://logs.example.com") + ); + assert_eq!(logging.echo_stdout, Some(true)); + } + + #[test] + fn adapter_logging_config_overrides_global() { + let manifest = r#" +[adapters.fastly.logging] +level = "error" +endpoint = "https://fastly-logs.example.com" +"#; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + let logging = m.logging_for("fastly").unwrap(); + assert_eq!(logging.level, LogLevel::Error); + assert_eq!( + logging.endpoint.as_deref(), + Some("https://fastly-logs.example.com") + ); + } + + // Environment binding tests + #[test] + fn environment_binding_uses_env_key_when_specified() { + let manifest = r#" +[[environment.variables]] +name = "MY_VAR" +env = "ACTUAL_ENV_KEY" +value = "some-value" +"#; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + let env = m.environment_for("any-adapter"); + assert_eq!(env.variables[0].name, "MY_VAR"); + assert_eq!(env.variables[0].env, "ACTUAL_ENV_KEY"); + assert_eq!(env.variables[0].value.as_deref(), Some("some-value")); + } + + #[test] + fn environment_binding_defaults_env_to_name() { + let manifest = r#" +[[environment.variables]] +name = "API_KEY" +value = "secret" +"#; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + let env = m.environment_for("any-adapter"); + assert_eq!(env.variables[0].name, "API_KEY"); + assert_eq!(env.variables[0].env, "API_KEY"); + } + + #[test] + fn environment_filters_by_adapter_case_insensitive() { + let manifest = r#" +[[environment.variables]] +name = "VAR1" +value = "v1" +adapters = ["Fastly"] + +[[environment.variables]] +name = "VAR2" +value = "v2" +adapters = ["cloudflare"] + +[[environment.variables]] +name = "VAR3" +value = "v3" +"#; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + + let fastly_env = m.environment_for("FASTLY"); + assert_eq!(fastly_env.variables.len(), 2); // VAR1 and VAR3 + assert!(fastly_env.variables.iter().any(|v| v.name == "VAR1")); + assert!(fastly_env.variables.iter().any(|v| v.name == "VAR3")); + + let cf_env = m.environment_for("Cloudflare"); + assert_eq!(cf_env.variables.len(), 2); // VAR2 and VAR3 + assert!(cf_env.variables.iter().any(|v| v.name == "VAR2")); + assert!(cf_env.variables.iter().any(|v| v.name == "VAR3")); + } + + #[test] + fn environment_binding_with_description() { + let manifest = r#" +[[environment.secrets]] +name = "DB_PASSWORD" +description = "Database password for production" +"#; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + let env = m.environment_for("any"); + assert_eq!( + env.secrets[0].description.as_deref(), + Some("Database password for production") + ); + } + + // Adapter configuration tests + #[test] + fn adapter_build_config() { + let manifest = r#" +[adapters.fastly.build] +target = "wasm32-wasip1" +profile = "release" +features = ["feature1", "feature2"] +"#; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + let adapter = m.adapters.get("fastly").unwrap(); + assert_eq!(adapter.build.target.as_deref(), Some("wasm32-wasip1")); + assert_eq!(adapter.build.profile.as_deref(), Some("release")); + assert_eq!(adapter.build.features, vec!["feature1", "feature2"]); + } + + #[test] + fn adapter_commands_config() { + let manifest = r#" +[adapters.fastly.commands] +build = "fastly compute build" +serve = "fastly compute serve" +deploy = "fastly compute publish" +"#; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + let adapter = m.adapters.get("fastly").unwrap(); + assert_eq!( + adapter.commands.build.as_deref(), + Some("fastly compute build") + ); + assert_eq!( + adapter.commands.serve.as_deref(), + Some("fastly compute serve") + ); + assert_eq!( + adapter.commands.deploy.as_deref(), + Some("fastly compute publish") + ); + } + + #[test] + fn adapter_definition_config() { + let manifest = r#" +[adapters.fastly.adapter] +crate = "crates/fastly-adapter" +manifest = "fastly.toml" +"#; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + let adapter = m.adapters.get("fastly").unwrap(); + assert_eq!( + adapter.adapter.crate_path.as_deref(), + Some("crates/fastly-adapter") + ); + assert_eq!(adapter.adapter.manifest.as_deref(), Some("fastly.toml")); + } + + // Empty/minimal manifest tests + #[test] + fn empty_manifest_has_defaults() { + let manifest = ""; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + assert!(m.app.name.is_none()); + assert!(m.app.entry.is_none()); + assert!(m.triggers.http.is_empty()); + assert!(m.adapters.is_empty()); + } + + #[test] + fn manifest_root_is_none_when_loaded_from_str() { + let loader = ManifestLoader::load_from_str(SAMPLE); + assert!(loader.manifest().root().is_none()); + } + + // HttpMethod as_str tests + #[test] + fn http_method_as_str_returns_uppercase() { + assert_eq!(HttpMethod::Get.as_str(), "GET"); + assert_eq!(HttpMethod::Post.as_str(), "POST"); + assert_eq!(HttpMethod::Put.as_str(), "PUT"); + assert_eq!(HttpMethod::Delete.as_str(), "DELETE"); + assert_eq!(HttpMethod::Patch.as_str(), "PATCH"); + assert_eq!(HttpMethod::Options.as_str(), "OPTIONS"); + assert_eq!(HttpMethod::Head.as_str(), "HEAD"); + } + + // Multiple triggers test + #[test] + fn triggers_with_all_fields() { + let manifest = r#" +[[triggers.http]] +id = "route-1" +path = "/api/users" +methods = ["GET", "POST"] +handler = "handlers::users" +adapters = ["axum", "fastly"] +description = "User management endpoint" +body-mode = "buffered" +"#; + let loader = ManifestLoader::load_from_str(manifest); + let trigger = &loader.manifest().triggers.http[0]; + assert_eq!(trigger.id.as_deref(), Some("route-1")); + assert_eq!(trigger.path, "/api/users"); + assert_eq!(trigger.methods(), vec!["GET", "POST"]); + assert_eq!(trigger.handler.as_deref(), Some("handlers::users")); + assert_eq!(trigger.adapters, vec!["axum", "fastly"]); + assert_eq!( + trigger.description.as_deref(), + Some("User management endpoint") + ); + assert_eq!(trigger.body_mode, Some(BodyMode::Buffered)); + } } diff --git a/crates/edgezero-core/src/middleware.rs b/crates/edgezero-core/src/middleware.rs index 6222d72..f22104b 100644 --- a/crates/edgezero-core/src/middleware.rs +++ b/crates/edgezero-core/src/middleware.rs @@ -162,6 +162,10 @@ mod tests { RequestContext::new(request, PathParams::default()) } + async fn ok_handler(_ctx: RequestContext) -> Result { + Ok(response_with_body(StatusCode::OK, Body::empty())) + } + #[test] fn middleware_chain_runs_in_order() { let log: Arc>> = Arc::new(Mutex::new(Vec::new())); @@ -195,14 +199,64 @@ mod tests { #[test] fn middleware_can_short_circuit() { + let handler = ok_handler.into_handler(); + + let middlewares: Vec = vec![Arc::new(ShortCircuit) as BoxMiddleware]; + let response = block_on(Next::new(&middlewares, handler.as_ref()).run(empty_context())) + .expect("response"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn next_runs_handler_without_middlewares() { + let handler = ok_handler.into_handler(); + let response = block_on(Next::new(&[], handler.as_ref()).run(empty_context())) + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + } + + #[test] + fn request_logger_passes_through_success() { + let handler = ok_handler.into_handler(); + let response = block_on(RequestLogger.handle( + empty_context(), + Next::new(&[], handler.as_ref()), + )) + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + } + + #[test] + fn request_logger_propagates_error() { let handler = (|_ctx: RequestContext| async move { - Ok::(response_with_body(StatusCode::OK, Body::empty())) + Err::(EdgeError::bad_request("boom")) }) .into_handler(); + let err = block_on(RequestLogger.handle( + empty_context(), + Next::new(&[], handler.as_ref()), + )) + .expect_err("error"); + assert_eq!(err.status(), StatusCode::BAD_REQUEST); + } - let middlewares: Vec = vec![Arc::new(ShortCircuit) as BoxMiddleware]; + #[test] + fn middleware_fn_executes_closure() { + let called = Arc::new(Mutex::new(false)); + let flag = Arc::clone(&called); + let middleware = middleware_fn(move |_ctx, _next| { + let flag = Arc::clone(&flag); + async move { + *flag.lock().unwrap() = true; + Ok(response_with_body(StatusCode::OK, Body::empty())) + } + }); + + let handler = ok_handler.into_handler(); + let middlewares: Vec = vec![Arc::new(middleware) as BoxMiddleware]; let response = block_on(Next::new(&middlewares, handler.as_ref()).run(empty_context())) .expect("response"); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.status(), StatusCode::OK); + assert!(*called.lock().unwrap()); } } diff --git a/crates/edgezero-core/src/proxy.rs b/crates/edgezero-core/src/proxy.rs index bcceed4..a3ff069 100644 --- a/crates/edgezero-core/src/proxy.rs +++ b/crates/edgezero-core/src/proxy.rs @@ -303,4 +303,319 @@ mod tests { }), } } + + // ProxyRequest tests + #[test] + fn proxy_request_new_creates_empty_request() { + let req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); + assert_eq!(req.method(), &Method::GET); + assert_eq!(req.uri(), &Uri::from_static("https://example.com")); + assert!(req.headers().is_empty()); + assert!(matches!(req.body(), Body::Once(b) if b.is_empty())); + } + + #[test] + fn proxy_request_from_request_preserves_all_parts() { + let request = request_builder() + .method(Method::POST) + .uri("/original") + .header("x-custom", "value") + .body(Body::from("request body")) + .expect("request"); + + let target = Uri::from_static("https://backend.example.com/api"); + let proxy_req = ProxyRequest::from_request(request, target.clone()); + + assert_eq!(proxy_req.method(), &Method::POST); + assert_eq!(proxy_req.uri(), &target); + assert_eq!( + proxy_req + .headers() + .get("x-custom") + .and_then(|v| v.to_str().ok()), + Some("value") + ); + } + + #[test] + fn proxy_request_headers_mut_allows_modification() { + let mut req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); + req.headers_mut() + .insert("authorization", HeaderValue::from_static("Bearer token")); + assert!(req.headers().get("authorization").is_some()); + } + + #[test] + fn proxy_request_body_mut_allows_modification() { + let mut req = ProxyRequest::new(Method::POST, Uri::from_static("https://example.com")); + *req.body_mut() = Body::from("new body content"); + assert!(matches!( + req.body(), + Body::Once(bytes) if bytes.as_ref() == b"new body content" + )); + } + + #[test] + fn proxy_request_extensions_mut_allows_modification() { + let mut req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); + req.extensions_mut().insert("custom-data".to_string()); + assert_eq!( + req.extensions().get::(), + Some(&"custom-data".to_string()) + ); + } + + #[test] + fn proxy_request_into_parts_destructures() { + let mut req = ProxyRequest::new( + Method::DELETE, + Uri::from_static("https://example.com/resource"), + ); + req.headers_mut() + .insert("x-test", HeaderValue::from_static("value")); + *req.body_mut() = Body::from("body"); + + let (method, uri, headers, body, _extensions) = req.into_parts(); + assert_eq!(method, Method::DELETE); + assert_eq!(uri, Uri::from_static("https://example.com/resource")); + assert!(headers.get("x-test").is_some()); + assert!(matches!( + body, + Body::Once(ref bytes) if bytes.as_ref() == b"body" + )); + } + + #[test] + fn proxy_request_debug_format() { + let mut req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); + req.headers_mut() + .insert("x-debug", HeaderValue::from_static("test")); + let debug = format!("{:?}", req); + assert!(debug.contains("ProxyRequest")); + assert!(debug.contains("GET")); + assert!(debug.contains("example.com")); + } + + // ProxyResponse tests + #[test] + fn proxy_response_new_creates_response() { + let resp = ProxyResponse::new(StatusCode::OK, Body::from("response body")); + assert_eq!(resp.status(), StatusCode::OK); + assert!(matches!( + resp.body(), + Body::Once(bytes) if bytes.as_ref() == b"response body" + )); + } + + #[test] + fn proxy_response_headers_mut_allows_modification() { + let mut resp = ProxyResponse::new(StatusCode::OK, Body::empty()); + resp.headers_mut() + .insert("content-type", HeaderValue::from_static("application/json")); + assert!(resp.headers().get("content-type").is_some()); + } + + #[test] + fn proxy_response_body_mut_allows_modification() { + let mut resp = ProxyResponse::new(StatusCode::OK, Body::empty()); + *resp.body_mut() = Body::from("updated body"); + assert!(matches!( + resp.body(), + Body::Once(bytes) if bytes.as_ref() == b"updated body" + )); + } + + #[test] + fn proxy_response_extensions_mut_allows_modification() { + let mut resp = ProxyResponse::new(StatusCode::OK, Body::empty()); + resp.extensions_mut().insert(42i32); + assert_eq!(resp.extensions().get::(), Some(&42)); + } + + #[test] + fn proxy_response_into_response_converts() { + let mut resp = ProxyResponse::new(StatusCode::CREATED, Body::from("created")); + resp.headers_mut() + .insert("x-custom", HeaderValue::from_static("header")); + + let http_resp = resp.into_response(); + assert_eq!(http_resp.status(), StatusCode::CREATED); + assert!(http_resp.headers().get("x-custom").is_some()); + } + + #[test] + fn proxy_response_debug_format() { + let resp = ProxyResponse::new(StatusCode::NOT_FOUND, Body::empty()); + let debug = format!("{:?}", resp); + assert!(debug.contains("ProxyResponse")); + assert!(debug.contains("404")); + } + + // ProxyHandle tests + #[test] + fn proxy_handle_new_wraps_client() { + let client = Arc::new(TestClient); + let handle = ProxyHandle::new(client); + assert!(Arc::strong_count(&handle.client()) >= 1); + } + + #[test] + fn proxy_handle_with_client_creates_arc() { + let handle = ProxyHandle::with_client(TestClient); + assert!(Arc::strong_count(&handle.client()) >= 1); + } + + #[test] + fn proxy_handle_forward_returns_response() { + let handle = ProxyHandle::with_client(TestClient); + let request = request_builder() + .method(Method::GET) + .uri("/test") + .header("x-demo", "true") + .body(Body::empty()) + .expect("request"); + + let proxy_req = + ProxyRequest::from_request(request, Uri::from_static("https://example.com")); + let response = block_on(handle.forward(proxy_req)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + } + + // ProxyClient error handling + struct ErrorClient; + + #[async_trait(?Send)] + impl ProxyClient for ErrorClient { + async fn send(&self, _request: ProxyRequest) -> Result { + Err(EdgeError::bad_request("connection failed")) + } + } + + #[test] + fn proxy_service_propagates_client_errors() { + let service = ProxyService::new(ErrorClient); + let req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); + let result = block_on(service.forward(req)); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn proxy_handle_propagates_client_errors() { + let handle = ProxyHandle::with_client(ErrorClient); + let req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); + let result = block_on(handle.forward(req)); + assert!(result.is_err()); + } + + // Test various HTTP methods + struct EchoMethodClient; + + #[async_trait(?Send)] + impl ProxyClient for EchoMethodClient { + async fn send(&self, request: ProxyRequest) -> Result { + let method_str = request.method().as_str(); + Ok(ProxyResponse::new( + StatusCode::OK, + Body::from(method_str.to_string()), + )) + } + } + + #[test] + fn proxy_forwards_various_methods() { + let service = ProxyService::new(EchoMethodClient); + + for method in [ + Method::GET, + Method::POST, + Method::PUT, + Method::DELETE, + Method::PATCH, + Method::HEAD, + Method::OPTIONS, + ] { + let req = ProxyRequest::new(method.clone(), Uri::from_static("https://example.com")); + let response = block_on(service.forward(req)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + } + } + + // Test body forwarding + struct EchoBodyClient; + + #[async_trait(?Send)] + impl ProxyClient for EchoBodyClient { + async fn send(&self, request: ProxyRequest) -> Result { + let (_, _, _, body, _) = request.into_parts(); + Ok(ProxyResponse::new(StatusCode::OK, body)) + } + } + + #[test] + fn proxy_forwards_request_body() { + let service = ProxyService::new(EchoBodyClient); + let request = request_builder() + .method(Method::POST) + .uri("/test") + .body(Body::from("request body content")) + .expect("request"); + + let proxy_req = + ProxyRequest::from_request(request, Uri::from_static("https://example.com")); + let response = block_on(service.forward(proxy_req)).expect("response"); + + let body_bytes = collect_body(response.into_body()); + assert_eq!(body_bytes, b"request body content"); + } + + // Test header forwarding + struct EchoHeadersClient; + + #[async_trait(?Send)] + impl ProxyClient for EchoHeadersClient { + async fn send(&self, request: ProxyRequest) -> Result { + let mut resp = ProxyResponse::new(StatusCode::OK, Body::empty()); + // Echo back headers with x-echo- prefix + for (name, value) in request.headers().iter() { + let echo_name = format!("x-echo-{}", name.as_str()); + if let Ok(header_name) = echo_name.parse::() { + resp.headers_mut().insert(header_name, value.clone()); + } + } + Ok(resp) + } + } + + #[test] + fn proxy_forwards_request_headers() { + let service = ProxyService::new(EchoHeadersClient); + let request = request_builder() + .method(Method::GET) + .uri("/test") + .header("x-custom-header", "custom-value") + .header("authorization", "Bearer token123") + .body(Body::empty()) + .expect("request"); + + let proxy_req = + ProxyRequest::from_request(request, Uri::from_static("https://example.com")); + let response = block_on(service.forward(proxy_req)).expect("response"); + + assert_eq!( + response + .headers() + .get("x-echo-x-custom-header") + .and_then(|v| v.to_str().ok()), + Some("custom-value") + ); + assert_eq!( + response + .headers() + .get("x-echo-authorization") + .and_then(|v| v.to_str().ok()), + Some("Bearer token123") + ); + } } diff --git a/crates/edgezero-core/src/response.rs b/crates/edgezero-core/src/response.rs index f0ffe3d..1c1e94c 100644 --- a/crates/edgezero-core/src/response.rs +++ b/crates/edgezero-core/src/response.rs @@ -119,4 +119,25 @@ mod tests { let response = response_with_body(StatusCode::OK, Body::empty()); assert!(response.headers().get(CONTENT_LENGTH).is_none()); } + + #[test] + fn text_wrapper_builds_response() { + let response = Text::new("hello").into_response(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().as_bytes(), b"hello"); + } + + #[test] + fn unit_type_sets_no_content() { + let response = ().into_response(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert!(response.body().as_bytes().is_empty()); + } + + #[test] + fn status_code_tuple_overrides_status() { + let response = (StatusCode::CREATED, "created").into_response(); + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(response.body().as_bytes(), b"created"); + } } diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index 25b0b38..381d9b8 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -11,7 +11,7 @@ use crate::error::EdgeError; use crate::handler::{BoxHandler, IntoHandler}; use crate::http::{ header::CONTENT_TYPE, response_builder, HandlerFuture, HeaderValue, Method, Request, Response, - StatusCode, + ResponseBuilder, StatusCode, }; use crate::middleware::{BoxMiddleware, Middleware, Next}; use crate::params::PathParams; @@ -48,6 +48,19 @@ struct RouteListingEntry { path: String, } +fn build_listing_response( + payload: &T, + builder: ResponseBuilder, +) -> Result { + let body = Body::json(payload).map_err(EdgeError::internal)?; + let response = builder + .status(StatusCode::OK) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .body(body) + .map_err(EdgeError::internal)?; + Ok(response) +} + #[derive(Default)] pub struct RouterBuilder { routes: HashMap>, @@ -151,13 +164,7 @@ impl RouterBuilder { }) .collect(); - let body = Body::json(&payload).map_err(EdgeError::internal)?; - let response = response_builder() - .status(StatusCode::OK) - .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) - .body(body) - .map_err(EdgeError::internal)?; - Ok(response) + build_listing_response(&payload, response_builder()) } }; @@ -329,10 +336,19 @@ mod tests { use crate::body::Body; use crate::context::RequestContext; use crate::error::EdgeError; - use crate::http::{request_builder, Method, StatusCode}; + use crate::http::{request_builder, Method, Request, Response, StatusCode}; + use crate::response::response_with_body; + use crate::params::PathParams; use futures::executor::block_on; - use serde::Deserialize; + use futures::task::noop_waker_ref; + use serde::{Deserialize, Serialize}; use serde_json::json; + use std::sync::{Arc, Mutex}; + use std::task::{Context, Poll}; + + async fn ok_handler(_ctx: RequestContext) -> Result { + Ok(response_with_body(StatusCode::OK, Body::empty())) + } #[test] fn route_matches_path_params() { @@ -400,15 +416,67 @@ mod tests { assert!(routes .iter() .any(|route| route.path() == "/health" && *route.method() == Method::GET)); + + let health_request = request_builder() + .method(Method::GET) + .uri("/health") + .body(Body::empty()) + .expect("request"); + let health_response = block_on(service.clone().call(health_request)).expect("response"); + assert_eq!(health_response.status(), StatusCode::NO_CONTENT); + + let items_request = request_builder() + .method(Method::POST) + .uri("/items") + .body(Body::empty()) + .expect("request"); + let items_response = block_on(service.clone().call(items_request)).expect("response"); + assert_eq!(items_response.status(), StatusCode::NO_CONTENT); } #[test] - fn returns_method_not_allowed() { - async fn handler(_ctx: RequestContext) -> Result<(), EdgeError> { - Ok(()) + fn route_listing_response_handles_json_failure() { + struct FailingSerialize; + + impl Serialize for FailingSerialize { + fn serialize(&self, _serializer: S) -> Result + where + S: serde::Serializer, + { + Err(serde::ser::Error::custom("boom")) + } + } + + let err = build_listing_response(&FailingSerialize, response_builder()) + .expect_err("expected error"); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn route_listing_response_handles_builder_failure() { + #[derive(Serialize)] + struct Payload { + ok: bool, } - let service = RouterService::builder().post("/submit", handler).build(); + let builder = response_builder().header("bad\nname", "value"); + let err = build_listing_response(&Payload { ok: true }, builder) + .expect_err("expected error"); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + #[should_panic(expected = "duplicate route definition")] + fn route_listing_duplicate_path_panics() { + RouterService::builder() + .enable_route_listing() + .get(DEFAULT_ROUTE_LISTING_PATH, ok_handler) + .build(); + } + + #[test] + fn returns_method_not_allowed() { + let service = RouterService::builder().post("/submit", ok_handler).build(); let request = request_builder() .method(Method::GET) @@ -420,9 +488,26 @@ mod tests { assert_eq!(error.status(), StatusCode::METHOD_NOT_ALLOWED); } + #[test] + fn returns_method_not_allowed_with_multiple_methods() { + let service = RouterService::builder() + .get("/submit", ok_handler) + .post("/submit", ok_handler) + .build(); + + let request = request_builder() + .method(Method::PUT) + .uri("/submit") + .body(Body::empty()) + .expect("request"); + + let error = block_on(service.clone().call(request)).expect_err("error"); + assert_eq!(error.status(), StatusCode::METHOD_NOT_ALLOWED); + } + #[test] fn returns_not_found() { - let service = RouterService::builder().build(); + let service = RouterService::builder().get("/known", ok_handler).build(); let request = request_builder() .method(Method::GET) .uri("/missing") @@ -433,6 +518,42 @@ mod tests { assert_eq!(error.status(), StatusCode::NOT_FOUND); } + #[test] + fn handler_returns_bad_request_for_invalid_path_params() { + #[derive(Deserialize)] + struct Params { + id: String, + } + + async fn handler(ctx: RequestContext) -> Result { + let params: Params = ctx.path()?; + let id = params + .id + .parse::() + .map_err(|_| EdgeError::bad_request("invalid id"))?; + Ok(format!("hello {}", id)) + } + + let service = RouterService::builder().get("/items/{id}", handler).build(); + let ok_request = request_builder() + .method(Method::GET) + .uri("/items/42") + .body(Body::empty()) + .expect("request"); + let ok_response = block_on(service.clone().call(ok_request)).expect("response"); + assert_eq!(ok_response.status(), StatusCode::OK); + assert_eq!(ok_response.body().as_bytes(), b"hello 42"); + + let request = request_builder() + .method(Method::GET) + .uri("/items/abc") + .body(Body::empty()) + .expect("request"); + + let error = block_on(service.clone().call(request)).expect_err("error"); + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + } + #[test] fn streams_body_through_router() { use bytes::Bytes; @@ -468,4 +589,148 @@ mod tests { }); assert_eq!(collected, b"chunk-one\nchunk-two\n"); } + + #[test] + #[should_panic(expected = "route listing path cannot be empty")] + fn route_listing_rejects_empty_path() { + let _ = RouterService::builder().enable_route_listing_at(""); + } + + #[test] + #[should_panic(expected = "route listing path must begin with '/'")] + fn route_listing_rejects_missing_slash() { + let _ = RouterService::builder().enable_route_listing_at("routes"); + } + + #[test] + fn builder_supports_put_and_delete_routes() { + let service = RouterService::builder() + .put("/items", ok_handler) + .delete("/items", ok_handler) + .build(); + + let put_request = request_builder() + .method(Method::PUT) + .uri("/items") + .body(Body::empty()) + .expect("request"); + let put_response = block_on(service.clone().call(put_request)).expect("response"); + assert_eq!(put_response.status(), StatusCode::OK); + + let delete_request = request_builder() + .method(Method::DELETE) + .uri("/items") + .body(Body::empty()) + .expect("request"); + let delete_response = block_on(service.clone().call(delete_request)).expect("response"); + assert_eq!(delete_response.status(), StatusCode::OK); + } + + #[test] + #[should_panic(expected = "duplicate route definition")] + fn duplicate_route_definition_panics() { + RouterService::builder() + .get("/dup", ok_handler) + .get("/dup", ok_handler) + .build(); + } + + #[test] + fn builder_accepts_middleware_and_middleware_arc() { + struct RecordingMiddleware { + log: Arc>>, + name: &'static str, + } + + #[async_trait::async_trait(?Send)] + impl Middleware for RecordingMiddleware { + async fn handle( + &self, + ctx: RequestContext, + next: Next<'_>, + ) -> Result { + self.log.lock().unwrap().push(self.name); + next.run(ctx).await + } + } + + let log = Arc::new(Mutex::new(Vec::new())); + let first = RecordingMiddleware { + log: Arc::clone(&log), + name: "first", + }; + let second = RecordingMiddleware { + log: Arc::clone(&log), + name: "second", + }; + + let service = RouterService::builder() + .middleware(first) + .middleware_arc(Arc::new(second) as BoxMiddleware) + .get("/test", ok_handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/test") + .body(Body::empty()) + .expect("request"); + let response = block_on(service.clone().call(request)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + + let entries = log.lock().unwrap().clone(); + assert_eq!(entries, vec!["first", "second"]); + } + + #[test] + fn oneshot_returns_success_response() { + let service = RouterService::builder().get("/ok", ok_handler).build(); + let request = request_builder() + .method(Method::GET) + .uri("/ok") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)); + assert_eq!(response.status(), StatusCode::OK); + } + + #[test] + fn oneshot_returns_error_response() { + let service = RouterService::builder().build(); + let request = request_builder() + .method(Method::GET) + .uri("/missing") + .body(Body::empty()) + .expect("request"); + + let response = block_on(service.oneshot(request)); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn service_poll_ready_reports_ready() { + let mut service = RouterService::builder().build(); + let waker = noop_waker_ref(); + let mut cx = Context::from_waker(waker); + let ready = Service::::poll_ready(&mut service, &mut cx); + assert!(matches!(ready, Poll::Ready(Ok(())))); + } + + #[test] + fn route_entry_clone_copies_handler() { + let entry = RouteEntry { + handler: ok_handler.into_handler(), + }; + let cloned = entry.clone(); + + let request = request_builder() + .method(Method::GET) + .uri("/test") + .body(Body::empty()) + .expect("request"); + let ctx = RequestContext::new(request, PathParams::default()); + let response = block_on(cloned.handler.call(ctx)).expect("response"); + assert_eq!(response.status(), StatusCode::OK); + } } diff --git a/crates/edgezero-macros/Cargo.toml b/crates/edgezero-macros/Cargo.toml index 4eb6527..d050dc3 100644 --- a/crates/edgezero-macros/Cargo.toml +++ b/crates/edgezero-macros/Cargo.toml @@ -16,3 +16,6 @@ serde = { workspace = true, features = ["derive"] } syn = { version = "2", features = ["full"] } toml = { workspace = true } validator = { workspace = true, features = ["derive"] } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/scripts/run_coverage.sh b/scripts/run_coverage.sh new file mode 100755 index 0000000..ec9fee8 --- /dev/null +++ b/scripts/run_coverage.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +command -v cargo >/dev/null 2>&1 || { + echo "cargo is required but was not found in PATH" >&2 + exit 1 +} + +if ! cargo llvm-cov --version >/dev/null 2>&1; then + echo "cargo-llvm-cov is required. Install with 'cargo install cargo-llvm-cov' and 'rustup component add llvm-tools-preview'." >&2 + exit 1 +fi + +OUTPUT_DIR="target/coverage" +mkdir -p "$OUTPUT_DIR" + +discover_packages() { + local python_bin + python_bin="$(command -v python3 || command -v python || true)" + if [ -z "${python_bin}" ]; then + echo "python3 is required to auto-discover workspace packages. Set EDGEZERO_COVERAGE_PACKAGES to skip discovery." >&2 + exit 1 + fi + + EDGEZERO_COVERAGE_INCLUDE_BINS="${EDGEZERO_COVERAGE_INCLUDE_BINS:-0}" \ + cargo metadata --format-version 1 --no-deps | "${python_bin}" -c ' +import json +import os +import sys + +data = json.load(sys.stdin) +workspace = set(data.get("workspace_members", [])) +include_bins = os.environ.get("EDGEZERO_COVERAGE_INCLUDE_BINS", "0") not in ("0", "", "false", "False") + +packages = [] +for pkg in data.get("packages", []): + if pkg.get("id") not in workspace: + continue + if include_bins: + packages.append(pkg.get("name")) + continue + targets = pkg.get("targets", []) + has_lib = any( + "lib" in target.get("kind", []) or "proc-macro" in target.get("kind", []) + for target in targets + ) + if has_lib: + packages.append(pkg.get("name")) + +print(" ".join(packages)) +' +} + +if [ -n "${EDGEZERO_COVERAGE_PACKAGES:-}" ]; then + PACKAGES="${EDGEZERO_COVERAGE_PACKAGES}" +else + PACKAGES="$(discover_packages)" + echo "==> Auto-discovered packages: ${PACKAGES}" +fi + +if [ -z "${PACKAGES}" ]; then + echo "No packages selected for coverage. Set EDGEZERO_COVERAGE_PACKAGES to override." >&2 + exit 1 +fi + +for pkg in $PACKAGES; do + echo "==> Coverage for ${pkg}" + cargo llvm-cov -p "${pkg}" --lcov --output-path "${OUTPUT_DIR}/${pkg}.lcov" + + if command -v genhtml >/dev/null 2>&1; then + html_dir="${OUTPUT_DIR}/${pkg}-html" + echo "==> HTML report for ${pkg}" + genhtml "${OUTPUT_DIR}/${pkg}.lcov" -o "${html_dir}" >/dev/null + fi +done + +echo "Coverage reports saved under ${OUTPUT_DIR}/" +if command -v genhtml >/dev/null 2>&1; then + echo "HTML reports saved under ${OUTPUT_DIR}/-html/" +else + echo "Install 'genhtml' (lcov) to generate HTML reports." +fi From 14ffb8474cf5d67e4bc78a9d8a4c1e7e6053ce07 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 20 Jan 2026 12:15:54 -0800 Subject: [PATCH 2/4] Fixed formatting --- .../edgezero-adapter-axum/src/dev_server.rs | 5 +--- crates/edgezero-core/src/body.rs | 4 ++- crates/edgezero-core/src/context.rs | 16 ++++++---- crates/edgezero-core/src/manifest.rs | 30 +++++++------------ crates/edgezero-core/src/middleware.rs | 19 +++++------- crates/edgezero-core/src/router.rs | 6 ++-- 6 files changed, 35 insertions(+), 45 deletions(-) diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 7170003..1d611f8 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -227,10 +227,7 @@ mod integration_tests { } } - async fn send_with_retry( - client: &reqwest::Client, - mut make_request: F, - ) -> reqwest::Response + async fn send_with_retry(client: &reqwest::Client, mut make_request: F) -> reqwest::Response where F: FnMut(&reqwest::Client) -> reqwest::RequestBuilder, { diff --git a/crates/edgezero-core/src/body.rs b/crates/edgezero-core/src/body.rs index 1600453..7288fd4 100644 --- a/crates/edgezero-core/src/body.rs +++ b/crates/edgezero-core/src/body.rs @@ -234,7 +234,9 @@ mod tests { let buffered_debug = format!("{:?}", buffered); assert!(buffered_debug.contains("Body::Once")); - let stream = Body::stream(futures_util::stream::iter(vec![Bytes::from_static(b"chunk")])); + let stream = Body::stream(futures_util::stream::iter(vec![Bytes::from_static( + b"chunk", + )])); let stream_debug = format!("{:?}", stream); assert!(stream_debug.contains("Body::Stream")); } diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index 69d2ab8..4038c33 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -237,10 +237,7 @@ mod tests { } let body = Body::from("age=not-a-number"); let ctx = ctx("/submit", body, PathParams::default()); - let err = ctx - .form::() - .err() - .expect("expected error"); + let err = ctx.form::().err().expect("expected error"); assert_eq!(err.status(), StatusCode::BAD_REQUEST); assert!(err.message().contains("invalid form payload")); } @@ -250,7 +247,10 @@ mod tests { let body = Body::from("name=demo"); let ctx = ctx("/submit", body, PathParams::default()); let parsed: serde_json::Value = ctx.form().expect("form data"); - assert_eq!(parsed.get("name").and_then(|value| value.as_str()), Some("demo")); + assert_eq!( + parsed.get("name").and_then(|value| value.as_str()), + Some("demo") + ); } #[test] @@ -291,7 +291,11 @@ mod tests { #[test] fn request_context_accessors_return_expected_values() { - let mut ctx = ctx("/items/123", Body::from("payload"), params(&[("id", "123")])); + let mut ctx = ctx( + "/items/123", + Body::from("payload"), + params(&[("id", "123")]), + ); assert_eq!(ctx.request().uri().path(), "/items/123"); ctx.request_mut() .headers_mut() diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 3fb7874..f3de364 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -710,18 +710,15 @@ methods = ["HEAD"] #[test] fn http_method_rejects_invalid_value() { - let err = toml::from_str::( - "path = \"/\"\nmethods = [\"FETCH\"]", - ) - .expect_err("invalid method"); + let err = toml::from_str::("path = \"/\"\nmethods = [\"FETCH\"]") + .expect_err("invalid method"); assert!(err.to_string().contains("unsupported HTTP method")); } #[test] fn http_method_rejects_non_string_value() { - let err = - toml::from_str::("path = \"/\"\nmethods = [1]") - .expect_err("invalid method"); + let err = toml::from_str::("path = \"/\"\nmethods = [1]") + .expect_err("invalid method"); assert!(err.to_string().contains("invalid type")); } @@ -775,18 +772,15 @@ body-mode = "stream" #[test] fn body_mode_rejects_invalid_value() { - let err = toml::from_str::( - "path = \"/\"\nbody-mode = \"chunked\"", - ) - .expect_err("invalid body mode"); + let err = toml::from_str::("path = \"/\"\nbody-mode = \"chunked\"") + .expect_err("invalid body mode"); assert!(err.to_string().contains("unsupported body mode")); } #[test] fn body_mode_rejects_non_string_value() { - let err = - toml::from_str::("path = \"/\"\nbody-mode = 1") - .expect_err("invalid body mode"); + let err = toml::from_str::("path = \"/\"\nbody-mode = 1") + .expect_err("invalid body mode"); assert!(err.to_string().contains("invalid type")); } @@ -824,9 +818,8 @@ level = "off" #[test] fn log_level_rejects_invalid_value() { - let err = - toml::from_str::("level = \"loud\"") - .expect_err("invalid log level"); + let err = toml::from_str::("level = \"loud\"") + .expect_err("invalid log level"); assert!(err .to_string() .contains("logging level must be trace, debug, info, warn, error, or off")); @@ -835,8 +828,7 @@ level = "off" #[test] fn log_level_rejects_non_string_value() { let err = - toml::from_str::("level = 123") - .expect_err("invalid log level"); + toml::from_str::("level = 123").expect_err("invalid log level"); assert!(err.to_string().contains("invalid type")); } diff --git a/crates/edgezero-core/src/middleware.rs b/crates/edgezero-core/src/middleware.rs index f22104b..146ff92 100644 --- a/crates/edgezero-core/src/middleware.rs +++ b/crates/edgezero-core/src/middleware.rs @@ -210,19 +210,17 @@ mod tests { #[test] fn next_runs_handler_without_middlewares() { let handler = ok_handler.into_handler(); - let response = block_on(Next::new(&[], handler.as_ref()).run(empty_context())) - .expect("response"); + let response = + block_on(Next::new(&[], handler.as_ref()).run(empty_context())).expect("response"); assert_eq!(response.status(), StatusCode::OK); } #[test] fn request_logger_passes_through_success() { let handler = ok_handler.into_handler(); - let response = block_on(RequestLogger.handle( - empty_context(), - Next::new(&[], handler.as_ref()), - )) - .expect("response"); + let response = + block_on(RequestLogger.handle(empty_context(), Next::new(&[], handler.as_ref()))) + .expect("response"); assert_eq!(response.status(), StatusCode::OK); } @@ -232,11 +230,8 @@ mod tests { Err::(EdgeError::bad_request("boom")) }) .into_handler(); - let err = block_on(RequestLogger.handle( - empty_context(), - Next::new(&[], handler.as_ref()), - )) - .expect_err("error"); + let err = block_on(RequestLogger.handle(empty_context(), Next::new(&[], handler.as_ref()))) + .expect_err("error"); assert_eq!(err.status(), StatusCode::BAD_REQUEST); } diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index 381d9b8..e524fa8 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -337,8 +337,8 @@ mod tests { use crate::context::RequestContext; use crate::error::EdgeError; use crate::http::{request_builder, Method, Request, Response, StatusCode}; - use crate::response::response_with_body; use crate::params::PathParams; + use crate::response::response_with_body; use futures::executor::block_on; use futures::task::noop_waker_ref; use serde::{Deserialize, Serialize}; @@ -460,8 +460,8 @@ mod tests { } let builder = response_builder().header("bad\nname", "value"); - let err = build_listing_response(&Payload { ok: true }, builder) - .expect_err("expected error"); + let err = + build_listing_response(&Payload { ok: true }, builder).expect_err("expected error"); assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); } From 3347482e1dac8fb0ab0488c00153f3d27fc6941a Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 20 Jan 2026 12:28:00 -0800 Subject: [PATCH 3/4] Fixed clippy warnings --- crates/edgezero-core/src/body.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/edgezero-core/src/body.rs b/crates/edgezero-core/src/body.rs index 7288fd4..69af028 100644 --- a/crates/edgezero-core/src/body.rs +++ b/crates/edgezero-core/src/body.rs @@ -169,7 +169,7 @@ mod tests { fn from_stream_maps_errors() { let stream = futures_util::stream::iter(vec![ Ok(Bytes::from_static(b"ok")), - Err(io::Error::new(io::ErrorKind::Other, "boom")), + Err(io::Error::other("boom")), ]); let body = Body::from_stream(stream); let mut stream = body.into_stream().expect("stream"); From f1cd61d18593e6ede873bcb3d39829504823143e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 21 Jan 2026 10:19:50 -0800 Subject: [PATCH 4/4] Added explicit ForwardedHost extractor to prioritize X-Forwarded-Host header over Host header. --- crates/edgezero-core/src/extractor.rs | 126 +++++++++++++++++++++++--- 1 file changed, 111 insertions(+), 15 deletions(-) diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index db4654c..63957a8 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -111,18 +111,15 @@ impl Headers { } } -/// Extracts the effective host from the request. +/// Extracts the host from the standard `Host` header. /// -/// Checks headers in this order: -/// 1. `X-Forwarded-Host` - set by reverse proxies/load balancers -/// 2. `Host` - standard HTTP host header -/// 3. Falls back to "localhost" if neither is present +/// Falls back to "localhost" if the header is not present. /// /// # Example /// ```ignore /// #[action] /// pub async fn handler(Host(host): Host) -> Response { -/// // host contains the effective hostname +/// // host contains the hostname from the Host header /// } /// ``` pub struct Host(pub String); @@ -132,8 +129,7 @@ impl FromRequest for Host { async fn from_request(ctx: &RequestContext) -> Result { let headers = ctx.request().headers(); let host = headers - .get("x-forwarded-host") - .or_else(|| headers.get(header::HOST)) + .get(header::HOST) .and_then(|v| v.to_str().ok()) .unwrap_or("localhost") .to_string(); @@ -155,6 +151,52 @@ impl Host { } } +/// Extracts the effective host from the request, checking forwarded headers first. +/// +/// Checks headers in this order: +/// 1. `X-Forwarded-Host` - set by reverse proxies/load balancers +/// 2. `Host` - standard HTTP host header +/// 3. Falls back to "localhost" if neither is present +/// +/// Use this extractor when your application is behind a reverse proxy or load balancer. +/// +/// # Example +/// ```ignore +/// #[action] +/// pub async fn handler(ForwardedHost(host): ForwardedHost) -> Response { +/// // host contains the effective hostname (X-Forwarded-Host or Host) +/// } +/// ``` +pub struct ForwardedHost(pub String); + +#[async_trait(?Send)] +impl FromRequest for ForwardedHost { + async fn from_request(ctx: &RequestContext) -> Result { + let headers = ctx.request().headers(); + let host = headers + .get("x-forwarded-host") + .or_else(|| headers.get(header::HOST)) + .and_then(|v| v.to_str().ok()) + .unwrap_or("localhost") + .to_string(); + Ok(ForwardedHost(host)) + } +} + +impl Deref for ForwardedHost { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl ForwardedHost { + pub fn into_inner(self) -> String { + self.0 + } +} + pub struct Query(pub T); #[async_trait(?Send)] @@ -762,7 +804,22 @@ mod tests { // Host extractor tests #[test] - fn host_extractor_uses_x_forwarded_host_first() { + fn host_extractor_uses_host_header() { + let mut request = request_builder() + .method(Method::GET) + .uri("/test") + .body(Body::empty()) + .expect("request"); + request + .headers_mut() + .insert("host", HeaderValue::from_static("example.com")); + let ctx = RequestContext::new(request, PathParams::default()); + let host = block_on(Host::from_request(&ctx)).expect("host"); + assert_eq!(host.0, "example.com"); + } + + #[test] + fn host_extractor_ignores_x_forwarded_host() { let mut request = request_builder() .method(Method::GET) .uri("/test") @@ -776,11 +833,50 @@ mod tests { .insert("x-forwarded-host", HeaderValue::from_static("example.com")); let ctx = RequestContext::new(request, PathParams::default()); let host = block_on(Host::from_request(&ctx)).expect("host"); + assert_eq!(host.0, "internal.local"); + } + + #[test] + fn host_extractor_uses_default_when_no_headers() { + let request = request_builder() + .method(Method::GET) + .uri("/test") + .body(Body::empty()) + .expect("request"); + let ctx = RequestContext::new(request, PathParams::default()); + let host = block_on(Host::from_request(&ctx)).expect("host"); + assert_eq!(host.0, "localhost"); + } + + #[test] + fn host_deref_and_into_inner() { + let host = Host("example.com".to_string()); + assert_eq!(&*host, "example.com"); // Deref + let inner = host.into_inner(); + assert_eq!(inner, "example.com"); + } + + // ForwardedHost extractor tests + #[test] + fn forwarded_host_extractor_uses_x_forwarded_host_first() { + let mut request = request_builder() + .method(Method::GET) + .uri("/test") + .body(Body::empty()) + .expect("request"); + request + .headers_mut() + .insert("host", HeaderValue::from_static("internal.local")); + request + .headers_mut() + .insert("x-forwarded-host", HeaderValue::from_static("example.com")); + let ctx = RequestContext::new(request, PathParams::default()); + let host = block_on(ForwardedHost::from_request(&ctx)).expect("host"); assert_eq!(host.0, "example.com"); } #[test] - fn host_extractor_falls_back_to_host_header() { + fn forwarded_host_extractor_falls_back_to_host_header() { let mut request = request_builder() .method(Method::GET) .uri("/test") @@ -790,25 +886,25 @@ mod tests { .headers_mut() .insert("host", HeaderValue::from_static("example.com")); let ctx = RequestContext::new(request, PathParams::default()); - let host = block_on(Host::from_request(&ctx)).expect("host"); + let host = block_on(ForwardedHost::from_request(&ctx)).expect("host"); assert_eq!(host.0, "example.com"); } #[test] - fn host_extractor_uses_default_when_no_headers() { + fn forwarded_host_extractor_uses_default_when_no_headers() { let request = request_builder() .method(Method::GET) .uri("/test") .body(Body::empty()) .expect("request"); let ctx = RequestContext::new(request, PathParams::default()); - let host = block_on(Host::from_request(&ctx)).expect("host"); + let host = block_on(ForwardedHost::from_request(&ctx)).expect("host"); assert_eq!(host.0, "localhost"); } #[test] - fn host_deref_and_into_inner() { - let host = Host("example.com".to_string()); + fn forwarded_host_deref_and_into_inner() { + let host = ForwardedHost("example.com".to_string()); assert_eq!(&*host, "example.com"); // Deref let inner = host.into_inner(); assert_eq!(inner, "example.com");