From bbde372bb8ec8ee873e106d2e683380694d65829 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sun, 5 Oct 2025 12:08:19 -0700 Subject: [PATCH] Refactor proxying for all adapters --- Cargo.lock | 393 +++++++++++++++++- Cargo.toml | 1 + crates/anyedge-adapter-axum/Cargo.toml | 16 +- crates/anyedge-adapter-axum/src/context.rs | 52 +++ .../src/{server/runner.rs => dev_server.rs} | 5 +- crates/anyedge-adapter-axum/src/lib.rs | 26 +- crates/anyedge-adapter-axum/src/proxy.rs | 88 ++++ crates/anyedge-adapter-axum/src/request.rs | 90 ++++ .../src/{server/convert.rs => response.rs} | 33 +- crates/anyedge-adapter-axum/src/server/mod.rs | 6 - .../src/{server => }/service.rs | 31 +- .../anyedge-adapter-cloudflare/src/request.rs | 5 + crates/anyedge-adapter-fastly/src/request.rs | 5 + .../src/templates/core/Cargo.toml.hbs | 4 + .../src/templates/core/src/handlers.rs.hbs | 121 +++++- .../src/templates/root/README.md.hbs | 13 + .../src/templates/root/anyedge.toml.hbs | 8 + crates/anyedge-core/src/context.rs | 31 ++ crates/anyedge-core/src/proxy.rs | 30 ++ examples/app-demo/anyedge.toml | 7 + .../app-demo/crates/app-demo-core/Cargo.toml | 1 + .../crates/app-demo-core/src/handlers.rs | 119 +++++- 22 files changed, 1027 insertions(+), 58 deletions(-) create mode 100644 crates/anyedge-adapter-axum/src/context.rs rename crates/anyedge-adapter-axum/src/{server/runner.rs => dev_server.rs} (94%) create mode 100644 crates/anyedge-adapter-axum/src/proxy.rs create mode 100644 crates/anyedge-adapter-axum/src/request.rs rename crates/anyedge-adapter-axum/src/{server/convert.rs => response.rs} (72%) delete mode 100644 crates/anyedge-adapter-axum/src/server/mod.rs rename crates/anyedge-adapter-axum/src/{server => }/service.rs (55%) diff --git a/Cargo.lock b/Cargo.lock index 62136e0..4f082f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,6 +124,7 @@ dependencies = [ "futures-util", "http", "log", + "reqwest", "simple_logger", "tempfile", "thiserror 2.0.17", @@ -286,6 +287,7 @@ name = "app-demo-core" version = "0.1.0" dependencies = [ "anyedge-core", + "async-trait", "bytes", "futures", "serde", @@ -429,6 +431,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -508,6 +516,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.42" @@ -1010,6 +1024,19 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.3" @@ -1017,9 +1044,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasi 0.14.7+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -1121,6 +1150,24 @@ dependencies = [ "pin-utils", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] @@ -1129,14 +1176,22 @@ version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ + "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -1297,6 +1352,22 @@ dependencies = [ "libc", ] +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -1369,6 +1440,12 @@ dependencies = [ "regex", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchit" version = "0.7.3" @@ -1585,6 +1662,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -1616,6 +1702,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.41" @@ -1631,6 +1772,35 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + [[package]] name = "regex" version = "1.11.3" @@ -1660,12 +1830,70 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +[[package]] +name = "reqwest" +version = "0.12.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-demangle" version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "1.1.2" @@ -1679,6 +1907,41 @@ dependencies = [ "windows-sys 0.61.1", ] +[[package]] +name = "rustls" +version = "0.23.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1882,6 +2145,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -1909,6 +2178,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -1928,7 +2200,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.3.3", "once_cell", "rustix", "windows-sys 0.61.1", @@ -2017,6 +2289,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.47.1" @@ -2024,6 +2311,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", + "bytes", "io-uring", "libc", "mio", @@ -2046,6 +2334,16 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "toml" version = "0.9.7" @@ -2101,6 +2399,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.9.4", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -2145,6 +2461,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.19.0" @@ -2163,6 +2485,12 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.7" @@ -2233,6 +2561,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2376,6 +2713,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -2453,6 +2809,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -2794,6 +3159,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "zerofrom" version = "0.1.6" @@ -2815,6 +3200,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index c1508bc..23ed363 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,3 +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"] } diff --git a/crates/anyedge-adapter-axum/Cargo.toml b/crates/anyedge-adapter-axum/Cargo.toml index a660eca..08433c5 100644 --- a/crates/anyedge-adapter-axum/Cargo.toml +++ b/crates/anyedge-adapter-axum/Cargo.toml @@ -7,30 +7,32 @@ license = { workspace = true } [features] default = ["axum"] -axum = ["dep:axum", "dep:tokio", "dep:tower", "dep:futures-util"] +axum = ["dep:axum", "dep:tokio", "dep:tower", "dep:futures-util", "dep:reqwest"] cli = ["dep:anyedge-adapter", "anyedge-adapter/cli", "dep:ctor", "dep:toml", "dep:walkdir"] [dependencies] -anyhow = { workspace = true } -anyedge-core = { path = "../anyedge-core" } anyedge-adapter = { path = "../anyedge-adapter", optional = true, features = ["cli"] } +anyedge-core = { path = "../anyedge-core" } +anyhow = { workspace = true } +async-trait = { workspace = true } axum = { workspace = true, optional = true } bytes = { workspace = true } ctor = { workspace = true, optional = true } futures = { workspace = true } futures-util = { workspace = true, optional = true } http = { workspace = true } +log = { workspace = true } +reqwest = { workspace = true, optional = true } +simple_logger = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, optional = true } +toml = { workspace = true, optional = true } tower = { workspace = true, optional = true } tracing = { workspace = true } -toml = { workspace = true, optional = true } walkdir = { workspace = true, optional = true } -log = { workspace = true } -simple_logger = { workspace = true } [dev-dependencies] async-trait = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } axum = { workspace = true, features = ["macros"] } tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } diff --git a/crates/anyedge-adapter-axum/src/context.rs b/crates/anyedge-adapter-axum/src/context.rs new file mode 100644 index 0000000..bca74ba --- /dev/null +++ b/crates/anyedge-adapter-axum/src/context.rs @@ -0,0 +1,52 @@ +use std::net::SocketAddr; + +use anyedge_core::http::Request; + +/// Axum-specific context data attached to each request. +#[derive(Clone, Debug)] +pub struct AxumRequestContext { + pub remote_addr: Option, +} + +impl AxumRequestContext { + pub fn insert(request: &mut Request, context: AxumRequestContext) { + request.extensions_mut().insert(context); + } + + pub fn get(request: &Request) -> Option<&AxumRequestContext> { + request.extensions().get::() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyedge_core::body::Body; + use anyedge_core::http::request_builder; + + #[test] + fn inserts_and_reads_context() { + let mut request = request_builder() + .uri("https://example.com") + .body(Body::empty()) + .expect("request"); + + let context = AxumRequestContext { + remote_addr: Some("127.0.0.1:3000".parse().unwrap()), + }; + AxumRequestContext::insert(&mut request, context.clone()); + + let retrieved = AxumRequestContext::get(&request).expect("context present"); + assert_eq!(retrieved.remote_addr, context.remote_addr); + } + + #[test] + fn missing_context_returns_none() { + let request = request_builder() + .uri("https://example.com") + .body(Body::empty()) + .expect("request"); + + assert!(AxumRequestContext::get(&request).is_none()); + } +} diff --git a/crates/anyedge-adapter-axum/src/server/runner.rs b/crates/anyedge-adapter-axum/src/dev_server.rs similarity index 94% rename from crates/anyedge-adapter-axum/src/server/runner.rs rename to crates/anyedge-adapter-axum/src/dev_server.rs index 01c2fb6..4083866 100644 --- a/crates/anyedge-adapter-axum/src/server/runner.rs +++ b/crates/anyedge-adapter-axum/src/dev_server.rs @@ -12,7 +12,7 @@ use anyedge_core::router::RouterService; use log::LevelFilter; use simple_logger::SimpleLogger; -use super::service::AnyEdgeAxumService; +use crate::service::AnyEdgeAxumService; /// Configuration used when running the dev server embedding AnyEdge into Axum. #[derive(Clone)] @@ -75,6 +75,7 @@ impl AxumDevServer { 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 { @@ -84,7 +85,7 @@ impl AxumDevServer { None }; - let server = axum::serve(listener, router.into_make_service()); + 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")?; diff --git a/crates/anyedge-adapter-axum/src/lib.rs b/crates/anyedge-adapter-axum/src/lib.rs index a5a73dc..4ef0072 100644 --- a/crates/anyedge-adapter-axum/src/lib.rs +++ b/crates/anyedge-adapter-axum/src/lib.rs @@ -1,10 +1,30 @@ //! Axum adapter for AnyEdge routers and applications. #[cfg(feature = "axum")] -mod server; - +mod context; +#[cfg(feature = "axum")] +mod dev_server; +#[cfg(feature = "axum")] +mod proxy; #[cfg(feature = "axum")] -pub use server::{run_app, AnyEdgeAxumService, AxumDevServer, AxumDevServerConfig}; +mod request; +#[cfg(feature = "axum")] +mod response; +#[cfg(feature = "axum")] +mod service; #[cfg(feature = "cli")] pub mod cli; + +#[cfg(feature = "axum")] +pub use context::AxumRequestContext; +#[cfg(feature = "axum")] +pub use dev_server::{run_app, AxumDevServer, AxumDevServerConfig}; +#[cfg(feature = "axum")] +pub use proxy::AxumProxyClient; +#[cfg(feature = "axum")] +pub use request::into_core_request; +#[cfg(feature = "axum")] +pub use response::into_axum_response; +#[cfg(feature = "axum")] +pub use service::AnyEdgeAxumService; diff --git a/crates/anyedge-adapter-axum/src/proxy.rs b/crates/anyedge-adapter-axum/src/proxy.rs new file mode 100644 index 0000000..b0cd8f3 --- /dev/null +++ b/crates/anyedge-adapter-axum/src/proxy.rs @@ -0,0 +1,88 @@ +use std::time::Duration; + +use anyedge_core::body::Body; +use anyedge_core::error::EdgeError; +use anyedge_core::http::{HeaderName, HeaderValue, Method, StatusCode}; +use anyedge_core::proxy::{ProxyClient, ProxyRequest, ProxyResponse}; +use async_trait::async_trait; +use futures_util::StreamExt; +use reqwest::{header, Client}; + +pub struct AxumProxyClient { + client: Client, +} + +impl Default for AxumProxyClient { + fn default() -> Self { + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("reqwest client"); + Self { client } + } +} + +#[async_trait(?Send)] +impl ProxyClient for AxumProxyClient { + async fn send(&self, request: ProxyRequest) -> Result { + let (method, uri, headers, body, _extensions) = request.into_parts(); + let reqwest_method = reqwest_method(&method)?; + let mut builder = self.client.request(reqwest_method, uri.to_string()); + + for (name, value) in headers.iter() { + let header_name = header::HeaderName::from_bytes(name.as_str().as_bytes()) + .map_err(EdgeError::internal)?; + let header_value = + header::HeaderValue::from_bytes(value.as_bytes()).map_err(EdgeError::internal)?; + builder = builder.header(header_name, header_value); + } + + builder = match body { + Body::Once(bytes) => builder.body(bytes.to_vec()), + Body::Stream(mut stream) => { + let mut buf = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(EdgeError::internal)?; + buf.extend_from_slice(&chunk); + } + builder.body(buf) + } + }; + + let response = builder.send().await.map_err(EdgeError::internal)?; + let status = + StatusCode::from_u16(response.status().as_u16()).map_err(EdgeError::internal)?; + let mut proxy_response = ProxyResponse::new(status, Body::empty()); + + for (name, value) in response.headers().iter() { + let header_name = + HeaderName::from_bytes(name.as_str().as_bytes()).map_err(EdgeError::internal)?; + let header_value = + HeaderValue::from_bytes(value.as_bytes()).map_err(EdgeError::internal)?; + proxy_response + .headers_mut() + .insert(header_name, header_value); + } + + let bytes = response.bytes().await.map_err(EdgeError::internal)?; + *proxy_response.body_mut() = Body::from(bytes.to_vec()); + + Ok(proxy_response) + } +} + +fn reqwest_method(method: &Method) -> Result { + reqwest::Method::from_bytes(method.as_str().as_bytes()).map_err(EdgeError::internal) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn converts_method_to_reqwest() { + let method = Method::POST; + let req = reqwest_method(&method).expect("reqwest method"); + assert_eq!(req, reqwest::Method::POST); + } +} diff --git a/crates/anyedge-adapter-axum/src/request.rs b/crates/anyedge-adapter-axum/src/request.rs new file mode 100644 index 0000000..8787d87 --- /dev/null +++ b/crates/anyedge-adapter-axum/src/request.rs @@ -0,0 +1,90 @@ +use std::net::SocketAddr; + +use anyedge_core::body::Body; +use anyedge_core::http::Request as CoreRequest; +use anyedge_core::proxy::ProxyHandle; +use axum::body::Body as AxumBody; +use axum::extract::connect_info::ConnectInfo; +use axum::http::Request; + +use crate::context::AxumRequestContext; +use crate::proxy::AxumProxyClient; + +/// Convert an Axum/Hyper request into an AnyEdge core request while preserving streaming bodies +/// and exposing connection metadata through `AxumRequestContext`. +pub fn into_core_request(request: Request) -> CoreRequest { + let (parts, body) = request.into_parts(); + let stream = body.into_data_stream(); + let body = Body::from_stream(stream); + let mut core_request = CoreRequest::from_parts(parts, body); + + if let Some(remote_addr) = core_request + .extensions() + .get::>() + .map(|ConnectInfo(addr)| *addr) + { + core_request + .extensions_mut() + .remove::>(); + AxumRequestContext::insert( + &mut core_request, + AxumRequestContext { + remote_addr: Some(remote_addr), + }, + ); + } + + core_request + .extensions_mut() + .insert(ProxyHandle::with_client(AxumProxyClient::default())); + + core_request +} + +#[cfg(test)] +mod tests { + use super::*; + use anyedge_core::body::Body; + use anyedge_core::http::Method; + + #[test] + fn converts_request_and_records_connect_info() { + let mut request = Request::builder() + .method(Method::POST) + .uri("/demo") + .header("x-test", "1") + .body(AxumBody::from("payload")) + .expect("request"); + request + .extensions_mut() + .insert(ConnectInfo::("127.0.0.1:4000".parse().unwrap())); + + let core_request = into_core_request(request); + assert_eq!(core_request.method(), &Method::POST); + assert_eq!(core_request.uri().path(), "/demo"); + assert_eq!(core_request.headers()["x-test"], "1"); + match core_request.body() { + Body::Stream(_) => {} // streaming bodies stay streaming + Body::Once(_) => panic!("body should remain streaming"), + } + + let context = AxumRequestContext::get(&core_request).expect("context"); + assert_eq!(context.remote_addr, Some("127.0.0.1:4000".parse().unwrap())); + assert!(core_request + .extensions() + .get::>() + .is_none()); + } + + #[test] + fn missing_connect_info_is_handled_gracefully() { + let request = Request::builder() + .method(Method::GET) + .uri("/demo") + .body(AxumBody::empty()) + .expect("request"); + + let core_request = into_core_request(request); + assert!(AxumRequestContext::get(&core_request).is_none()); + } +} diff --git a/crates/anyedge-adapter-axum/src/server/convert.rs b/crates/anyedge-adapter-axum/src/response.rs similarity index 72% rename from crates/anyedge-adapter-axum/src/server/convert.rs rename to crates/anyedge-adapter-axum/src/response.rs index 042357a..d6d7d11 100644 --- a/crates/anyedge-adapter-axum/src/server/convert.rs +++ b/crates/anyedge-adapter-axum/src/response.rs @@ -1,19 +1,11 @@ use axum::body::Body as AxumBody; -use axum::http::{Request, Response, StatusCode}; +use axum::http::{Response, StatusCode}; use futures::executor::block_on; use futures_util::{pin_mut, StreamExt}; use tracing::error; use anyedge_core::body::Body; -use anyedge_core::http::{Request as CoreRequest, Response as CoreResponse}; - -/// Convert an Axum/Hyper request into an AnyEdge core request while preserving streaming bodies. -pub fn into_core_request(request: Request) -> CoreRequest { - let (parts, body) = request.into_parts(); - let stream = body.into_data_stream(); - let body = Body::from_stream(stream); - CoreRequest::from_parts(parts, body) -} +use anyedge_core::http::Response as CoreResponse; /// Convert an AnyEdge response into one consumable by Axum/Hyper. /// @@ -61,28 +53,9 @@ pub fn into_axum_response(response: CoreResponse) -> Response { mod tests { use super::*; use anyedge_core::body::Body; - use anyedge_core::http::{response_builder, Method, StatusCode}; + use anyedge_core::http::{response_builder, StatusCode}; use futures::stream; - #[test] - fn converts_axum_request_into_core_request() { - let request = Request::builder() - .method(Method::POST) - .uri("/demo") - .header("x-test", "1") - .body(AxumBody::from("payload")) - .expect("request"); - - let core_request = into_core_request(request); - assert_eq!(core_request.method(), &Method::POST); - assert_eq!(core_request.uri().path(), "/demo"); - assert_eq!(core_request.headers()["x-test"], "1"); - match core_request.into_body() { - Body::Once(_) => panic!("body should be wrapped as stream"), - Body::Stream(_) => {} // streaming bodies stay streaming - } - } - #[test] fn converts_core_response_stream_into_axum_body() { let stream = stream::iter(vec![ diff --git a/crates/anyedge-adapter-axum/src/server/mod.rs b/crates/anyedge-adapter-axum/src/server/mod.rs deleted file mode 100644 index 318c085..0000000 --- a/crates/anyedge-adapter-axum/src/server/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -mod convert; -mod runner; -mod service; - -pub use runner::{run_app, AxumDevServer, AxumDevServerConfig}; -pub use service::AnyEdgeAxumService; diff --git a/crates/anyedge-adapter-axum/src/server/service.rs b/crates/anyedge-adapter-axum/src/service.rs similarity index 55% rename from crates/anyedge-adapter-axum/src/server/service.rs rename to crates/anyedge-adapter-axum/src/service.rs index a4d1688..77ea0e1 100644 --- a/crates/anyedge-adapter-axum/src/server/service.rs +++ b/crates/anyedge-adapter-axum/src/service.rs @@ -10,7 +10,8 @@ use tower::Service; use anyedge_core::router::RouterService; -use super::convert::{into_axum_response, into_core_request}; +use crate::request::into_core_request; +use crate::response::into_axum_response; /// Tower service that adapts AnyEdge router requests to Axum/Hyper compatible responses. #[derive(Clone)] @@ -45,3 +46,31 @@ impl Service> for AnyEdgeAxumService { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use anyedge_core::body::Body; + use anyedge_core::context::RequestContext; + use anyedge_core::error::EdgeError; + use anyedge_core::http::{response_builder, StatusCode}; + use tower::ServiceExt; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn forwards_request_to_router() { + let router = RouterService::builder() + .get("/", |_ctx: RequestContext| async move { + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from("ok")) + .expect("response"); + Ok::<_, EdgeError>(response) + }) + .build(); + let mut service = AnyEdgeAxumService::new(router); + + let request = Request::builder().uri("/").body(AxumBody::empty()).unwrap(); + let response = service.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } +} diff --git a/crates/anyedge-adapter-cloudflare/src/request.rs b/crates/anyedge-adapter-cloudflare/src/request.rs index 3995ac9..05e445f 100644 --- a/crates/anyedge-adapter-cloudflare/src/request.rs +++ b/crates/anyedge-adapter-cloudflare/src/request.rs @@ -1,9 +1,11 @@ +use crate::proxy::CloudflareProxyClient; use crate::response::from_core_response; use crate::CloudflareRequestContext; use anyedge_core::app::App; use anyedge_core::body::Body; use anyedge_core::error::EdgeError; use anyedge_core::http::{request_builder, Method as CoreMethod, Request, Uri}; +use anyedge_core::proxy::ProxyHandle; use worker::{ Context, Env, Error as WorkerError, Method, Request as CfRequest, Response as CfResponse, }; @@ -37,6 +39,9 @@ pub async fn into_core_request( .map_err(EdgeError::internal)?; CloudflareRequestContext::insert(&mut request, env, ctx); + request + .extensions_mut() + .insert(ProxyHandle::with_client(CloudflareProxyClient)); Ok(request) } diff --git a/crates/anyedge-adapter-fastly/src/request.rs b/crates/anyedge-adapter-fastly/src/request.rs index 3fa4d08..715d9a3 100644 --- a/crates/anyedge-adapter-fastly/src/request.rs +++ b/crates/anyedge-adapter-fastly/src/request.rs @@ -4,9 +4,11 @@ use anyedge_core::app::App; use anyedge_core::body::Body; use anyedge_core::error::EdgeError; use anyedge_core::http::{request_builder, Request}; +use anyedge_core::proxy::ProxyHandle; use fastly::{Error as FastlyError, Request as FastlyRequest, Response as FastlyResponse}; use futures::executor; +use crate::proxy::FastlyProxyClient; use crate::response::{from_core_response, parse_uri}; use crate::FastlyRequestContext; @@ -31,6 +33,9 @@ pub fn into_core_request(mut req: FastlyRequest) -> Result { client_ip: req.get_client_ip_addr(), }; FastlyRequestContext::insert(&mut request, context); + request + .extensions_mut() + .insert(ProxyHandle::with_client(FastlyProxyClient)); Ok(request) } diff --git a/crates/anyedge-cli/src/templates/core/Cargo.toml.hbs b/crates/anyedge-cli/src/templates/core/Cargo.toml.hbs index 1c44542..617c1c2 100644 --- a/crates/anyedge-cli/src/templates/core/Cargo.toml.hbs +++ b/crates/anyedge-cli/src/templates/core/Cargo.toml.hbs @@ -9,3 +9,7 @@ publish = false bytes = { workspace = true } futures = { workspace = true } serde = { workspace = true } + +[dev-dependencies] +async-trait = "0.1" +serde_json = "1" diff --git a/crates/anyedge-cli/src/templates/core/src/handlers.rs.hbs b/crates/anyedge-cli/src/templates/core/src/handlers.rs.hbs index 22ce3b4..13063f8 100644 --- a/crates/anyedge-cli/src/templates/core/src/handlers.rs.hbs +++ b/crates/anyedge-cli/src/templates/core/src/handlers.rs.hbs @@ -1,11 +1,16 @@ use anyedge_core::action; use anyedge_core::body::Body; +use anyedge_core::context::RequestContext; +use anyedge_core::error::EdgeError; use anyedge_core::extractor::{Headers, Json, Path}; -use anyedge_core::http::{self, Response, StatusCode}; +use anyedge_core::http::{self, Response, StatusCode, Uri}; +use anyedge_core::proxy::ProxyRequest; use anyedge_core::response::Text; use bytes::Bytes; use futures::{stream, StreamExt}; +const DEFAULT_PROXY_BASE: &str = "https://httpbin.org"; + #[derive(serde::Deserialize)] pub(crate) struct EchoParams { pub(crate) name: String, @@ -16,6 +21,12 @@ pub(crate) struct EchoBody { pub(crate) name: String, } +#[derive(serde::Deserialize)] +struct ProxyPath { + #[serde(default)] + rest: String, +} + #[action] pub(crate) async fn root() -> Text<&'static str> { Text::new("{{name}} app") @@ -54,17 +65,66 @@ pub(crate) async fn echo_json(Json(body): Json) -> Text { Text::new(format!("Hello, {}!", body.name)) } +pub(crate) async fn proxy_demo(ctx: RequestContext) -> Result { + let params: ProxyPath = ctx.path()?; + let proxy_handle = ctx.proxy_handle(); + let request = ctx.into_request(); + let target = build_proxy_target(¶ms.rest, request.uri())?; + let proxy_request = ProxyRequest::from_request(request, target); + + if let Some(handle) = proxy_handle { + handle.forward(proxy_request).await + } else { + proxy_not_available_response() + } +} + +fn build_proxy_target(rest: &str, original_uri: &Uri) -> Result { + let base = std::env::var("API_BASE_URL").unwrap_or_else(|_| DEFAULT_PROXY_BASE.to_string()); + let mut target = base.trim_end_matches('/').to_string(); + let trimmed_rest = rest.trim_start_matches('/'); + if !trimmed_rest.is_empty() { + target.push('/'); + target.push_str(trimmed_rest); + } + + if let Some(query) = original_uri.query() { + if !query.is_empty() { + target.push('?'); + target.push_str(query); + } + } + + target + .parse::() + .map_err(|err| EdgeError::bad_request(format!("invalid proxy target URI: {err}"))) +} + +fn proxy_not_available_response() -> Result { + let body = Body::text( + "proxy example is not enabled for this adapter build; enable a proxy-capable adapter", + ); + http::response_builder() + .status(StatusCode::NOT_IMPLEMENTED) + .header("content-type", "text/plain; charset=utf-8") + .body(body) + .map_err(EdgeError::internal) +} + #[cfg(test)] mod tests { use super::*; use anyedge_core::body::Body; use anyedge_core::context::RequestContext; - use anyedge_core::http::{request_builder, Method, StatusCode}; use anyedge_core::http::header::{HeaderName, HeaderValue}; + use anyedge_core::http::{request_builder, Method, StatusCode, Uri}; use anyedge_core::params::PathParams; + use anyedge_core::proxy::{ProxyClient, ProxyHandle, ProxyResponse}; use anyedge_core::response::IntoResponse; + use async_trait::async_trait; use futures::{executor::block_on, StreamExt}; use std::collections::HashMap; + use std::env; #[test] fn root_returns_static_body() { @@ -101,10 +161,7 @@ mod tests { let response = block_on(stream(ctx)).expect("handler ok"); assert_eq!(response.status(), StatusCode::OK); - let mut chunks = response - .into_body() - .into_stream() - .expect("stream body"); + let mut chunks = response.into_body().into_stream().expect("stream body"); let collected = block_on(async { let mut buf = Vec::new(); while let Some(chunk) = chunks.next().await { @@ -130,6 +187,58 @@ mod tests { assert_eq!(bytes.as_ref(), b"Hello, Edge!"); } + #[test] + fn build_proxy_target_merges_segments_and_query() { + env::set_var("API_BASE_URL", "https://example.com/api"); + let original = Uri::from_static("/proxy/status?foo=bar"); + let target = build_proxy_target("status/200", &original).expect("target uri"); + assert_eq!(target.to_string(), "https://example.com/api/status/200?foo=bar"); + env::remove_var("API_BASE_URL"); + } + + #[test] + fn proxy_demo_without_handle_returns_placeholder() { + env::set_var("API_BASE_URL", "https://example.com/api"); + let ctx = context_with_params("/proxy/status/200", &[("rest", "status/200")]); + let response = block_on(proxy_demo(ctx)).expect("response"); + assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); + env::remove_var("API_BASE_URL"); + } + + struct TestProxyClient; + + #[async_trait(?Send)] + impl ProxyClient for TestProxyClient { + async fn send(&self, request: ProxyRequest) -> Result { + let (_method, uri, _headers, _body, _) = request.into_parts(); + assert!(uri.to_string().contains("status/201")); + Ok(ProxyResponse::new(StatusCode::CREATED, Body::empty())) + } + } + + #[test] + fn proxy_demo_uses_injected_handle() { + env::set_var("API_BASE_URL", "https://example.com/api"); + + let mut request = request_builder() + .method(Method::GET) + .uri("/proxy/status/201") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(ProxyHandle::with_client(TestProxyClient)); + + let mut params = HashMap::new(); + params.insert("rest".to_string(), "status/201".to_string()); + let ctx = RequestContext::new(request, PathParams::new(params)); + + let response = block_on(proxy_demo(ctx)).expect("response"); + assert_eq!(response.status(), StatusCode::CREATED); + + env::remove_var("API_BASE_URL"); + } + fn empty_context(path: &str) -> RequestContext { let request = request_builder() .method(Method::GET) diff --git a/crates/anyedge-cli/src/templates/root/README.md.hbs b/crates/anyedge-cli/src/templates/root/README.md.hbs index ad327d3..6a84955 100644 --- a/crates/anyedge-cli/src/templates/root/README.md.hbs +++ b/crates/anyedge-cli/src/templates/root/README.md.hbs @@ -5,6 +5,19 @@ This workspace demonstrates a multi-target AnyEdge app. - `crates/{{proj_core}}`: reusable application logic built with `anyedge-core`. {{{readme_adapter_crates}}} +## Routes + +- `GET /` – simple text health check +- `GET /echo/{name}` – path parameter echo +- `GET /headers` – prints the incoming `user-agent` +- `GET /stream` – emits a short streamed response body +- `POST /echo` – echoes JSON payloads +- `GET|POST /proxy/{*rest}` – forwards requests to `API_BASE_URL`, falling back to a 501 when the active adapter does not expose a proxy client + ## Dev {{{readme_adapter_dev}}} + +## Configuration + +Environment variables are declared in `anyedge.toml`. Set `API_BASE_URL` to the upstream origin you want `/proxy/...` to target and provide adapter-specific secrets (for example `API_TOKEN`) when deploying. diff --git a/crates/anyedge-cli/src/templates/root/anyedge.toml.hbs b/crates/anyedge-cli/src/templates/root/anyedge.toml.hbs index 305929b..8d547b9 100644 --- a/crates/anyedge-cli/src/templates/root/anyedge.toml.hbs +++ b/crates/anyedge-cli/src/templates/root/anyedge.toml.hbs @@ -44,6 +44,14 @@ methods = ["POST"] handler = "{{proj_core_mod}}::handlers::echo_json" adapters = [{{{adapter_list}}}] + +[[triggers.http]] +id = "proxy_demo" +path = "/proxy/{*rest}" +methods = ["GET", "POST"] +handler = "{{proj_core_mod}}::handlers::proxy_demo" +adapters = [{{{adapter_list}}}] + [environment] [[environment.variables]] diff --git a/crates/anyedge-core/src/context.rs b/crates/anyedge-core/src/context.rs index f2b9e41..910455d 100644 --- a/crates/anyedge-core/src/context.rs +++ b/crates/anyedge-core/src/context.rs @@ -2,6 +2,7 @@ use crate::body::Body; use crate::error::EdgeError; use crate::http::Request; use crate::params::PathParams; +use crate::proxy::ProxyHandle; use serde::de::DeserializeOwned; /// Request context exposed to handlers and middleware. @@ -78,6 +79,10 @@ impl RequestContext { )), } } + + pub fn proxy_handle(&self) -> Option { + self.request.extensions().get::().cloned() + } } #[cfg(test)] @@ -85,6 +90,8 @@ mod tests { use super::*; use crate::http::{request_builder, Method, StatusCode}; use crate::params::PathParams; + use crate::proxy::{ProxyClient, ProxyHandle, ProxyRequest, ProxyResponse}; + use async_trait::async_trait; use bytes::Bytes; use futures::stream; use serde::{Deserialize, Serialize}; @@ -216,4 +223,28 @@ mod tests { .message() .contains("streaming bodies are not supported for form extraction")); } + + struct DummyClient; + + #[async_trait(?Send)] + impl ProxyClient for DummyClient { + async fn send(&self, _request: ProxyRequest) -> Result { + Ok(ProxyResponse::new(StatusCode::OK, Body::empty())) + } + } + + #[test] + fn proxy_handle_is_retrieved_when_present() { + let mut request = request_builder() + .method(Method::GET) + .uri("/proxy") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(ProxyHandle::with_client(DummyClient)); + + let ctx = RequestContext::new(request, PathParams::default()); + assert!(ctx.proxy_handle().is_some()); + } } diff --git a/crates/anyedge-core/src/proxy.rs b/crates/anyedge-core/src/proxy.rs index 41ced58..bcceed4 100644 --- a/crates/anyedge-core/src/proxy.rs +++ b/crates/anyedge-core/src/proxy.rs @@ -1,4 +1,5 @@ use std::fmt; +use std::sync::Arc; use async_trait::async_trait; @@ -156,6 +157,35 @@ impl fmt::Debug for ProxyResponse { } } +#[derive(Clone)] +pub struct ProxyHandle { + client: Arc, +} + +impl ProxyHandle { + pub fn new(client: Arc) -> Self { + Self { client } + } + + pub fn with_client(client: C) -> Self + where + C: ProxyClient + 'static, + { + Self { + client: Arc::new(client), + } + } + + pub fn client(&self) -> Arc { + Arc::clone(&self.client) + } + + pub async fn forward(&self, request: ProxyRequest) -> Result { + let response = self.client.send(request).await?; + Ok(response.into_response()) + } +} + #[async_trait(?Send)] pub trait ProxyClient: Send + Sync { async fn send(&self, request: ProxyRequest) -> Result; diff --git a/examples/app-demo/anyedge.toml b/examples/app-demo/anyedge.toml index 00d364d..811f6bc 100644 --- a/examples/app-demo/anyedge.toml +++ b/examples/app-demo/anyedge.toml @@ -48,6 +48,13 @@ methods = ["GET"] handler = "app_demo_core::handlers::list_routes" adapters = ["fastly", "cloudflare", "axum"] +[[triggers.http]] +id = "proxy_demo" +path = "/proxy/{*rest}" +methods = ["GET", "POST"] +handler = "app_demo_core::handlers::proxy_demo" +adapters = ["fastly", "cloudflare", "axum"] + [environment] [[environment.variables]] diff --git a/examples/app-demo/crates/app-demo-core/Cargo.toml b/examples/app-demo/crates/app-demo-core/Cargo.toml index d4b8081..a963715 100644 --- a/examples/app-demo/crates/app-demo-core/Cargo.toml +++ b/examples/app-demo/crates/app-demo-core/Cargo.toml @@ -15,4 +15,5 @@ futures = { workspace = true } serde = { workspace = true } [dev-dependencies] +async-trait = { workspace = true } serde_json = { workspace = true } diff --git a/examples/app-demo/crates/app-demo-core/src/handlers.rs b/examples/app-demo/crates/app-demo-core/src/handlers.rs index 62fc887..0049e26 100644 --- a/examples/app-demo/crates/app-demo-core/src/handlers.rs +++ b/examples/app-demo/crates/app-demo-core/src/handlers.rs @@ -1,12 +1,16 @@ use anyedge_core::action; use anyedge_core::body::Body; +use anyedge_core::context::RequestContext; use anyedge_core::error::EdgeError; use anyedge_core::extractor::{Headers, Json, Path}; -use anyedge_core::http::{self, Response, StatusCode}; +use anyedge_core::http::{self, Response, StatusCode, Uri}; +use anyedge_core::proxy::ProxyRequest; use anyedge_core::response::Text; use bytes::Bytes; use futures::{stream, StreamExt}; +const DEFAULT_PROXY_BASE: &str = "https://httpbin.org"; + #[derive(serde::Deserialize)] pub(crate) struct EchoParams { pub(crate) name: String, @@ -80,18 +84,72 @@ pub(crate) async fn list_routes() -> Result { Ok(response) } +#[derive(serde::Deserialize)] +struct ProxyPath { + #[serde(default)] + rest: String, +} + +pub(crate) async fn proxy_demo(ctx: RequestContext) -> Result { + let params: ProxyPath = ctx.path()?; + let proxy_handle = ctx.proxy_handle(); + let request = ctx.into_request(); + let target = build_proxy_target(¶ms.rest, request.uri())?; + let proxy_request = ProxyRequest::from_request(request, target); + if let Some(handle) = proxy_handle { + handle.forward(proxy_request).await + } else { + proxy_not_available_response() + } +} + +fn build_proxy_target(rest: &str, original_uri: &Uri) -> Result { + let base = std::env::var("API_BASE_URL").unwrap_or_else(|_| DEFAULT_PROXY_BASE.to_string()); + let mut target = base.trim_end_matches('/').to_string(); + let trimmed_rest = rest.trim_start_matches('/'); + if !trimmed_rest.is_empty() { + target.push('/'); + target.push_str(trimmed_rest); + } + + if let Some(query) = original_uri.query() { + if !query.is_empty() { + target.push('?'); + target.push_str(query); + } + } + + target + .parse::() + .map_err(|err| EdgeError::bad_request(format!("invalid proxy target URI: {err}"))) +} + +fn proxy_not_available_response() -> Result { + let body = Body::text( + "proxy example is not enabled for this adapter build; enable a proxy-capable adapter", + ); + http::response_builder() + .status(StatusCode::NOT_IMPLEMENTED) + .header("content-type", "text/plain; charset=utf-8") + .body(body) + .map_err(EdgeError::internal) +} + #[cfg(test)] mod tests { use super::*; use anyedge_core::body::Body; use anyedge_core::context::RequestContext; use anyedge_core::http::header::{HeaderName, HeaderValue}; - use anyedge_core::http::{request_builder, Method, StatusCode}; + use anyedge_core::http::{request_builder, Method, StatusCode, Uri}; use anyedge_core::params::PathParams; + use anyedge_core::proxy::{ProxyClient, ProxyHandle, ProxyResponse}; use anyedge_core::response::IntoResponse; use anyedge_core::router::DEFAULT_ROUTE_LISTING_PATH; + use async_trait::async_trait; use futures::{executor::block_on, StreamExt}; use std::collections::HashMap; + use std::env; #[test] fn root_returns_static_body() { @@ -170,6 +228,63 @@ mod tests { .any(|entry| { entry["method"] == "GET" && entry["path"] == "/echo/{name}" })); } + #[test] + fn build_proxy_target_merges_rest_and_query() { + env::set_var("API_BASE_URL", "https://example.com/api"); + let original = Uri::from_static("/proxy/status?foo=bar"); + let target = super::build_proxy_target("status/200", &original).expect("target uri"); + assert_eq!( + target.to_string(), + "https://example.com/api/status/200?foo=bar" + ); + env::remove_var("API_BASE_URL"); + } + + #[test] + fn proxy_demo_without_proxy_support_returns_placeholder() { + env::set_var("API_BASE_URL", "https://example.com/api"); + + let ctx = context_with_params("/proxy/status/200", &[("rest", "status/200")]); + let response = block_on(proxy_demo(ctx)).expect("response"); + assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); + + env::remove_var("API_BASE_URL"); + } + + struct TestProxyClient; + + #[async_trait(?Send)] + impl ProxyClient for TestProxyClient { + async fn send(&self, request: ProxyRequest) -> Result { + let (_method, uri, _headers, _body, _) = request.into_parts(); + assert!(uri.to_string().contains("status/201")); + Ok(ProxyResponse::new(StatusCode::CREATED, Body::empty())) + } + } + + #[test] + fn proxy_demo_uses_injected_proxy_handle() { + env::set_var("API_BASE_URL", "https://example.com/api"); + + let mut request = request_builder() + .method(Method::GET) + .uri("/proxy/status/201") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(ProxyHandle::with_client(TestProxyClient)); + + let mut params = HashMap::new(); + params.insert("rest".to_string(), "status/201".to_string()); + let ctx = RequestContext::new(request, PathParams::new(params)); + + let response = block_on(proxy_demo(ctx)).expect("response"); + assert_eq!(response.status(), StatusCode::CREATED); + + env::remove_var("API_BASE_URL"); + } + fn empty_context(path: &str) -> RequestContext { let request = request_builder() .method(Method::GET)