diff --git a/Cargo.lock b/Cargo.lock index 21df05973..7ce514d54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -779,6 +779,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" +[[package]] +name = "colored" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8" +dependencies = [ + "lazy_static", + "windows-sys 0.48.0", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -2859,6 +2869,7 @@ dependencies = [ "http 1.1.0", "http-body 1.0.1", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -3528,6 +3539,30 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "mockito" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b34bd91b9e5c5b06338d392463e1318d683cf82ec3d3af4014609be6e2108d" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-util", + "http 1.1.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.4.1", + "hyper-util", + "log", + "rand", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + [[package]] name = "native-tls" version = "0.2.12" @@ -3693,15 +3728,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" -[[package]] -name = "openssl-src" -version = "300.3.1+3.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7259953d42a81bf137fbbd73bd30a8e1914d6dce43c2b90ed575783a22608b91" -dependencies = [ - "cc", -] - [[package]] name = "openssl-sys" version = "0.9.103" @@ -3710,7 +3736,6 @@ checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" dependencies = [ "cc", "libc", - "openssl-src", "pkg-config", "vcpkg", ] @@ -4308,6 +4333,7 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2 0.4.6", @@ -4336,10 +4362,12 @@ dependencies = [ "sync_wrapper 1.0.1", "tokio", "tokio-rustls 0.26.0", + "tokio-util", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots 0.26.3", "windows-registry", @@ -4521,7 +4549,6 @@ version = "0.23.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c58f8c84392efc0a126acce10fa59ff7b3d2ac06ab451a33f2741989b806b044" dependencies = [ - "log", "once_cell", "ring", "rustls-pki-types", @@ -5133,21 +5160,19 @@ dependencies = [ "heck 0.5.0", "hex", "home", - "http 0.2.12", "humantime", - "hyper 0.14.30", - "hyper-tls", "itertools 0.10.5", "jsonrpsee-core", "jsonrpsee-http-client", + "mockito", "num-bigint", "open", - "openssl", "pathdiff", "phf", "predicates 2.1.5", "rand", "regex", + "reqwest 0.12.7", "rpassword", "rust-embed", "semver", @@ -5183,7 +5208,6 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "ulid", - "ureq", "url", "walkdir", "wasm-opt", @@ -6341,24 +6365,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b74fc6b57825be3373f7054754755f03ac3a8f5d70015ccad699ba2029956f4a" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "once_cell", - "rustls 0.23.12", - "rustls-pki-types", - "serde", - "serde_json", - "url", - "webpki-roots 0.26.3", -] - [[package]] name = "url" version = "2.5.2" @@ -6547,6 +6553,19 @@ dependencies = [ "cxx-build", ] +[[package]] +name = "wasm-streams" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmi_arena" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 4cf12b4d9..7282ff497 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,7 +94,6 @@ termcolor_output = "1.0.1" ed25519-dalek = ">= 2.1.1" # networking -http = "1.0.0" jsonrpsee-http-client = "0.20.1" jsonrpsee-core = "0.20.1" tokio = "1.28.1" diff --git a/cmd/soroban-cli/Cargo.toml b/cmd/soroban-cli/Cargo.toml index 5f4cc7c5a..646e8a92e 100644 --- a/cmd/soroban-cli/Cargo.toml +++ b/cmd/soroban-cli/Cargo.toml @@ -74,14 +74,18 @@ wasmparser = { workspace = true } sha2 = { workspace = true } csv = "1.1.6" ed25519-dalek = { workspace = true } +reqwest = { version = "0.12.7", default-features = false, features = [ + "rustls-tls", + "http2", + "json", + "blocking", + "stream", +] } jsonrpsee-http-client = "0.20.1" jsonrpsee-core = "0.20.1" -hyper = "0.14.27" -hyper-tls = "0.5" -http = "0.2.9" regex = "1.6.0" wasm-opt = { version = "0.114.0", optional = true } -chrono = { version = "0.4.27", features = ["serde"]} +chrono = { version = "0.4.27", features = ["serde"] } rpassword = "7.2.0" dirs = "4.0.0" toml = "0.5.9" @@ -107,13 +111,12 @@ gix = { version = "0.58.0", default-features = false, features = [ "blocking-http-transport-reqwest-rust-tls", "worktree-mutation", ] } -ureq = { version = "2.9.1", features = ["json"] } -async-compression = { version = "0.4.12", features = [ "tokio", "gzip" ] } +async-compression = { version = "0.4.12", features = ["tokio", "gzip"] } tempfile = "3.8.1" toml_edit = "0.21.0" rust-embed = { version = "8.2.0", features = ["debug-embed"] } -bollard = { workspace=true } +bollard = { workspace = true } futures-util = "0.3.30" futures = "0.3.30" home = "0.5.9" @@ -127,15 +130,10 @@ fqdn = "0.3.12" open = "5.3.0" url = "2.5.2" -# For hyper-tls -[target.'cfg(unix)'.dependencies] -openssl = { version = "=0.10.55", features = ["vendored"] } - [build-dependencies] crate-git-revision = "0.0.4" serde.workspace = true thiserror.workspace = true -ureq = { version = "2.9.1", features = ["json"] } [dev-dependencies] @@ -143,3 +141,4 @@ assert_cmd = "2.0.4" assert_fs = "1.0.7" predicates = "2.1.5" walkdir = "2.5.0" +mockito = "1.5.0" diff --git a/cmd/soroban-cli/src/cli.rs b/cmd/soroban-cli/src/cli.rs index efed1b63b..5470562db 100644 --- a/cmd/soroban-cli/src/cli.rs +++ b/cmd/soroban-cli/src/cli.rs @@ -1,6 +1,5 @@ use clap::CommandFactory; use dotenvy::dotenv; -use std::thread; use tracing_subscriber::{fmt, EnvFilter}; use crate::upgrade_check::upgrade_check; @@ -75,8 +74,8 @@ pub async fn main() { // Spawn a thread to check if a new version exists. // It depends on logger, so we need to place it after // the code block that initializes the logger. - thread::spawn(move || { - upgrade_check(root.global_args.quiet); + tokio::spawn(async move { + upgrade_check(root.global_args.quiet).await; }); let printer = print::Print::new(root.global_args.quiet); diff --git a/cmd/soroban-cli/src/commands/contract/init.rs b/cmd/soroban-cli/src/commands/contract/init.rs index 18938d001..fd4cf483a 100644 --- a/cmd/soroban-cli/src/commands/contract/init.rs +++ b/cmd/soroban-cli/src/commands/contract/init.rs @@ -19,8 +19,8 @@ use std::{ sync::atomic::AtomicBool, }; use toml_edit::{Document, TomlError}; -use ureq::get; +use crate::utils::http; use crate::{commands::global, print}; const SOROBAN_EXAMPLES_URL: &str = "https://github.com/stellar/soroban-examples.git"; @@ -261,7 +261,7 @@ impl Runner { } fn check_internet_connection() -> bool { - if let Ok(_req) = get(GITHUB_URL).call() { + if let Ok(_req) = http::blocking_client().get(GITHUB_URL).send() { return true; } diff --git a/cmd/soroban-cli/src/commands/global.rs b/cmd/soroban-cli/src/commands/global.rs index 9148e30cc..be883b6fd 100644 --- a/cmd/soroban-cli/src/commands/global.rs +++ b/cmd/soroban-cli/src/commands/global.rs @@ -1,11 +1,24 @@ -use clap::arg; +use clap::{ + arg, + builder::styling::{AnsiColor, Effects, Styles}, +}; use std::path::PathBuf; use super::config; +const USAGE_STYLES: Styles = Styles::styled() + .header(AnsiColor::Green.on_default().effects(Effects::BOLD)) + .usage(AnsiColor::Green.on_default().effects(Effects::BOLD)) + .literal(AnsiColor::Cyan.on_default().effects(Effects::BOLD)) + .placeholder(AnsiColor::Cyan.on_default().effects(Effects::BOLD)) + .error(AnsiColor::Red.on_default().effects(Effects::BOLD)) + .valid(AnsiColor::Cyan.on_default().effects(Effects::BOLD)) + .invalid(AnsiColor::Yellow.on_default().effects(Effects::BOLD)); + #[derive(Debug, clap::Args, Clone, Default)] #[group(skip)] #[allow(clippy::struct_excessive_bools)] +#[command(styles = USAGE_STYLES)] pub struct Args { #[clap(flatten)] pub locator: config::locator::Args, diff --git a/cmd/soroban-cli/src/commands/network/container.rs b/cmd/soroban-cli/src/commands/network/container.rs index 463dbbdc8..d14dc72e4 100644 --- a/cmd/soroban-cli/src/commands/network/container.rs +++ b/cmd/soroban-cli/src/commands/network/container.rs @@ -41,7 +41,7 @@ pub enum Error { impl Cmd { pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { match &self { - Cmd::Logs(cmd) => cmd.run().await?, + Cmd::Logs(cmd) => cmd.run(global_args).await?, Cmd::Start(cmd) => cmd.run(global_args).await?, Cmd::Stop(cmd) => cmd.run(global_args).await?, } diff --git a/cmd/soroban-cli/src/commands/network/container/logs.rs b/cmd/soroban-cli/src/commands/network/container/logs.rs index 99b36af9b..aaccffdde 100644 --- a/cmd/soroban-cli/src/commands/network/container/logs.rs +++ b/cmd/soroban-cli/src/commands/network/container/logs.rs @@ -1,6 +1,9 @@ use futures_util::TryStreamExt; -use crate::commands::network::container::shared::Error as ConnectionError; +use crate::{ + commands::{global, network::container::shared::Error as ConnectionError}, + print, +}; use super::shared::{Args, Name}; @@ -23,9 +26,10 @@ pub struct Cmd { } impl Cmd { - pub async fn run(&self) -> Result<(), Error> { + pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { + let print = print::Print::new(global_args.quiet); let container_name = Name(self.name.clone()).get_internal_container_name(); - let docker = self.container_args.connect_to_docker().await?; + let docker = self.container_args.connect_to_docker(&print).await?; let logs_stream = &mut docker.logs( &container_name, Some(bollard::container::LogsOptions { diff --git a/cmd/soroban-cli/src/commands/network/container/shared.rs b/cmd/soroban-cli/src/commands/network/container/shared.rs index f819f3ed3..38cb17af2 100644 --- a/cmd/soroban-cli/src/commands/network/container/shared.rs +++ b/cmd/soroban-cli/src/commands/network/container/shared.rs @@ -6,6 +6,8 @@ use clap::ValueEnum; // Need to add this for windows, since we are only using this crate for the unix fn try_docker_desktop_socket use home::home_dir; +use crate::print; + pub const DOCKER_HOST_HELP: &str = "Optional argument to override the default docker host. This is useful when you are using a non-standard docker host path for your Docker-compatible container runtime, e.g. Docker Desktop defaults to $HOME/.docker/run/docker.sock instead of /var/run/docker.sock"; // DEFAULT_DOCKER_HOST is from the bollard crate on the main branch, which has not been released yet: https://github.com/fussybeaver/bollard/blob/0972b1aac0ad5c08798e100319ddd0d2ee010365/src/docker.rs#L64 @@ -46,7 +48,8 @@ impl Args { .unwrap_or_default() } - pub(crate) async fn connect_to_docker(&self) -> Result { + #[allow(unused_variables)] + pub(crate) async fn connect_to_docker(&self, print: &print::Print) -> Result { // if no docker_host is provided, use the default docker host: // "unix:///var/run/docker.sock" on unix machines // "npipe:////./pipe/docker_engine" on windows machines @@ -89,7 +92,7 @@ impl Args { // if on unix, try to connect to the default docker desktop socket #[cfg(unix)] { - let docker_desktop_connection = try_docker_desktop_socket(&host)?; + let docker_desktop_connection = try_docker_desktop_socket(&host, print)?; match check_docker_connection(&docker_desktop_connection).await { Ok(()) => Ok(docker_desktop_connection), Err(err) => Err(err)?, @@ -138,40 +141,41 @@ impl Name { } #[cfg(unix)] -fn try_docker_desktop_socket(host: &str) -> Result { +fn try_docker_desktop_socket( + host: &str, + print: &print::Print, +) -> Result { let default_docker_desktop_host = format!("{}/.docker/run/docker.sock", home_dir().unwrap().display()); - println!("Failed to connect to DOCKER_HOST: {host}.\nTrying to connect to the default Docker Desktop socket at {default_docker_desktop_host}."); + print.warnln(format!("Failed to connect to Docker daemon at {host}.")); + + print.infoln(format!( + "Attempting to connect to the default Docker Desktop socket at {default_docker_desktop_host} instead." + )); Docker::connect_with_unix( &default_docker_desktop_host, DEFAULT_TIMEOUT, API_DEFAULT_VERSION, - ) + ).map_err(|e| { + print.errorln(format!( + "Failed to connect to the Docker daemon at {host:?}. Is the docker daemon running?" + )); + print.infoln( + "Running a local Stellar network requires a Docker-compatible container runtime." + ); + print.infoln( + "Please note that if you are using Docker Desktop, you may need to utilize the `--docker-host` flag to pass in the location of the docker socket on your machine." + ); + e + }) } // When bollard is not able to connect to the docker daemon, it returns a generic ConnectionRefused error // This method attempts to connect to the docker daemon and returns a more specific error message async fn check_docker_connection(docker: &Docker) -> Result<(), bollard::errors::Error> { - // This is a bit hacky, but the `client_addr` field is not directly accessible from the `Docker` struct, but we can access it from the debug string representation of the `Docker` struct - let docker_debug_string = format!("{docker:#?}"); - let start_of_client_addr = docker_debug_string.find("client_addr: ").unwrap(); - let end_of_client_addr = docker_debug_string[start_of_client_addr..] - .find(',') - .unwrap(); - // Extract the substring containing the value of client_addr - let client_addr = &docker_debug_string - [start_of_client_addr + "client_addr: ".len()..start_of_client_addr + end_of_client_addr] - .trim() - .trim_matches('"'); - match docker.version().await { Ok(_version) => Ok(()), - Err(err) => { - println!( - "⛔️ Failed to connect to the Docker daemon at {client_addr:?}. Is the docker daemon running?\nℹ️ Running a local Stellar network requires a Docker-compatible container runtime.\nℹ️ Please note that if you are using Docker Desktop, you may need to utilize the `--docker-host` flag to pass in the location of the docker socket on your machine.\n" - ); - Err(err) - } + Err(err) => Err(err), } } diff --git a/cmd/soroban-cli/src/commands/network/container/start.rs b/cmd/soroban-cli/src/commands/network/container/start.rs index a4d840f0f..2f16d3c1e 100644 --- a/cmd/soroban-cli/src/commands/network/container/start.rs +++ b/cmd/soroban-cli/src/commands/network/container/start.rs @@ -79,7 +79,11 @@ impl Runner { self.print .infoln(format!("Starting {} network", &self.args.network)); - let docker = self.args.container_args.connect_to_docker().await?; + let docker = self + .args + .container_args + .connect_to_docker(&self.print) + .await?; let image = self.get_image_name(); let mut stream = docker.create_image( diff --git a/cmd/soroban-cli/src/commands/network/container/stop.rs b/cmd/soroban-cli/src/commands/network/container/stop.rs index 87d6ba495..77f674c46 100644 --- a/cmd/soroban-cli/src/commands/network/container/stop.rs +++ b/cmd/soroban-cli/src/commands/network/container/stop.rs @@ -34,7 +34,7 @@ impl Cmd { pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { let print = print::Print::new(global_args.quiet); let container_name = Name(self.name.clone()); - let docker = self.container_args.connect_to_docker().await?; + let docker = self.container_args.connect_to_docker(&print).await?; print.infoln(format!( "Stopping {} container", diff --git a/cmd/soroban-cli/src/commands/network/mod.rs b/cmd/soroban-cli/src/commands/network/mod.rs index 772d0cbe8..8dd61b394 100644 --- a/cmd/soroban-cli/src/commands/network/mod.rs +++ b/cmd/soroban-cli/src/commands/network/mod.rs @@ -67,11 +67,9 @@ pub enum Error { #[error("network arg or rpc url and network passphrase are required if using the network")] Network, #[error(transparent)] - Http(#[from] http::Error), - #[error(transparent)] Rpc(#[from] rpc::Error), #[error(transparent)] - Hyper(#[from] hyper::Error), + HttpClient(#[from] reqwest::Error), #[error("Failed to parse JSON from {0}, {1}")] FailedToParseJSON(String, serde_json::Error), #[error("Invalid URL {0}")] diff --git a/cmd/soroban-cli/src/commands/snapshot/create.rs b/cmd/soroban-cli/src/commands/snapshot/create.rs index 8a587e243..13bef9465 100644 --- a/cmd/soroban-cli/src/commands/snapshot/create.rs +++ b/cmd/soroban-cli/src/commands/snapshot/create.rs @@ -1,8 +1,7 @@ use async_compression::tokio::bufread::GzipDecoder; use bytesize::ByteSize; use clap::{arg, Parser, ValueEnum}; -use futures::{StreamExt, TryStreamExt}; -use http::Uri; +use futures::StreamExt; use humantime::format_duration; use itertools::{Either, Itertools}; use sha2::{Digest, Sha256}; @@ -24,7 +23,11 @@ use stellar_xdr::curr::{ ScVal, }; use tokio::fs::OpenOptions; +use tokio::io::BufReader; +use tokio_util::io::StreamReader; +use url::Url; +use crate::utils::http; use crate::{ commands::{config::data, global, HEADING_RPC}, config::{self, locator, network::passphrase}, @@ -85,7 +88,7 @@ pub struct Cmd { network: config::network::Args, /// Archive URL #[arg(long, help_heading = HEADING_RPC, env = "STELLAR_ARCHIVE_URL")] - archive_url: Option, + archive_url: Option, } #[derive(thiserror::Error, Debug)] @@ -93,19 +96,19 @@ pub enum Error { #[error("wasm hash invalid: {0}")] WasmHashInvalid(String), #[error("downloading history: {0}")] - DownloadingHistory(hyper::Error), + DownloadingHistory(reqwest::Error), #[error("downloading history: got status code {0}")] - DownloadingHistoryGotStatusCode(hyper::StatusCode), + DownloadingHistoryGotStatusCode(reqwest::StatusCode), #[error("json decoding history: {0}")] JsonDecodingHistory(serde_json::Error), #[error("opening cached bucket to read: {0}")] ReadOpeningCachedBucket(io::Error), #[error("parsing bucket url: {0}")] - ParsingBucketUrl(http::uri::InvalidUri), + ParsingBucketUrl(url::ParseError), #[error("getting bucket: {0}")] - GettingBucket(hyper::Error), + GettingBucket(reqwest::Error), #[error("getting bucket: got status code {0}")] - GettingBucketGotStatusCode(hyper::StatusCode), + GettingBucketGotStatusCode(reqwest::StatusCode), #[error("opening cached bucket to write: {0}")] WriteOpeningCachedBucket(io::Error), #[error("streaming bucket: {0}")] @@ -117,7 +120,7 @@ pub enum Error { #[error("getting bucket directory: {0}")] GetBucketDir(data::Error), #[error("reading history http stream: {0}")] - ReadHistoryHttpStream(hyper::Error), + ReadHistoryHttpStream(reqwest::Error), #[error("writing ledger snapshot: {0}")] WriteLedgerSnapshot(soroban_ledger_snapshot::Error), #[error(transparent)] @@ -362,7 +365,7 @@ impl Cmd { Ok(()) } - fn archive_url(&self) -> Result { + fn archive_url(&self) -> Result { // Return the configured archive URL, or if one is not configured, guess // at an appropriate archive URL given the network passphrase. self.archive_url @@ -380,7 +383,7 @@ impl Cmd { passphrase::LOCAL => Some("http://localhost:8000/archive"), _ => None, } - .map(|s| Uri::from_str(s).expect("archive url valid")) + .map(|s| Url::from_str(s).expect("archive url valid")) }) }) .ok_or(Error::ArchiveUrlNotConfigured) @@ -389,7 +392,7 @@ impl Cmd { async fn get_history( print: &print::Print, - archive_url: &Uri, + archive_url: &Url, ledger: Option, ) -> Result { let archive_url = archive_url.to_string(); @@ -403,14 +406,13 @@ async fn get_history( } else { format!("{archive_url}/.well-known/stellar-history.json") }; - let history_url = Uri::from_str(&history_url).unwrap(); + let history_url = Url::from_str(&history_url).unwrap(); print.globe(format!("Downloading history {history_url}")); - let https = hyper_tls::HttpsConnector::new(); - let response = hyper::Client::builder() - .build::<_, hyper::Body>(https) - .get(history_url.clone()) + let response = http::client() + .get(history_url.as_str()) + .send() .await .map_err(Error::DownloadingHistory)?; @@ -431,7 +433,8 @@ async fn get_history( return Err(Error::DownloadingHistoryGotStatusCode(response.status())); } - let body = hyper::body::to_bytes(response.into_body()) + let body = response + .bytes() .await .map_err(Error::ReadHistoryHttpStream)?; @@ -443,7 +446,7 @@ async fn get_history( async fn cache_bucket( print: &print::Print, - archive_url: &Uri, + archive_url: &Url, bucket_index: usize, bucket: &str, ) -> Result { @@ -458,11 +461,11 @@ async fn cache_bucket( print.globe(format!("Downloading bucket {bucket_index} {bucket}…")); - let bucket_url = Uri::from_str(&bucket_url).map_err(Error::ParsingBucketUrl)?; - let https = hyper_tls::HttpsConnector::new(); - let response = hyper::Client::builder() - .build::<_, hyper::Body>(https) - .get(bucket_url) + let bucket_url = Url::from_str(&bucket_url).map_err(Error::ParsingBucketUrl)?; + + let response = http::client() + .get(bucket_url.as_str()) + .send() .await .map_err(Error::GettingBucket)?; @@ -471,26 +474,22 @@ async fn cache_bucket( return Err(Error::GettingBucketGotStatusCode(response.status())); } - if let Some(val) = response.headers().get("Content-Length") { - if let Ok(str) = val.to_str() { - if let Ok(len) = str.parse::() { - print.clear_line(); - print.globe(format!( - "Downloaded bucket {bucket_index} {bucket} ({})", - ByteSize(len) - )); - } - } + if let Some(len) = response.content_length() { + print.clear_line(); + print.globe(format!( + "Downloaded bucket {bucket_index} {bucket} ({})", + ByteSize(len) + )); } print.println(""); - let read = response - .into_body() - .map(|result| result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))) - .into_async_read(); - let read = tokio_util::compat::FuturesAsyncReadCompatExt::compat(read); - let mut read = GzipDecoder::new(read); + let stream = response + .bytes_stream() + .map(|result| result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))); + let stream_reader = StreamReader::new(stream); + let buf_reader = BufReader::new(stream_reader); + let mut decoder = GzipDecoder::new(buf_reader); let dl_path = cache_path.with_extension("dl"); let mut file = OpenOptions::new() .create(true) @@ -499,7 +498,7 @@ async fn cache_bucket( .open(&dl_path) .await .map_err(Error::WriteOpeningCachedBucket)?; - tokio::io::copy(&mut read, &mut file) + tokio::io::copy(&mut decoder, &mut file) .await .map_err(Error::StreamingBucket)?; fs::rename(&dl_path, &cache_path).map_err(Error::RenameDownloadFile)?; diff --git a/cmd/soroban-cli/src/config/data.rs b/cmd/soroban-cli/src/config/data.rs index 23dedc619..bbfc6994e 100644 --- a/cmd/soroban-cli/src/config/data.rs +++ b/cmd/soroban-cli/src/config/data.rs @@ -1,8 +1,8 @@ use crate::rpc::{GetTransactionResponse, GetTransactionResponseRaw, SimulateTransactionResponse}; use directories::ProjectDirs; -use http::Uri; use serde::{Deserialize, Serialize}; use std::str::FromStr; +use url::Url; use crate::xdr::{self, WriteXdr}; @@ -15,7 +15,7 @@ pub enum Error { #[error(transparent)] SerdeJson(#[from] serde_json::Error), #[error(transparent)] - Http(#[from] http::uri::InvalidUri), + InvalidUrl(#[from] url::ParseError), #[error(transparent)] Ulid(#[from] ulid::DecodeError), #[error(transparent)] @@ -56,7 +56,7 @@ pub fn bucket_dir() -> Result { Ok(dir) } -pub fn write(action: Action, rpc_url: &Uri) -> Result { +pub fn write(action: Action, rpc_url: &Url) -> Result { let data = Data { action, rpc_url: rpc_url.to_string(), @@ -67,10 +67,10 @@ pub fn write(action: Action, rpc_url: &Uri) -> Result { Ok(id) } -pub fn read(id: &ulid::Ulid) -> Result<(Action, Uri), Error> { +pub fn read(id: &ulid::Ulid) -> Result<(Action, Url), Error> { let file = actions_dir()?.join(id.to_string()).with_extension("json"); let data: Data = serde_json::from_str(&std::fs::read_to_string(file)?)?; - Ok((data.action, http::Uri::from_str(&data.rpc_url)?)) + Ok((data.action, Url::from_str(&data.rpc_url)?)) } pub fn write_spec(hash: &str, spec_entries: &[xdr::ScSpecEntry]) -> Result<(), Error> { @@ -117,7 +117,7 @@ pub fn list_actions() -> Result, Error> { .collect::, Error>>() } -pub struct DatedAction(ulid::Ulid, Action, Uri); +pub struct DatedAction(ulid::Ulid, Action, Url); impl std::fmt::Display for DatedAction { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -200,7 +200,7 @@ mod test { fn test_write_read() { let t = assert_fs::TempDir::new().unwrap(); std::env::set_var(XDG_DATA_HOME, t.path().to_str().unwrap()); - let rpc_uri = http::uri::Uri::from_str("http://localhost:8000").unwrap(); + let rpc_uri = Url::from_str("http://localhost:8000").unwrap(); let sim = SimulateTransactionResponse::default(); let original_action: Action = sim.into(); diff --git a/cmd/soroban-cli/src/config/network.rs b/cmd/soroban-cli/src/config/network.rs index 9e1eabee3..b6f6d8c1d 100644 --- a/cmd/soroban-cli/src/config/network.rs +++ b/cmd/soroban-cli/src/config/network.rs @@ -1,32 +1,29 @@ -use std::str::FromStr; - use clap::arg; use phf::phf_map; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::str::FromStr; use stellar_strkey::ed25519::PublicKey; +use url::Url; +use super::locator; +use crate::utils::http; use crate::{ commands::HEADING_RPC, rpc::{self, Client}, }; - -use super::locator; pub mod passphrase; #[derive(thiserror::Error, Debug)] pub enum Error { #[error(transparent)] Config(#[from] locator::Error), - #[error("network arg or rpc url and network passphrase are required if using the network")] Network, #[error(transparent)] - Http(#[from] http::Error), - #[error(transparent)] Rpc(#[from] rpc::Error), #[error(transparent)] - Hyper(#[from] hyper::Error), + HttpClient(#[from] reqwest::Error), #[error("Failed to parse JSON from {0}, {1}")] FailedToParseJSON(String, serde_json::Error), #[error("Invalid URL {0}")] @@ -107,29 +104,27 @@ pub struct Network { } impl Network { - pub async fn helper_url(&self, addr: &str) -> Result { - use http::Uri; + pub async fn helper_url(&self, addr: &str) -> Result { tracing::debug!("address {addr:?}"); - let rpc_uri = Uri::from_str(&self.rpc_url) + let rpc_url = Url::from_str(&self.rpc_url) .map_err(|_| Error::InvalidUrl(self.rpc_url.to_string()))?; if self.network_passphrase.as_str() == passphrase::LOCAL { - let auth = rpc_uri.authority().unwrap().clone(); - let scheme = rpc_uri.scheme_str().unwrap(); - Ok(Uri::builder() - .authority(auth) - .scheme(scheme) - .path_and_query(format!("/friendbot?addr={addr}")) - .build()?) + let mut local_url = rpc_url; + local_url.set_path("/friendbot"); + local_url.set_query(Some(&format!("addr={addr}"))); + Ok(local_url) } else { let client = Client::new(&self.rpc_url)?; let network = client.get_network().await?; tracing::debug!("network {network:?}"); - let uri = client.friendbot_url().await?; - tracing::debug!("URI {uri:?}"); - Uri::from_str(&format!("{uri}?addr={addr}")).map_err(|e| { + let url = client.friendbot_url().await?; + tracing::debug!("URL {url:?}"); + let mut url = Url::from_str(&url).map_err(|e| { tracing::error!("{e}"); - Error::InvalidUrl(uri.to_string()) - }) + Error::InvalidUrl(url.to_string()) + })?; + url.query_pairs_mut().append_pair("addr", addr); + Ok(url) } } @@ -137,21 +132,10 @@ impl Network { pub async fn fund_address(&self, addr: &PublicKey) -> Result<(), Error> { let uri = self.helper_url(&addr.to_string()).await?; tracing::debug!("URL {uri:?}"); - let response = match uri.scheme_str() { - Some("http") => hyper::Client::new().get(uri.clone()).await?, - Some("https") => { - let https = hyper_tls::HttpsConnector::new(); - hyper::Client::builder() - .build::<_, hyper::Body>(https) - .get(uri.clone()) - .await? - } - _ => { - return Err(Error::InvalidUrl(uri.to_string())); - } - }; + let response = http::client().get(uri.as_str()).send().await?; + let request_successful = response.status().is_success(); - let body = hyper::body::to_bytes(response.into_body()).await?; + let body = response.bytes().await?; let res = serde_json::from_slice::(&body) .map_err(|e| Error::FailedToParseJSON(uri.to_string(), e))?; tracing::debug!("{res:#?}"); @@ -173,8 +157,8 @@ impl Network { Ok(()) } - pub fn rpc_uri(&self) -> Result { - http::Uri::from_str(&self.rpc_url).map_err(|_| Error::InvalidUrl(self.rpc_url.to_string())) + pub fn rpc_uri(&self) -> Result { + Url::from_str(&self.rpc_url).map_err(|_| Error::InvalidUrl(self.rpc_url.to_string())) } } @@ -206,3 +190,90 @@ impl From<&(&str, &str)> for Network { } } } + +#[cfg(test)] +mod tests { + use super::*; + use mockito::Server; + use serde_json::json; + + #[tokio::test] + async fn test_helper_url_local_network() { + let network = Network { + rpc_url: "http://localhost:8000".to_string(), + network_passphrase: passphrase::LOCAL.to_string(), + }; + + let result = network + .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI") + .await; + + assert!(result.is_ok()); + let url = result.unwrap(); + assert_eq!(url.as_str(), "http://localhost:8000/friendbot?addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI"); + } + + #[tokio::test] + async fn test_helper_url_test_network() { + let mut server = Server::new_async().await; + let _mock = server + .mock("POST", "/") + .with_body_from_request(|req| { + let body: Value = serde_json::from_slice(req.body().unwrap()).unwrap(); + let id = body["id"].clone(); + json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "friendbotUrl": "https://friendbot.stellar.org/", + "passphrase": passphrase::TESTNET.to_string(), + "protocolVersion": 21 + } + }) + .to_string() + .into() + }) + .create_async() + .await; + + let network = Network { + rpc_url: server.url(), + network_passphrase: passphrase::TESTNET.to_string(), + }; + let url = network + .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI") + .await + .unwrap(); + assert_eq!(url.as_str(), "https://friendbot.stellar.org/?addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI"); + } + + #[tokio::test] + async fn test_helper_url_test_network_with_path_and_params() { + let mut server = Server::new_async().await; + let _mock = server.mock("POST", "/") + .with_body_from_request(|req| { + let body: Value = serde_json::from_slice(req.body().unwrap()).unwrap(); + let id = body["id"].clone(); + json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "friendbotUrl": "https://friendbot.stellar.org/secret?api_key=123456&user=demo", + "passphrase": passphrase::TESTNET.to_string(), + "protocolVersion": 21 + } + }).to_string().into() + }) + .create_async().await; + + let network = Network { + rpc_url: server.url(), + network_passphrase: passphrase::TESTNET.to_string(), + }; + let url = network + .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI") + .await + .unwrap(); + assert_eq!(url.as_str(), "https://friendbot.stellar.org/secret?api_key=123456&user=demo&addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI"); + } +} diff --git a/cmd/soroban-cli/src/upgrade_check.rs b/cmd/soroban-cli/src/upgrade_check.rs index ecd1a4adc..294056dd1 100644 --- a/cmd/soroban-cli/src/upgrade_check.rs +++ b/cmd/soroban-cli/src/upgrade_check.rs @@ -1,5 +1,6 @@ use crate::config::upgrade_check::UpgradeCheck; use crate::print::Print; +use crate::utils::http; use semver::Version; use serde::Deserialize; use std::error::Error; @@ -8,7 +9,6 @@ use std::time::Duration; const MINIMUM_CHECK_INTERVAL: Duration = Duration::from_secs(60 * 60 * 24); // 1 day const CRATES_IO_API_URL: &str = "https://crates.io/api/v1/crates/"; -const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); const NO_UPDATE_CHECK_ENV_VAR: &str = "STELLAR_NO_UPDATE_CHECK"; #[derive(Deserialize)] @@ -26,16 +26,20 @@ struct Crate { } /// Fetch the latest stable version of the crate from crates.io -fn fetch_latest_crate_info() -> Result> { +async fn fetch_latest_crate_info() -> Result> { let crate_name = env!("CARGO_PKG_NAME"); let url = format!("{CRATES_IO_API_URL}{crate_name}"); - let response = ureq::get(&url).timeout(REQUEST_TIMEOUT).call()?; - let crate_data: CrateResponse = response.into_json()?; - Ok(crate_data.crate_) + let resp = http::client() + .get(url) + .send() + .await? + .json::() + .await?; + Ok(resp.crate_) } /// Print a warning if a new version of the CLI is available -pub fn upgrade_check(quiet: bool) { +pub async fn upgrade_check(quiet: bool) { // We should skip the upgrade check if we're not in a tty environment. if !std::io::stderr().is_terminal() { return; @@ -59,7 +63,7 @@ pub fn upgrade_check(quiet: bool) { let now = chrono::Utc::now(); // Skip fetch from crates.io if we've checked recently if now - MINIMUM_CHECK_INTERVAL >= stats.latest_check_time { - match fetch_latest_crate_info() { + match fetch_latest_crate_info().await { Ok(c) => { stats = UpgradeCheck { latest_check_time: now, @@ -112,9 +116,9 @@ fn get_latest_version<'a>(current_version: &Version, stats: &'a UpgradeCheck) -> mod tests { use super::*; - #[test] - fn test_fetch_latest_stable_version() { - let _ = fetch_latest_crate_info().unwrap(); + #[tokio::test] + async fn test_fetch_latest_stable_version() { + let _ = fetch_latest_crate_info().await.unwrap(); } #[test] diff --git a/cmd/soroban-cli/src/utils.rs b/cmd/soroban-cli/src/utils.rs index 8d0090042..ee8bb1ece 100644 --- a/cmd/soroban-cli/src/utils.rs +++ b/cmd/soroban-cli/src/utils.rs @@ -161,6 +161,41 @@ pub fn get_name_from_stellar_asset_contract_storage(storage: &ScMap) -> Option String { + format!("{}/{}", env!("CARGO_PKG_NAME"), version::pkg()) + } + + /// Creates and returns a configured `reqwest::Client`. + /// + /// # Panics + /// + /// Panics if the Client initialization fails. + pub fn client() -> reqwest::Client { + // Why we panic here: + // 1. Client initialization failures are rare and usually indicate serious issues. + // 2. The application cannot function properly without a working HTTP client. + // 3. This simplifies error handling for callers, as they can assume a valid client. + reqwest::Client::builder() + .user_agent(user_agent()) + .build() + .expect("Failed to build reqwest client") + } + + /// Creates and returns a configured `reqwest::blocking::Client`. + /// + /// # Panics + /// + /// Panics if the Client initialization fails. + pub fn blocking_client() -> reqwest::blocking::Client { + reqwest::blocking::Client::builder() + .user_agent(user_agent()) + .build() + .expect("Failed to build reqwest blocking client") + } +} + pub mod rpc { use soroban_env_host::xdr; use soroban_rpc::{Client, Error};