From 5bafa66de9d9dd0b8f6cd5014afd79585ff6a395 Mon Sep 17 00:00:00 2001 From: supreme2580 Date: Mon, 29 Jun 2026 15:01:49 +0100 Subject: [PATCH] feat: convert all blocking HTTP operations to async/await (issue #317) - Replace ureq with reqwest across stream.rs, registry.rs, templates.rs, node.rs, network.rs - Create shared reqwest client in src/utils/http_client.rs with connection pooling - Convert all HTTP-dependent command handlers to async - Make sandbox::LocalSorobanSandbox::new async - Un-ignore soroban.rs tests (no more reqwest blocking runtime conflict) - Remove ureq dependency from Cargo.toml - Remove reqwest "blocking" feature flag --- Cargo.lock | 111 +++-------------------------- Cargo.toml | 3 +- src/commands/monitor.rs | 6 +- src/commands/network.rs | 10 ++- src/commands/new.rs | 29 ++++---- src/commands/node.rs | 6 +- src/commands/registry.rs | 48 ++++++------- src/commands/security.rs | 10 +-- src/commands/shell.rs | 2 +- src/commands/template.rs | 60 ++++++++-------- src/utils/http_client.rs | 17 +++++ src/utils/mock_soroban.rs | 4 +- src/utils/mod.rs | 1 + src/utils/node.rs | 31 +++++---- src/utils/registry.rs | 142 +++++++++++++++++++------------------- src/utils/sandbox.rs | 4 +- src/utils/soroban.rs | 2 - src/utils/stream.rs | 24 ++++--- src/utils/templates.rs | 104 ++++++++++++++-------------- 19 files changed, 271 insertions(+), 343 deletions(-) create mode 100644 src/utils/http_client.rs diff --git a/Cargo.lock b/Cargo.lock index 544183bd..2925c6af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1060,12 +1060,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - [[package]] name = "futures-sink" version = "0.3.32" @@ -1085,9 +1079,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", - "futures-io", "futures-task", - "memchr", "pin-project-lite", "slab", ] @@ -2391,25 +2383,10 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 0.25.4", + "webpki-roots", "winreg", ] -[[package]] -name = "ring" -version = "0.16.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" -dependencies = [ - "cc", - "libc", - "once_cell", - "spin", - "untrusted 0.7.1", - "web-sys", - "winapi", -] - [[package]] name = "ring" version = "0.17.14" @@ -2420,7 +2397,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted 0.9.0", + "untrusted", "windows-sys 0.52.0", ] @@ -2477,8 +2454,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ "log", - "ring 0.17.14", - "rustls-webpki 0.101.7", + "ring", + "rustls-webpki", "sct", ] @@ -2491,24 +2468,14 @@ dependencies = [ "base64 0.21.7", ] -[[package]] -name = "rustls-webpki" -version = "0.100.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6a5fc258f1c1276dfe3016516945546e2d5383911efc0fc4f1cdc5df3a4ae3" -dependencies = [ - "ring 0.16.20", - "untrusted 0.7.1", -] - [[package]] name = "rustls-webpki" version = "0.101.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ - "ring 0.17.14", - "untrusted 0.9.0", + "ring", + "untrusted", ] [[package]] @@ -2590,8 +2557,8 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ - "ring 0.17.14", - "untrusted 0.9.0", + "ring", + "untrusted", ] [[package]] @@ -2801,12 +2768,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - [[package]] name = "spki" version = "0.7.3" @@ -2864,7 +2825,6 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "trezor-client", - "ureq", "urlencoding", "uuid", "wasm-bindgen", @@ -3352,36 +3312,12 @@ dependencies = [ "subtle", ] -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b11c96ac7ee530603dcdf68ed1557050f374ce55a5a07193ebf8cbc9f8927e9" -dependencies = [ - "base64 0.21.7", - "flate2", - "log", - "once_cell", - "rustls", - "rustls-webpki 0.100.3", - "serde", - "serde_json", - "url", - "webpki-roots 0.23.1", -] - [[package]] name = "url" version = "2.5.8" @@ -3637,37 +3573,12 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-roots" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b03058f88386e5ff5310d9111d53f48b17d732b401aeb83a8d5190f2ac459338" -dependencies = [ - "rustls-webpki 0.100.3", -] - [[package]] name = "webpki-roots" version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" version = "0.1.11" @@ -3677,12 +3588,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 158937d8..0bf81c1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,8 +49,7 @@ anyhow = "1.0" chrono = { version = "0.4", features = ["serde"] } rand = "0.8" ed25519-dalek = "=2.0.0" -ureq = { version = "=2.7.1", features = ["json"] } -reqwest = { version = "0.11", features = ["json", "rustls-tls", "blocking"], default-features = false } +reqwest = { version = "0.11", features = ["json", "rustls-tls"], default-features = false } tokio = { version = "1", features = ["full"] } ctrlc = "3.4" clap_complete = "=4.4.10" diff --git a/src/commands/monitor.rs b/src/commands/monitor.rs index 47f63fc7..612a290e 100644 --- a/src/commands/monitor.rs +++ b/src/commands/monitor.rs @@ -151,7 +151,7 @@ async fn monitor_contract( let mut printed_any = false; while running.load(Ordering::SeqCst) { - match stream.next_batch() { + match stream.next_batch().await { Ok(batch) => { for event in batch { let as_text = event.value.to_string(); @@ -178,7 +178,7 @@ async fn monitor_contract( } break; } - stream.sleep(); + stream.sleep().await; } Err(err) => { if !follow && !printed_any { @@ -188,7 +188,7 @@ async fn monitor_contract( "Event stream error: {}. Reconnecting with backoff…", err )); - stream.sleep_backoff(); + stream.sleep_backoff().await; } } } diff --git a/src/commands/network.rs b/src/commands/network.rs index a56af9b2..5ffaffba 100644 --- a/src/commands/network.rs +++ b/src/commands/network.rs @@ -1,4 +1,4 @@ -use crate::utils::{config, print as p}; +use crate::utils::{config, http_client, print as p}; use anyhow::Result; use clap::Subcommand; @@ -189,7 +189,8 @@ async fn test_network(network_name: Option) -> Result<()> { p::info(&format!("Horizon: {}", net_cfg.horizon_url)); // Test Horizon endpoint - match ureq::get(&format!("{}/health", net_cfg.horizon_url)).call() { + let client = http_client::get_client(); + match client.get(&format!("{}/health", net_cfg.horizon_url)).send().await { Ok(_) => { p::success("✓ Horizon endpoint is reachable"); } @@ -208,10 +209,7 @@ async fn test_network(network_name: Option) -> Result<()> { "params": [] }); - match ureq::post(soroban_url) - .set("Content-Type", "application/json") - .send_json(&req) - { + match client.post(soroban_url).json(&req).send().await { Ok(_) => { p::success("✓ Soroban RPC endpoint is reachable"); } diff --git a/src/commands/new.rs b/src/commands/new.rs index b4959ca2..c8dd9a68 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -52,13 +52,13 @@ pub async fn handle(cmd: NewCommands) -> Result<()> { force_refresh, } => { if let Some(query) = search { - return search_templates(&query); + return search_templates(&query).await; } let name = name.ok_or_else(|| { anyhow::anyhow!("A contract name is required unless --search is used") })?; if interactive { - scaffold_contract_interactive(name) + scaffold_contract_interactive(name).await } else { scaffold_contract( name, @@ -69,15 +69,15 @@ pub async fn handle(cmd: NewCommands) -> Result<()> { "none", true, force_refresh, - ) + ).await } } NewCommands::Dapp { name } => scaffold_dapp(name), } } -fn search_templates(query: &str) -> Result<()> { - let results = templates::search_templates(query, None)?; +async fn search_templates(query: &str) -> Result<()> { + let results = templates::search_templates(query, None).await?; p::header(&format!("Template search results for '{}'", query)); if results.is_empty() { @@ -110,7 +110,7 @@ struct ContractOptions { include_tests: bool, } -fn scaffold_contract_interactive(default_name: String) -> Result<()> { +async fn scaffold_contract_interactive(default_name: String) -> Result<()> { let theme = ColorfulTheme::default(); println!(); @@ -197,9 +197,10 @@ fn scaffold_contract_interactive(default_name: String) -> Result<()> { opts.include_tests, false, // interactive path never force-refreshes ) + .await } -fn scaffold_contract( +async fn scaffold_contract( name: String, template: String, source: &str, @@ -220,7 +221,7 @@ fn scaffold_contract( p::header(&format!("Scaffolding Soroban contract: {}", name)); println!(" Template: {}\n", template.cyan()); // Ensure selected template is compatible with current CLI version - let entry = templates::get_template(&template)?; + let entry = templates::get_template(&template).await?; match templates::check_template_compatibility(&entry) { templates::CompatibilityStatus::Compatible => {} templates::CompatibilityStatus::TooOld { @@ -270,7 +271,7 @@ fn scaffold_contract( "voting" => voting_template(&name), "nft" => nft_template(&name), _ => { - if let Some(custom) = templates::template_source_content(&template, force_refresh)? { + if let Some(custom) = templates::template_source_content(&template, force_refresh).await? { custom } else if template == "hello-world" { hello_world_template(&name, storage, include_tests) @@ -964,7 +965,7 @@ Source: `{source}` // ── Template Marketplace ────────────────────────────────────────────────────── -fn handle_template_search(query: &str, tags: Option<&str>) -> Result<()> { +async fn handle_template_search(query: &str, tags: Option<&str>) -> Result<()> { p::header("Template Marketplace — Search"); p::kv("Query", query); @@ -981,7 +982,7 @@ fn handle_template_search(query: &str, tags: Option<&str>) -> Result<()> { println!(); - let results = templates::search_templates(query, tag_list.as_deref())?; + let results = templates::search_templates(query, tag_list.as_deref()).await?; if results.is_empty() { p::info("No templates found matching your search."); @@ -1084,11 +1085,11 @@ fn install_step( } } -fn scaffold_from_marketplace(name: String, template_name: String) -> Result<()> { +async fn scaffold_from_marketplace(name: String, template_name: String) -> Result<()> { p::header(&format!("Scaffolding from Marketplace: {}", template_name)); // Get template from registry - let template = templates::get_template(&template_name).with_context(|| { + let template = templates::get_template(&template_name).await.with_context(|| { format!( "Template '{}' not found in the registry.\n • List templates with `starforge template list`.\n • Search with `starforge new contract --search {}`.", template_name, template_name @@ -1176,7 +1177,7 @@ fn scaffold_from_marketplace(name: String, template_name: String) -> Result<()> // Update download count (best-effort; failure here must not roll back a // successfully installed project). - if let Ok(mut registry) = templates::load_registry() { + if let Ok(mut registry) = templates::load_registry().await { if let Some(entry) = registry .templates .iter_mut() diff --git a/src/commands/node.rs b/src/commands/node.rs index fa18aeb3..a1422d71 100644 --- a/src/commands/node.rs +++ b/src/commands/node.rs @@ -15,11 +15,11 @@ pub enum NodeCommands { pub async fn handle(cmd: NodeCommands) -> Result<()> { match cmd { - NodeCommands::Start { port } => start(port), + NodeCommands::Start { port } => start(port).await, } } -fn start(port: u16) -> Result<()> { +async fn start(port: u16) -> Result<()> { p::header("Local Devnet"); p::step(1, 3, "Checking Docker…"); node::ensure_docker_available()?; @@ -27,7 +27,7 @@ fn start(port: u16) -> Result<()> { p::step(2, 3, &format!("Starting {}…", node::QUICKSTART_IMAGE)); let already_running = node::container_running().unwrap_or(false); - node::start_devnet(port)?; + node::start_devnet(port).await?; if already_running { p::info("Devnet container was already running; verified health."); } else { diff --git a/src/commands/registry.rs b/src/commands/registry.rs index 15c04b90..193b8597 100644 --- a/src/commands/registry.rs +++ b/src/commands/registry.rs @@ -115,10 +115,10 @@ pub async fn handle(cmd: RegistryCommands) -> Result<()> { verified, min_quality, limit, - } => search(query, tags, verified, min_quality, limit), - RegistryCommands::Info { name, version } => info(name, version), - RegistryCommands::Login { email } => login(email), - RegistryCommands::Signup { email, username } => signup(email, username), + } => search(query, tags, verified, min_quality, limit).await, + RegistryCommands::Info { name, version } => info(name, version).await, + RegistryCommands::Login { email } => login(email).await, + RegistryCommands::Signup { email, username } => signup(email, username).await, RegistryCommands::Logout => logout(), RegistryCommands::Publish { path, @@ -140,19 +140,19 @@ pub async fn handle(cmd: RegistryCommands) -> Result<()> { license, repository, homepage, - ), - RegistryCommands::Install { name, version } => install(name, version), + ).await, + RegistryCommands::Install { name, version } => install(name, version).await, RegistryCommands::Review { name, rating, comment, - } => review(name, rating, comment), + } => review(name, rating, comment).await, RegistryCommands::Status => status(), RegistryCommands::Config { url } => config(url), } } -fn search( +async fn search( query: String, tags: Option, verified: bool, @@ -179,7 +179,7 @@ fn search( offset: Some(0), }; - let resp = client.search(&req)?; + let resp = client.search(&req).await?; if resp.results.is_empty() { p::info(&format!("No templates found matching '{}'", query)); @@ -218,7 +218,7 @@ fn search( Ok(()) } -fn info(name: String, version: Option) -> Result<()> { +async fn info(name: String, version: Option) -> Result<()> { p::info(&format!( "Fetching template info for '{}'{}", name, @@ -231,7 +231,7 @@ fn info(name: String, version: Option) -> Result<()> { let config = registry::load_registry_config()?; let client = registry::RegistryClient::new(config.url, config.token); - let tpl = client.get_template(&name, version.as_deref())?; + let tpl = client.get_template(&name, version.as_deref()).await?; println!(); println!( @@ -262,7 +262,7 @@ fn info(name: String, version: Option) -> Result<()> { Ok(()) } -fn login(email: Option) -> Result<()> { +async fn login(email: Option) -> Result<()> { let email = email.unwrap_or_else(|| { dialoguer::Input::new() .with_prompt("Email") @@ -283,7 +283,7 @@ fn login(email: Option) -> Result<()> { let config = registry::load_registry_config()?; let client = registry::RegistryClient::new(config.url.clone(), None); - let resp = client.authenticate(&email, &password)?; + let resp = client.authenticate(&email, &password).await?; if !resp.success { anyhow::bail!("Authentication failed: {}", resp.message); @@ -308,7 +308,7 @@ fn login(email: Option) -> Result<()> { Ok(()) } -fn signup(email: Option, username: Option) -> Result<()> { +async fn signup(email: Option, username: Option) -> Result<()> { let email = email.unwrap_or_else(|| { dialoguer::Input::new() .with_prompt("Email") @@ -344,7 +344,7 @@ fn signup(email: Option, username: Option) -> Result<()> { let config = registry::load_registry_config()?; let client = registry::RegistryClient::new(config.url.clone(), None); - let resp = client.signup(&email, &username, &password)?; + let resp = client.signup(&email, &username, &password).await?; if !resp.success { anyhow::bail!("Signup failed: {}", resp.message); @@ -378,7 +378,7 @@ fn logout() -> Result<()> { Ok(()) } -fn publish( +async fn publish( path: PathBuf, name: Option, description: Option, @@ -447,7 +447,7 @@ fn publish( p::info("Publishing to remote registry..."); let client = registry::RegistryClient::new(config.url, config.token); - let resp = client.publish(&publish_req)?; + let resp = client.publish(&publish_req).await?; if !resp.success { anyhow::bail!("Publish failed: {}", resp.message); @@ -465,7 +465,7 @@ fn publish( Ok(()) } -fn install(name: String, version: Option) -> Result<()> { +async fn install(name: String, version: Option) -> Result<()> { p::info(&format!( "Downloading template '{}'{}", name, @@ -478,10 +478,10 @@ fn install(name: String, version: Option) -> Result<()> { let config = registry::load_registry_config()?; let client = registry::RegistryClient::new(config.url, config.token); - let tpl = client.get_template(&name, version.as_deref())?; + let tpl = client.get_template(&name, version.as_deref()).await?; // Download archive - let archive_bytes = client.download_template(&tpl.download_url)?; + let archive_bytes = client.download_template(&tpl.download_url).await?; // Save to temp and extract let temp_dir = std::env::temp_dir().join(format!("starforge-dl-{}", uuid::Uuid::new_v4())); @@ -505,7 +505,7 @@ fn install(name: String, version: Option) -> Result<()> { tpl.version, None, None, - )?; + ).await?; p::success(&format!("Template '{}' installed successfully", name)); @@ -515,7 +515,7 @@ fn install(name: String, version: Option) -> Result<()> { Ok(()) } -fn review(name: String, rating: u8, comment: Option) -> Result<()> { +async fn review(name: String, rating: u8, comment: Option) -> Result<()> { if !(1..=5).contains(&rating) { anyhow::bail!("Rating must be between 1 and 5"); } @@ -528,8 +528,8 @@ fn review(name: String, rating: u8, comment: Option) -> Result<()> { p::info("Posting review..."); let client = registry::RegistryClient::new(config.url, config.token); - let tpl = client.get_template(&name, None)?; - let resp = client.post_review(&tpl.id, rating, comment.as_deref())?; + let tpl = client.get_template(&name, None).await?; + let resp = client.post_review(&tpl.id, rating, comment.as_deref()).await?; if !resp.success { anyhow::bail!("Failed to post review: {}", resp.message); diff --git a/src/commands/security.rs b/src/commands/security.rs index 40a7b996..c3cee299 100644 --- a/src/commands/security.rs +++ b/src/commands/security.rs @@ -118,7 +118,7 @@ pub async fn handle(cmd: SecurityCommands) -> Result<()> { SecurityCommands::Checklist(args) => handle_checklist(args), SecurityCommands::Validate(args) => handle_validate(args), SecurityCommands::Report(args) => handle_report(args), - SecurityCommands::Monitor(args) => handle_monitor(args), + SecurityCommands::Monitor(args) => handle_monitor(args).await, SecurityCommands::Incident(args) => handle_incident(args), SecurityCommands::Audit(args) => handle_audit(args), } @@ -219,7 +219,7 @@ fn handle_report(args: ReportArgs) -> Result<()> { Ok(()) } -fn handle_monitor(args: SecurityMonitorArgs) -> Result<()> { +async fn handle_monitor(args: SecurityMonitorArgs) -> Result<()> { config::validate_contract_id(&args.contract)?; config::validate_network(&args.network)?; @@ -246,7 +246,7 @@ fn handle_monitor(args: SecurityMonitorArgs) -> Result<()> { fs::create_dir_all(&report_dir)?; while running.load(Ordering::SeqCst) { - match stream.next_batch() { + match stream.next_batch().await { Ok(batch) => { for event in batch { let security_events = evaluate_event( @@ -287,11 +287,11 @@ fn handle_monitor(args: SecurityMonitorArgs) -> Result<()> { if !args.follow { break; } - stream.sleep(); + stream.sleep().await; } Err(err) => { notifications::warn(&format!("Stream error: {}. Retrying…", err)); - stream.sleep_backoff(); + stream.sleep_backoff().await; } } } diff --git a/src/commands/shell.rs b/src/commands/shell.rs index f348003a..6d142a28 100644 --- a/src/commands/shell.rs +++ b/src/commands/shell.rs @@ -28,7 +28,7 @@ pub async fn handle(args: ShellArgs) -> Result<()> { p::separator(); println!(); - let sandbox = LocalSorobanSandbox::new(&args.contract, &args.network)?; + let sandbox = LocalSorobanSandbox::new(&args.contract, &args.network).await?; let runner = ShellRunner { sandbox }; let repl_options = repl::ReplOptions { history_enabled: !args.no_history, diff --git a/src/commands/template.rs b/src/commands/template.rs index 520a2ec4..a034f7dd 100644 --- a/src/commands/template.rs +++ b/src/commands/template.rs @@ -155,7 +155,7 @@ pub async fn handle(cmd: TemplateCommands) -> Result<()> { version, cli_version_min, cli_version_max, - ), + ).await, TemplateCommands::Publish { path, name, @@ -182,30 +182,30 @@ pub async fn handle(cmd: TemplateCommands) -> Result<()> { repository, homepage, documentation, - ), - TemplateCommands::List => list(), + ).await, + TemplateCommands::List => list().await, TemplateCommands::Search { query, tags, verified, min_quality, refresh, - } => search(query, tags, verified, min_quality, refresh), - TemplateCommands::Show { name } => show(name), - TemplateCommands::Remove { name, purge } => remove(name, purge), + } => search(query, tags, verified, min_quality, refresh).await, + TemplateCommands::Show { name } => show(name).await, + TemplateCommands::Remove { name, purge } => remove(name, purge).await, TemplateCommands::Init => init(), - TemplateCommands::Info { name } => info(name), + TemplateCommands::Info { name } => info(name).await, TemplateCommands::Install { source, name, version, force, - } => install(source, name, version, force), - TemplateCommands::Update { name, all } => update(name, all), + } => install(source, name, version, force).await, + TemplateCommands::Update { name, all } => update(name, all).await, } } -fn import( +async fn import( path: PathBuf, name: Option, description: Option, @@ -228,13 +228,13 @@ fn import( None, None, None, - )?; + ).await?; p::header("Template Import"); p::info("Template package imported into the local registry."); Ok(()) } -fn publish( +async fn publish( path: PathBuf, name: Option, description: Option, @@ -287,8 +287,8 @@ fn publish( repository, homepage, documentation, - )?; - let template = templates::get_template(&name)?; + ).await?; + let template = templates::get_template(&name).await?; p::header("Template Publish"); p::success("Template registered successfully"); @@ -311,10 +311,10 @@ fn publish( Ok(()) } -fn list() -> Result<()> { +async fn list() -> Result<()> { use crate::utils::templates::{check_template_compatibility, CompatibilityStatus}; - let registry = templates::load_registry()?; + let registry = templates::load_registry().await?; p::header("Template Registry"); if registry.templates.is_empty() { p::info("No templates found. Publish one with: starforge template publish "); @@ -355,7 +355,7 @@ fn list() -> Result<()> { Ok(()) } -fn search( +async fn search( query: String, tags: Option, verified: bool, @@ -379,11 +379,11 @@ fn search( // Load registry, optionally forcing a refresh of the remote copy. let results = if refresh { std::env::set_var("STARFORGE_TEMPLATE_REGISTRY_FORCE_REFRESH", "1"); - let res = templates::search_templates_ranked(&query, &filters); + let res = templates::search_templates_ranked(&query, &filters).await; std::env::remove_var("STARFORGE_TEMPLATE_REGISTRY_FORCE_REFRESH"); res? } else { - templates::search_templates_ranked(&query, &filters)? + templates::search_templates_ranked(&query, &filters).await? }; let heading = if query.trim().is_empty() { @@ -460,10 +460,10 @@ fn search( Ok(()) } -fn show(name: String) -> Result<()> { +async fn show(name: String) -> Result<()> { use crate::utils::templates::{check_template_compatibility, CompatibilityStatus}; - let template = templates::get_template(&name)?; + let template = templates::get_template(&name).await?; p::header(&format!("Template: {}", template.name)); p::kv("Version", &template.version); p::kv("Description", &template.description); @@ -543,8 +543,8 @@ fn print_quality_signals(template: &templates::TemplateEntry) { } } -fn remove(name: String, purge: bool) -> Result<()> { - templates::remove_template(&name, purge)?; +async fn remove(name: String, purge: bool) -> Result<()> { + templates::remove_template(&name, purge).await?; if purge { p::success(&format!("Template '{}' and all local assets removed", name)); @@ -562,10 +562,10 @@ fn init() -> Result<()> { Ok(()) } -fn info(name: String) -> Result<()> { +async fn info(name: String) -> Result<()> { use crate::utils::templates::{check_template_compatibility, CompatibilityStatus}; - let template = templates::get_template(&name)?; + let template = templates::get_template(&name).await?; p::header(&format!("Template Info: {}", template.name)); p::separator(); @@ -662,7 +662,7 @@ fn info(name: String) -> Result<()> { Ok(()) } -fn install( +async fn install( source: String, name: Option, version: Option, @@ -679,7 +679,7 @@ fn install( println!(); p::step(1, 2, "Resolving and fetching template..."); - let entry = templates::install_template(&source, name.as_deref(), version.as_deref(), force)?; + let entry = templates::install_template(&source, name.as_deref(), version.as_deref(), force).await?; p::step(2, 2, "Registering in local registry..."); println!(); @@ -697,11 +697,11 @@ fn install( Ok(()) } -fn update(name: Option, all: bool) -> Result<()> { +async fn update(name: Option, all: bool) -> Result<()> { if all { p::header("Template Update — All"); p::step(1, 1, "Updating all git-sourced templates..."); - let results = templates::update_all_installed_templates()?; + let results = templates::update_all_installed_templates().await?; if results.is_empty() { p::info("No git-sourced templates are installed."); @@ -728,7 +728,7 @@ fn update(name: Option, all: bool) -> Result<()> { p::header(&format!("Template Update: {}", name)); p::step(1, 1, "Re-fetching from source..."); - templates::update_installed_template(&name)?; + templates::update_installed_template(&name).await?; println!(); p::success(&format!("Template '{}' updated", name)); Ok(()) diff --git a/src/utils/http_client.rs b/src/utils/http_client.rs new file mode 100644 index 00000000..c2fde17e --- /dev/null +++ b/src/utils/http_client.rs @@ -0,0 +1,17 @@ +use once_cell::sync::Lazy; +use reqwest::Client; +use std::time::Duration; + +static HTTP_CLIENT: Lazy = Lazy::new(|| { + Client::builder() + .timeout(Duration::from_secs(30)) + .pool_max_idle_per_host(32) + .pool_idle_timeout(Duration::from_secs(90)) + .tcp_keepalive(Duration::from_secs(60)) + .build() + .expect("Failed to create HTTP client") +}); + +pub fn get_client() -> &'static Client { + &HTTP_CLIENT +} diff --git a/src/utils/mock_soroban.rs b/src/utils/mock_soroban.rs index 9d8f4f4b..bc0dc2f3 100644 --- a/src/utils/mock_soroban.rs +++ b/src/utils/mock_soroban.rs @@ -11,8 +11,8 @@ pub fn validate_wasm(bytes: &[u8]) -> Result<()> { Ok(()) } -pub fn ensure_docker_sandbox() -> Result<()> { - node::ensure_running(8000) +pub async fn ensure_docker_sandbox() -> Result<()> { + node::ensure_running(8000).await } pub fn stop_docker_sandbox() -> Result<()> { diff --git a/src/utils/mod.rs b/src/utils/mod.rs index ce941859..f6c75938 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -10,6 +10,7 @@ pub mod deploy_orchestrator; pub mod docs; pub mod hardware_wallet; pub mod horizon; +pub mod http_client; pub mod logging; pub mod mnemonic; pub mod mock_soroban; diff --git a/src/utils/node.rs b/src/utils/node.rs index be602c40..445d886b 100644 --- a/src/utils/node.rs +++ b/src/utils/node.rs @@ -1,6 +1,6 @@ +use crate::utils::http_client; use anyhow::{Context, Result}; use std::process::Command; -use std::thread; use std::time::Duration; pub const CONTAINER_NAME: &str = "starforge-devnet"; @@ -89,16 +89,16 @@ fn start_existing_container() -> Result<()> { Ok(()) } -pub fn wait_for_healthy(host_port: u16) -> Result<()> { +pub async fn wait_for_healthy(host_port: u16) -> Result<()> { let root_url = format!("http://127.0.0.1:{}/", host_port); - let rpc_url = rpc_url(host_port); + let soroban_rpc_url = rpc_url(host_port); for attempt in 1..=HEALTH_ATTEMPTS { - if probe_url(&root_url) || probe_url(&rpc_url) { + if probe_url(&root_url).await || probe_url(&soroban_rpc_url).await { return Ok(()); } if attempt < HEALTH_ATTEMPTS { - thread::sleep(HEALTH_INTERVAL); + tokio::time::sleep(HEALTH_INTERVAL).await; } } @@ -109,20 +109,21 @@ pub fn wait_for_healthy(host_port: u16) -> Result<()> { ) } -fn probe_url(url: &str) -> bool { - ureq::get(url) - .timeout(Duration::from_secs(3)) - .call() - .map(|r| r.status() < 500) +async fn probe_url(url: &str) -> bool { + http_client::get_client() + .get(url) + .send() + .await + .map(|r| r.status().as_u16() < 500) .unwrap_or(false) } /// Start (or reuse) the local quickstart devnet container and wait until healthy. -pub fn start_devnet(host_port: u16) -> Result<()> { +pub async fn start_devnet(host_port: u16) -> Result<()> { ensure_docker_available()?; if container_running()? { - wait_for_healthy(host_port)?; + wait_for_healthy(host_port).await?; return Ok(()); } @@ -132,12 +133,12 @@ pub fn start_devnet(host_port: u16) -> Result<()> { run_container(host_port)?; } - wait_for_healthy(host_port) + wait_for_healthy(host_port).await } /// Ensure the devnet is running (used by shell / docker-testnet workflows). -pub fn ensure_running(host_port: u16) -> Result<()> { - start_devnet(host_port) +pub async fn ensure_running(host_port: u16) -> Result<()> { + start_devnet(host_port).await } pub fn stop_devnet() -> Result<()> { diff --git a/src/utils/registry.rs b/src/utils/registry.rs index 1e244c9e..c5623459 100644 --- a/src/utils/registry.rs +++ b/src/utils/registry.rs @@ -1,3 +1,4 @@ +use crate::utils::http_client; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::fs; @@ -168,136 +169,139 @@ impl RegistryClient { } /// Helper to make authenticated HTTP requests. - fn build_headers(&self) -> Vec<(&'static str, String)> { - let mut headers = vec![("Content-Type", "application/json".to_string())]; + fn build_headers(&self) -> Vec<(String, String)> { + let mut headers = vec![("Content-Type".to_string(), "application/json".to_string())]; if let Some(ref token) = self.token { - headers.push(("Authorization", format!("Bearer {}", token))); + headers.push(("Authorization".to_string(), format!("Bearer {}", token))); } headers } /// Search the remote registry. - pub fn search(&self, req: &SearchRequest) -> Result { + pub async fn search(&self, req: &SearchRequest) -> Result { let url = format!("{}/api/templates/search", self.registry_url); - let payload = serde_json::to_string(req)?; + let client = http_client::get_client(); - let resp = ureq::post(&url) - .set("Content-Type", "application/json") - .send_string(&payload) + let mut http_req = client.post(&url).json(req); + for (key, value) in self.build_headers() { + http_req = http_req.header(&key, &value); + } + + let resp = http_req + .send() + .await .with_context(|| format!("Failed to search remote registry at {}", url))?; if resp.status() != 200 { - anyhow::bail!( - "Search failed with status {}: {}", - resp.status(), - resp.into_string().unwrap_or_default() - ); + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Search failed with status {}: {}", status, body); } - let result: SearchResponse = resp.into_json()?; + let result: SearchResponse = resp.json().await?; Ok(result) } /// Get details of a specific template. - pub fn get_template(&self, name: &str, version: Option<&str>) -> Result { + pub async fn get_template(&self, name: &str, version: Option<&str>) -> Result { let version_param = version.unwrap_or("latest"); let url = format!( "{}/api/templates/{}/{}", self.registry_url, name, version_param ); - let resp = ureq::get(&url) - .call() + let resp = http_client::get_client() + .get(&url) + .send() + .await .with_context(|| format!("Failed to fetch template from remote registry: {}", url))?; if resp.status() != 200 { - anyhow::bail!( - "Template not found with status {}: {}", - resp.status(), - resp.into_string().unwrap_or_default() - ); + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Template not found with status {}: {}", status, body); } - let result: RemoteTemplateEntry = resp.into_json()?; + let result: RemoteTemplateEntry = resp.json().await?; Ok(result) } /// Publish a template to the remote registry. - pub fn publish(&self, req: &PublishTemplateRequest) -> Result { + pub async fn publish(&self, req: &PublishTemplateRequest) -> Result { let url = format!("{}/api/templates/publish", self.registry_url); - let payload = serde_json::to_string(req)?; - - let mut request = ureq::post(&url).set("Content-Type", "application/json"); + let client = http_client::get_client(); - if let Some(ref token) = self.token { - request = request.set("Authorization", &format!("Bearer {}", token)); + let mut http_req = client.post(&url).json(req); + for (key, value) in self.build_headers() { + http_req = http_req.header(&key, &value); } - let resp = request - .send_string(&payload) + let resp = http_req + .send() + .await .with_context(|| format!("Failed to publish template to {}", url))?; - let result: PublishTemplateResponse = resp.into_json()?; + let result: PublishTemplateResponse = resp.json().await?; Ok(result) } /// Authenticate with the remote registry. - pub fn authenticate(&self, email: &str, password: &str) -> Result { + pub async fn authenticate(&self, email: &str, password: &str) -> Result { let url = format!("{}/api/auth/login", self.registry_url); let req = AuthRequest { email: email.to_string(), password: password.to_string(), }; - let payload = serde_json::to_string(&req)?; - let resp = ureq::post(&url) - .set("Content-Type", "application/json") - .send_string(&payload) + let resp = http_client::get_client() + .post(&url) + .json(&req) + .send() + .await .with_context(|| format!("Failed to authenticate with remote registry at {}", url))?; if resp.status() != 200 { - anyhow::bail!( - "Authentication failed with status {}: {}", - resp.status(), - resp.into_string().unwrap_or_default() - ); + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Authentication failed with status {}: {}", status, body); } - let result: AuthResponse = resp.into_json()?; + let result: AuthResponse = resp.json().await?; Ok(result) } /// Sign up for a new registry account. - pub fn signup(&self, email: &str, username: &str, password: &str) -> Result { + pub async fn signup(&self, email: &str, username: &str, password: &str) -> Result { let url = format!("{}/api/auth/signup", self.registry_url); let req = serde_json::json!({ "email": email, "username": username, "password": password }); - let payload = serde_json::to_string(&req)?; - let resp = ureq::post(&url) - .set("Content-Type", "application/json") - .send_string(&payload) + let resp = http_client::get_client() + .post(&url) + .json(&req) + .send() + .await .with_context(|| format!("Failed to sign up for remote registry at {}", url))?; if resp.status() != 201 { - anyhow::bail!( - "Signup failed with status {}: {}", - resp.status(), - resp.into_string().unwrap_or_default() - ); + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Signup failed with status {}: {}", status, body); } - let result: AuthResponse = resp.into_json()?; + let result: AuthResponse = resp.json().await?; Ok(result) } /// Download a template archive from the remote registry. - pub fn download_template(&self, download_url: &str) -> Result> { - let resp = ureq::get(download_url) - .call() + pub async fn download_template(&self, download_url: &str) -> Result> { + let resp = http_client::get_client() + .get(download_url) + .send() + .await .with_context(|| format!("Failed to download template from {}", download_url))?; if resp.status() != 200 { @@ -308,14 +312,12 @@ impl RegistryClient { ); } - let mut reader = resp.into_reader(); - let mut buffer = Vec::new(); - std::io::Read::read_to_end(&mut reader, &mut buffer)?; - Ok(buffer) + let bytes = resp.bytes().await?; + Ok(bytes.to_vec()) } /// Post a review/rating for a template. - pub fn post_review( + pub async fn post_review( &self, template_id: &str, rating: u8, @@ -330,19 +332,19 @@ impl RegistryClient { rating, comment: comment.map(str::to_string), }; - let payload = serde_json::to_string(&req)?; + let client = http_client::get_client(); - let mut request = ureq::post(&url).set("Content-Type", "application/json"); - - if let Some(ref token) = self.token { - request = request.set("Authorization", &format!("Bearer {}", token)); + let mut http_req = client.post(&url).json(&req); + for (key, value) in self.build_headers() { + http_req = http_req.header(&key, &value); } - let resp = request - .send_string(&payload) + let resp = http_req + .send() + .await .with_context(|| format!("Failed to post review to {}", url))?; - let result: ReviewResponse = resp.into_json()?; + let result: ReviewResponse = resp.json().await?; Ok(result) } } diff --git a/src/utils/sandbox.rs b/src/utils/sandbox.rs index 142396ea..de6717a1 100644 --- a/src/utils/sandbox.rs +++ b/src/utils/sandbox.rs @@ -10,14 +10,14 @@ pub struct LocalSorobanSandbox { } impl LocalSorobanSandbox { - pub fn new>(wasm_path: P, network: &str) -> Result { + pub async fn new>(wasm_path: P, network: &str) -> Result { let wasm_path = wasm_path.as_ref().to_path_buf(); if !wasm_path.exists() { anyhow::bail!("Contract wasm not found: {}", wasm_path.display()); } if network == "docker-testnet" { - mock_soroban::ensure_docker_sandbox()?; + mock_soroban::ensure_docker_sandbox().await?; } Ok(Self { diff --git a/src/utils/soroban.rs b/src/utils/soroban.rs index c9cb1a63..bc544866 100644 --- a/src/utils/soroban.rs +++ b/src/utils/soroban.rs @@ -925,7 +925,6 @@ mod tests { } #[test] - #[ignore = "reqwest blocking runtime conflict with current_thread tokio runtime"] fn check_soroban_rpc_url_reports_healthy_endpoint() { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -946,7 +945,6 @@ mod tests { } #[test] - #[ignore = "reqwest blocking runtime conflict with current_thread tokio runtime"] fn check_soroban_rpc_url_rejects_error_response() { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() diff --git a/src/utils/stream.rs b/src/utils/stream.rs index 6277fac2..a68e33e7 100644 --- a/src/utils/stream.rs +++ b/src/utils/stream.rs @@ -1,7 +1,7 @@ +use crate::utils::http_client; use anyhow::{Context, Result}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use serde::Deserialize; -use std::thread; use std::time::Duration; use stellar_xdr::curr::{Limited, Limits, ScSymbol, ScVal, WriteXdr}; @@ -90,7 +90,7 @@ impl SorobanEventStream { self } - pub fn next_batch(&mut self) -> Result> { + pub async fn next_batch(&mut self) -> Result> { let filter = self.build_rpc_filter(); let request = serde_json::json!({ "jsonrpc": "2.0", @@ -105,11 +105,15 @@ impl SorobanEventStream { } }); - let response: SorobanGetEventsResponse = ureq::post(&self.rpc_url) - .set("Content-Type", "application/json") - .send_json(&request) + let client = http_client::get_client(); + let response: SorobanGetEventsResponse = client + .post(&self.rpc_url) + .json(&request) + .send() + .await .with_context(|| format!("Soroban RPC request to {} failed", self.rpc_url))? - .into_json() + .json() + .await .with_context(|| "Failed to decode Soroban getEvents response")?; if let Some(error) = response.error { @@ -138,12 +142,12 @@ impl SorobanEventStream { Ok(events) } - pub fn sleep(&self) { - thread::sleep(self.poll_interval); + pub async fn sleep(&self) { + tokio::time::sleep(self.poll_interval).await; } - pub fn sleep_backoff(&mut self) { - thread::sleep(self.backoff.next_delay()); + pub async fn sleep_backoff(&mut self) { + tokio::time::sleep(self.backoff.next_delay()).await; } fn build_rpc_filter(&self) -> serde_json::Value { diff --git a/src/utils/templates.rs b/src/utils/templates.rs index c0839984..7895680d 100644 --- a/src/utils/templates.rs +++ b/src/utils/templates.rs @@ -1,3 +1,4 @@ +use crate::utils::http_client; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::fs; @@ -514,8 +515,8 @@ pub fn fetch_template_cached(entry: &TemplateEntry, force_refresh: bool) -> Resu /// caching it if necessary. /// /// Returns `None` when the template name is not found in the registry. -pub fn template_source_content(name: &str, force_refresh: bool) -> Result> { - let registry = load_registry()?; +pub async fn template_source_content(name: &str, force_refresh: bool) -> Result> { + let registry = load_registry().await?; let entry = match registry.templates.into_iter().find(|t| t.name == name) { Some(e) => e, None => return Ok(None), @@ -532,7 +533,7 @@ pub fn template_source_content(name: &str, force_refresh: bool) -> Result