diff --git a/.github/workflows/cont_integration.yml b/.github/workflows/cont_integration.yml index 4260862..ec19b86 100644 --- a/.github/workflows/cont_integration.yml +++ b/.github/workflows/cont_integration.yml @@ -18,14 +18,14 @@ jobs: - default - blocking - blocking-https - - blocking-https-rustls - blocking-https-native - - blocking-https-bundled + - blocking-https-rustls + - blocking-https-rustls-probe - async - async-https - async-https-native - async-https-rustls - - async-https-rustls-manual-roots + - async-https-rustls-probe steps: - name: Checkout uses: actions/checkout@v4 diff --git a/Cargo.toml b/Cargo.toml index 55f51ca..d4e27f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,8 +25,7 @@ serde_json = { version = "1.0", default-features = false } bitcoin = { version = "0.32", features = ["serde", "std"], default-features = false } hex = { version = "0.2", package = "hex-conservative" } log = "^0.4" -minreq = { version = "2.11.0", features = ["json-using-serde"], optional = true } -reqwest = { version = "0.12", features = ["json"], default-features = false, optional = true } +bitreq = { version = "0.3.4", optional = true } # default async runtime tokio = { version = "1", features = ["time"], optional = true } @@ -37,16 +36,16 @@ electrsd = { version = "0.36.1", features = ["legacy", "esplora_a33e97e1", "core lazy_static = "1.4.0" [features] -default = ["blocking", "async", "async-https", "tokio"] -blocking = ["minreq", "minreq/proxy"] -blocking-https = ["blocking", "minreq/https"] -blocking-https-rustls = ["blocking", "minreq/https-rustls"] -blocking-https-native = ["blocking", "minreq/https-native"] -blocking-https-bundled = ["blocking", "minreq/https-bundled"] +default = ["blocking", "blocking-https", "async", "async-https", "tokio"] +blocking = ["bitreq/proxy", "bitreq/json-using-serde"] +blocking-https = ["blocking", "bitreq/https"] +blocking-https-native = ["blocking", "bitreq/https-native-tls"] +blocking-https-rustls = ["blocking", "bitreq/https-rustls"] +blocking-https-rustls-probe = ["blocking", "bitreq/https-rustls-probe"] tokio = ["dep:tokio"] -async = ["reqwest", "reqwest/socks", "tokio?/time"] -async-https = ["async", "reqwest/default-tls"] -async-https-native = ["async", "reqwest/native-tls"] -async-https-rustls = ["async", "reqwest/rustls-tls"] -async-https-rustls-manual-roots = ["async", "reqwest/rustls-tls-manual-roots"] +async = ["bitreq/async", "bitreq/proxy", "bitreq/json-using-serde", "tokio?/time"] +async-https = ["async", "bitreq/async-https"] +async-https-native = ["async", "bitreq/async-https-native-tls"] +async-https-rustls = ["async", "bitreq/async-https-rustls"] +async-https-rustls-probe = ["async", "bitreq/async-https-rustls-probe"] diff --git a/ci/pin-msrv.sh b/ci/pin-msrv.sh index f74986e..41cb506 100644 --- a/ci/pin-msrv.sh +++ b/ci/pin-msrv.sh @@ -5,7 +5,5 @@ set -euo pipefail # Pin dependencies for MSRV (1.75.0) cargo update -p minreq --precise "2.13.2" -cargo update -p idna_adapter --precise "1.2.0" cargo update -p native-tls --precise "0.2.13" -cargo update -p zerofrom --precise "0.1.5" -cargo update -p litemap --precise "0.7.4" +cargo update -p getrandom@0.4.1 --precise "0.3.4" \ No newline at end of file diff --git a/src/async.rs b/src/async.rs index fb6d56d..f725266 100644 --- a/src/async.rs +++ b/src/async.rs @@ -23,10 +23,7 @@ use bitcoin::hashes::{sha256, Hash}; use bitcoin::hex::{DisplayHex, FromHex}; use bitcoin::{Address, Block, BlockHash, MerkleBlock, Script, Transaction, Txid}; -#[allow(unused_imports)] -use log::{debug, error, info, trace}; - -use reqwest::{header, Body, Client, Response}; +use bitreq::{Client, RequestExt, Response}; use crate::{ AddressStats, BlockInfo, BlockStatus, BlockSummary, Builder, Error, MempoolRecentTx, @@ -35,14 +32,21 @@ use crate::{ }; /// An async client for interacting with an Esplora API server. -#[derive(Debug, Clone)] +// FIXME: (@oleonardolima) there's no `Debug` implementation for `bitreq::Client`. +#[derive(Clone)] pub struct AsyncClient { /// The URL of the Esplora Server. url: String, - /// The inner [`reqwest::Client`] to make HTTP requests. - client: Client, + /// The proxy is ignored when targeting `wasm32`. + proxy: Option, + /// Socket timeout. + timeout: Option, + /// HTTP headers to set on every request made to Esplora server + headers: HashMap, /// Number of times to retry a request max_retries: usize, + /// The inner [`reqwest::Client`] to make HTTP requests. + client: Client, /// Marker for the type of sleeper used marker: PhantomData, } @@ -50,46 +54,19 @@ pub struct AsyncClient { impl AsyncClient { /// Build an [`AsyncClient`] from a [`Builder`]. pub fn from_builder(builder: Builder) -> Result { - let mut client_builder = Client::builder(); - - #[cfg(not(target_arch = "wasm32"))] - if let Some(proxy) = &builder.proxy { - client_builder = client_builder.proxy(reqwest::Proxy::all(proxy)?); - } - - #[cfg(not(target_arch = "wasm32"))] - if let Some(timeout) = builder.timeout { - client_builder = client_builder.timeout(core::time::Duration::from_secs(timeout)); - } - - if !builder.headers.is_empty() { - let mut headers = header::HeaderMap::new(); - for (k, v) in builder.headers { - let header_name = header::HeaderName::from_lowercase(k.to_lowercase().as_bytes()) - .map_err(|_| Error::InvalidHttpHeaderName(k))?; - let header_value = header::HeaderValue::from_str(&v) - .map_err(|_| Error::InvalidHttpHeaderValue(v))?; - headers.insert(header_name, header_value); - } - client_builder = client_builder.default_headers(headers); - } + // TODO: (@oleonardolima) we should expose this to the final user through `Builder`. + let cached_connections = 10; + let client = Client::new(cached_connections); Ok(AsyncClient { url: builder.base_url, - client: client_builder.build()?, + proxy: builder.proxy, + timeout: builder.timeout, + headers: builder.headers, max_retries: builder.max_retries, - marker: PhantomData, - }) - } - - /// Build an [`AsyncClient`] from a [`Client`]. - pub fn from_client(url: String, client: Client) -> Self { - AsyncClient { - url, client, - max_retries: crate::DEFAULT_MAX_RETRIES, marker: PhantomData, - } + }) } /// Make an HTTP GET request to given URL, deserializing to any `T` that @@ -107,14 +84,13 @@ impl AsyncClient { let url = format!("{}{}", self.url, path); let response = self.get_with_retry(&url).await?; - if !response.status().is_success() { - return Err(Error::HttpResponse { - status: response.status().as_u16(), - message: response.text().await?, - }); + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); } - Ok(deserialize::(&response.bytes().await?)?) + Ok(deserialize::(response.as_bytes())?) } /// Make an HTTP GET request to given URL, deserializing to `Option`. @@ -147,14 +123,13 @@ impl AsyncClient { let url = format!("{}{}", self.url, path); let response = self.get_with_retry(&url).await?; - if !response.status().is_success() { - return Err(Error::HttpResponse { - status: response.status().as_u16(), - message: response.text().await?, - }); + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); } - response.json::().await.map_err(Error::Reqwest) + response.json::().map_err(Error::BitReq) } /// Make an HTTP GET request to given URL, deserializing to `Option`. @@ -189,15 +164,14 @@ impl AsyncClient { let url = format!("{}{}", self.url, path); let response = self.get_with_retry(&url).await?; - if !response.status().is_success() { - return Err(Error::HttpResponse { - status: response.status().as_u16(), - message: response.text().await?, - }); + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); } - let hex_str = response.text().await?; - Ok(deserialize(&Vec::from_hex(&hex_str)?)?) + let hex_str = response.as_str()?; + Ok(deserialize(&Vec::from_hex(hex_str)?)?) } /// Make an HTTP GET request to given URL, deserializing to `Option`. @@ -226,14 +200,13 @@ impl AsyncClient { let url = format!("{}{}", self.url, path); let response = self.get_with_retry(&url).await?; - if !response.status().is_success() { - return Err(Error::HttpResponse { - status: response.status().as_u16(), - message: response.text().await?, - }); + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); } - Ok(response.text().await?) + Ok(response.as_str()?.to_string()) } /// Make an HTTP GET request to given URL, deserializing to `Option`. @@ -257,26 +230,25 @@ impl AsyncClient { /// /// This function will return an error either from the HTTP client, or the /// response's [`serde_json`] deserialization. - async fn post_request_bytes>( + async fn post_request_bytes>>( &self, path: &str, body: T, query_params: Option>, ) -> Result { let url: String = format!("{}{}", self.url, path); - let mut request = self.client.post(url).body(body); + let mut request: bitreq::Request = bitreq::post(url).with_body(body); - for param in query_params.unwrap_or_default() { - request = request.query(¶m); + for (key, value) in query_params.unwrap_or_default() { + request = request.with_param(key, value); } - let response = request.send().await?; + let response = request.send_async_with_client(&self.client).await?; - if !response.status().is_success() { - return Err(Error::HttpResponse { - status: response.status().as_u16(), - message: response.text().await?, - }); + if !is_success(&response) { + let status = u16::try_from(response.status_code).map_err(Error::StatusCode)?; + let message = response.as_str().unwrap_or_default().to_string(); + return Err(Error::HttpResponse { status, message }); } Ok(response) @@ -375,7 +347,7 @@ impl AsyncClient { pub async fn broadcast(&self, transaction: &Transaction) -> Result { let body = serialize::(transaction).to_lower_hex_string(); let response = self.post_request_bytes("/tx", body, None).await?; - let txid = Txid::from_str(&response.text().await?).map_err(Error::HexToArray)?; + let txid = Txid::from_str(response.as_str()?).map_err(Error::HexToArray)?; Ok(txid) } @@ -414,7 +386,7 @@ impl AsyncClient { ) .await?; - Ok(response.json::().await?) + Ok(response.json::()?) } /// Get the current height of the blockchain tip @@ -606,21 +578,65 @@ impl AsyncClient { let mut delay = BASE_BACKOFF_MILLIS; let mut attempts = 0; + let mut request = bitreq::get(url); + + #[cfg(not(target_arch = "wasm32"))] + if let Some(proxy) = &self.proxy { + use bitreq::Proxy; + + let proxy = Proxy::new_http(proxy.as_str())?; + request = request.with_proxy(proxy); + } + + #[cfg(not(target_arch = "wasm32"))] + if let Some(timeout) = &self.timeout { + request = request.with_timeout(*timeout); + } + + if !self.headers.is_empty() { + request = request.with_headers(&self.headers); + } + loop { - match self.client.get(url).send().await? { - resp if attempts < self.max_retries && is_status_retryable(resp.status()) => { + match request.clone().send_async_with_client(&self.client).await? { + response if attempts < self.max_retries && is_retryable(&response) => { S::sleep(delay).await; attempts += 1; delay *= 2; } - resp => return Ok(resp), + response => return Ok(response), } } } } -fn is_status_retryable(status: reqwest::StatusCode) -> bool { - RETRYABLE_ERROR_CODES.contains(&status.as_u16()) +// /// Check if [`Response`] status is within 100-199. +// fn is_informational(response: &Response) -> bool { +// (100..200).contains(&response.status_code) +// } + +/// Check if [`Response`] status is within 200-299. +fn is_success(response: &Response) -> bool { + (200..300).contains(&response.status_code) +} + +// /// Check if [`Response`] status is within 300-399. +// fn is_redirection(response: &Response) -> bool { +// (300..400).contains(&response.status_code) +// } + +// /// Check if [`Response`] status is within 400-499. +// fn is_client_error(response: &Response) -> bool { +// (400..500).contains(&response.status_code) +// } + +// /// Check if [`Response`] status is within 500-599. +// fn is_server_error(response: &Response) -> bool { +// (500..600).contains(&response.status_code) +// } + +fn is_retryable(response: &Response) -> bool { + RETRYABLE_ERROR_CODES.contains(&(response.status_code as u16)) } /// Sleeper trait that allows any async runtime to be used. diff --git a/src/blocking.rs b/src/blocking.rs index ff54d7f..6ad74ab 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -17,11 +17,10 @@ use std::str::FromStr; use std::thread; use bitcoin::consensus::encode::serialize_hex; +use bitreq::{Proxy, Request, Response}; #[allow(unused_imports)] use log::{debug, error, info, trace}; -use minreq::{Proxy, Request, Response}; - use bitcoin::block::Header as BlockHeader; use bitcoin::consensus::{deserialize, serialize, Decodable}; use bitcoin::hashes::{sha256, Hash}; @@ -68,10 +67,10 @@ impl BlockingClient { /// Perform a raw HTTP GET request with the given URI `path`. pub fn get_request(&self, path: &str) -> Result { - let mut request = minreq::get(format!("{}{}", self.url, path)); + let mut request = bitreq::get(format!("{}{}", self.url, path)); if let Some(proxy) = &self.proxy { - let proxy = Proxy::new(proxy.as_str())?; + let proxy = Proxy::new_http(proxy.as_str())?; request = request.with_proxy(proxy); } @@ -92,10 +91,10 @@ impl BlockingClient { where T: Into>, { - let mut request = minreq::post(format!("{}{}", self.url, path)).with_body(body); + let mut request = bitreq::post(format!("{}{}", self.url, path)).with_body(body); if let Some(proxy) = &self.proxy { - let proxy = Proxy::new(proxy.as_str())?; + let proxy = Proxy::new_http(proxy.as_str())?; request = request.with_proxy(proxy); } @@ -128,7 +127,7 @@ impl BlockingClient { Err(Error::HttpResponse { status, message }) } Ok(resp) => Ok(Some( - Txid::from_str(resp.as_str().map_err(Error::Minreq)?).map_err(Error::HexToArray)?, + Txid::from_str(resp.as_str().map_err(Error::BitReq)?).map_err(Error::HexToArray)?, )), Err(e) => Err(e), } @@ -143,7 +142,7 @@ impl BlockingClient { Err(Error::HttpResponse { status, message }) } Ok(resp) => { - let hex_str = resp.as_str().map_err(Error::Minreq)?; + let hex_str = resp.as_str().map_err(Error::BitReq)?; let hex_vec = Vec::from_hex(hex_str)?; deserialize::(&hex_vec) .map_err(Error::BitcoinEncoding) @@ -161,7 +160,7 @@ impl BlockingClient { Err(Error::HttpResponse { status, message }) } Ok(resp) => { - let hex_str = resp.as_str().map_err(Error::Minreq)?; + let hex_str = resp.as_str().map_err(Error::BitReq)?; let hex_vec = Vec::from_hex(hex_str)?; deserialize::(&hex_vec).map_err(Error::BitcoinEncoding) } @@ -180,7 +179,7 @@ impl BlockingClient { let message = resp.as_str().unwrap_or_default().to_string(); Err(Error::HttpResponse { status, message }) } - Ok(resp) => Ok(resp.json::().map_err(Error::Minreq)?), + Ok(resp) => Ok(resp.json::().map_err(Error::BitReq)?), Err(e) => Err(e), } } @@ -309,7 +308,7 @@ impl BlockingClient { let txid = Txid::from_str(resp.as_str()?).map_err(Error::HexToArray)?; Ok(txid) } - Err(e) => Err(Error::Minreq(e)), + Err(e) => Err(Error::BitReq(e)), } } @@ -353,8 +352,8 @@ impl BlockingClient { let message = resp.as_str().unwrap_or_default().to_string(); Err(Error::HttpResponse { status, message }) } - Ok(resp) => Ok(resp.json::().map_err(Error::Minreq)?), - Err(e) => Err(Error::Minreq(e)), + Ok(resp) => Ok(resp.json::().map_err(Error::BitReq)?), + Err(e) => Err(Error::BitReq(e)), } } diff --git a/src/lib.rs b/src/lib.rs index e686078..106d751 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,7 @@ //! async Esplora client to query Esplora's backend. //! //! The library provides the possibility to build a blocking -//! client using [`minreq`] and an async client using [`reqwest`]. +//! or async client, both using [`bitreq`]. //! The library supports communicating to Esplora via a proxy //! and also using TLS (SSL) for secure communication. //! @@ -44,32 +44,33 @@ //! `esplora-client = { version = "*", default-features = false, features = //! ["blocking"] }` //! -//! * `blocking` enables [`minreq`], the blocking client with proxy. -//! * `blocking-https` enables [`minreq`], the blocking client with proxy and TLS (SSL) capabilities -//! using the default [`minreq`] backend. -//! * `blocking-https-rustls` enables [`minreq`], the blocking client with proxy and TLS (SSL) +//! * `blocking` enables [`bitreq`], the blocking client with proxy. +//! * `blocking-https` enables [`bitreq`], the blocking client with proxy and TLS (SSL) capabilities +//! using the default [`bitreq`] backend. +//! * `blocking-https-rustls` enables [`bitreq`], the blocking client with proxy and TLS (SSL) //! capabilities using the `rustls` backend. -//! * `blocking-https-native` enables [`minreq`], the blocking client with proxy and TLS (SSL) +//! * `blocking-https-native` enables [`bitreq`], the blocking client with proxy and TLS (SSL) //! capabilities using the platform's native TLS backend (likely OpenSSL). -//! * `blocking-https-bundled` enables [`minreq`], the blocking client with proxy and TLS (SSL) +//! * `blocking-https-bundled` enables [`bitreq`], the blocking client with proxy and TLS (SSL) //! capabilities using a bundled OpenSSL library backend. -//! * `async` enables [`reqwest`], the async client with proxy capabilities. -//! * `async-https` enables [`reqwest`], the async client with support for proxying and TLS (SSL) -//! using the default [`reqwest`] TLS backend. -//! * `async-https-native` enables [`reqwest`], the async client with support for proxying and TLS +//! * `async` enables [`bitreq`], the async client with proxy capabilities. +//! * `async-https` enables [`bitreq`], the async client with support for proxying and TLS (SSL) +//! using the default [`bitreq`] TLS backend. +//! * `async-https-native` enables [`bitreq`], the async client with support for proxying and TLS //! (SSL) using the platform's native TLS backend (likely OpenSSL). -//! * `async-https-rustls` enables [`reqwest`], the async client with support for proxying and TLS +//! * `async-https-rustls` enables [`bitreq`], the async client with support for proxying and TLS //! (SSL) using the `rustls` TLS backend. -//! * `async-https-rustls-manual-roots` enables [`reqwest`], the async client with support for +//! * `async-https-rustls-manual-roots` enables [`bitreq`], the async client with support for //! proxying and TLS (SSL) using the `rustls` TLS backend without using the default root //! certificates. //! //! [`dont remove this line or cargo doc will break`]: https://example.com -#![cfg_attr(not(feature = "minreq"), doc = "[`minreq`]: https://docs.rs/minreq")] -#![cfg_attr(not(feature = "reqwest"), doc = "[`reqwest`]: https://docs.rs/reqwest")] +#![cfg_attr(not(feature = "bitreq"), doc = "[`bitreq`]: https://docs.rs/bitreq")] #![allow(clippy::result_large_err)] #![warn(missing_docs)] +// TODO: (@oleonardolima) update the documentation regarding the features (above) accordingly. + use std::collections::HashMap; use std::fmt; use std::num::TryFromIntError; @@ -128,7 +129,7 @@ pub struct Builder { /// /// Note that the format of this value and the supported protocols change /// slightly between the blocking version of the client (using `minreq`) - /// and the async version (using `reqwest`). For more details check with + /// and the async version (using `bitreq`). For more details check with /// the documentation of the two crates. Both of them are compiled with /// the `socks` feature enabled. /// @@ -202,12 +203,9 @@ impl Builder { /// Errors that can happen during a request to `Esplora` servers. #[derive(Debug)] pub enum Error { - /// Error during `minreq` HTTP request - #[cfg(feature = "blocking")] - Minreq(minreq::Error), - /// Error during `reqwest` HTTP request - #[cfg(feature = "async")] - Reqwest(reqwest::Error), + /// Error during `bitreq` HTTP request + #[cfg(any(feature = "blocking", feature = "async"))] + BitReq(bitreq::Error), /// Error during JSON (de)serialization SerdeJson(serde_json::Error), /// HTTP response error @@ -261,10 +259,8 @@ macro_rules! impl_error { } impl std::error::Error for Error {} -#[cfg(feature = "blocking")] -impl_error!(::minreq::Error, Minreq, Error); -#[cfg(feature = "async")] -impl_error!(::reqwest::Error, Reqwest, Error); +#[cfg(any(feature = "blocking", feature = "async"))] +impl_error!(::bitreq::Error, BitReq, Error); impl_error!(serde_json::Error, SerdeJson, Error); impl_error!(std::num::ParseIntError, Parsing, Error); impl_error!(bitcoin::consensus::encode::Error, BitcoinEncoding, Error); @@ -704,6 +700,7 @@ mod test { let tx = blocking_client.get_tx(&txid).unwrap(); let async_res = async_client.broadcast(tx.as_ref().unwrap()).await; + println!("{:?}", async_res); let blocking_res = blocking_client.broadcast(tx.as_ref().unwrap()); assert!(async_res.is_err()); assert!(matches!(