#![cfg_attr(not(feature = "minreq"), doc = "[`minreq`]: https://docs.rs/minreq")]
#![cfg_attr(not(feature = "reqwest"), doc = "[`reqwest`]: https://docs.rs/reqwest")]
#![allow(clippy::result_large_err)]
use std::collections::HashMap;
use std::fmt;
use std::num::TryFromIntError;
#[cfg(feature = "async")]
pub use r#async::Sleeper;
pub mod api;
#[cfg(feature = "async")]
pub mod r#async;
#[cfg(feature = "blocking")]
pub mod blocking;
pub use api::*;
#[cfg(feature = "blocking")]
pub use blocking::BlockingClient;
#[cfg(feature = "async")]
pub use r#async::AsyncClient;
pub const RETRYABLE_ERROR_CODES: [u16; 3] = [
429, 500, 503, ];
#[cfg(any(feature = "blocking", feature = "async"))]
const BASE_BACKOFF_MILLIS: std::time::Duration = std::time::Duration::from_millis(256);
const DEFAULT_MAX_RETRIES: usize = 6;
#[derive(Debug, Clone)]
pub struct Builder {
pub base_url: String,
pub proxy: Option<String>,
pub timeout: Option<u64>,
pub headers: HashMap<String, String>,
pub max_retries: usize,
}
impl Builder {
pub fn new(base_url: &str) -> Self {
Builder {
base_url: base_url.to_string(),
proxy: None,
timeout: None,
headers: HashMap::new(),
max_retries: DEFAULT_MAX_RETRIES,
}
}
pub fn proxy(mut self, proxy: &str) -> Self {
self.proxy = Some(proxy.to_string());
self
}
pub fn timeout(mut self, timeout: u64) -> Self {
self.timeout = Some(timeout);
self
}
pub fn header(mut self, key: &str, value: &str) -> Self {
self.headers.insert(key.to_string(), value.to_string());
self
}
pub fn max_retries(mut self, count: usize) -> Self {
self.max_retries = count;
self
}
#[cfg(feature = "blocking")]
pub fn build_blocking(self) -> BlockingClient {
BlockingClient::from_builder(self)
}
#[cfg(all(feature = "async", feature = "tokio"))]
pub fn build_async(self) -> Result<AsyncClient, Error> {
AsyncClient::from_builder(self)
}
#[cfg(feature = "async")]
pub fn build_async_with_sleeper<S: Sleeper>(self) -> Result<AsyncClient<S>, Error> {
AsyncClient::from_builder(self)
}
}
#[derive(Debug)]
pub enum Error {
#[cfg(feature = "blocking")]
Minreq(::minreq::Error),
#[cfg(feature = "async")]
Reqwest(::reqwest::Error),
HttpResponse { status: u16, message: String },
Parsing(std::num::ParseIntError),
StatusCode(TryFromIntError),
BitcoinEncoding(bitcoin::consensus::encode::Error),
HexToArray(bitcoin::hex::HexToArrayError),
HexToBytes(bitcoin::hex::HexToBytesError),
TransactionNotFound(Txid),
HeaderHeightNotFound(u32),
HeaderHashNotFound(BlockHash),
InvalidHttpHeaderName(String),
InvalidHttpHeaderValue(String),
InvalidResponse,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{self:?}")
}
}
macro_rules! impl_error {
( $from:ty, $to:ident ) => {
impl_error!($from, $to, Error);
};
( $from:ty, $to:ident, $impl_for:ty ) => {
impl std::convert::From<$from> for $impl_for {
fn from(err: $from) -> Self {
<$impl_for>::$to(err)
}
}
};
}
impl std::error::Error for Error {}
#[cfg(feature = "blocking")]
impl_error!(::minreq::Error, Minreq, Error);
#[cfg(feature = "async")]
impl_error!(::reqwest::Error, Reqwest, Error);
impl_error!(std::num::ParseIntError, Parsing, Error);
impl_error!(bitcoin::consensus::encode::Error, BitcoinEncoding, Error);
impl_error!(bitcoin::hex::HexToArrayError, HexToArray, Error);
impl_error!(bitcoin::hex::HexToBytesError, HexToBytes, Error);
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::str::FromStr;
#[test]
fn test_builder() {
let builder = Builder::new("https://waterfalls.example.com/api");
assert_eq!(builder.base_url, "https://waterfalls.example.com/api");
assert_eq!(builder.proxy, None);
assert_eq!(builder.timeout, None);
assert_eq!(builder.max_retries, DEFAULT_MAX_RETRIES);
assert!(builder.headers.is_empty());
}
#[test]
fn test_builder_with_proxy() {
let builder =
Builder::new("https://waterfalls.example.com/api").proxy("socks5://127.0.0.1:9050");
assert_eq!(builder.proxy, Some("socks5://127.0.0.1:9050".to_string()));
}
#[test]
fn test_builder_with_timeout() {
let builder = Builder::new("https://waterfalls.example.com/api").timeout(30);
assert_eq!(builder.timeout, Some(30));
}
#[test]
fn test_builder_with_headers() {
let builder = Builder::new("https://waterfalls.example.com/api")
.header("User-Agent", "test-client")
.header("Authorization", "Bearer token");
let expected_headers: HashMap<String, String> = [
("User-Agent".to_string(), "test-client".to_string()),
("Authorization".to_string(), "Bearer token".to_string()),
]
.into();
assert_eq!(builder.headers, expected_headers);
}
#[test]
fn test_builder_with_max_retries() {
let builder = Builder::new("https://waterfalls.example.com/api").max_retries(10);
assert_eq!(builder.max_retries, 10);
}
#[test]
fn test_retryable_error_codes() {
assert!(RETRYABLE_ERROR_CODES.contains(&429)); assert!(RETRYABLE_ERROR_CODES.contains(&500)); assert!(RETRYABLE_ERROR_CODES.contains(&503)); assert!(!RETRYABLE_ERROR_CODES.contains(&404)); }
#[test]
fn test_v_serialization() {
use crate::api::V;
let undefined = V::Undefined;
let vout = V::Vout(5);
let vin = V::Vin(3);
assert_eq!(undefined.raw(), 0);
assert_eq!(vout.raw(), 5);
assert_eq!(vin.raw(), -4);
assert_eq!(V::from_raw(0), V::Undefined);
assert_eq!(V::from_raw(5), V::Vout(5));
assert_eq!(V::from_raw(-4), V::Vin(3));
}
#[test]
fn test_waterfall_response_is_empty() {
use crate::api::{TxSeen, WaterfallResponse, V};
use bitcoin::Txid;
use std::collections::BTreeMap;
let empty_response = WaterfallResponse {
txs_seen: BTreeMap::new(),
page: 0,
tip: None,
tip_meta: None,
};
assert!(empty_response.is_empty());
let mut txs_seen = BTreeMap::new();
txs_seen.insert("key1".to_string(), vec![vec![]]);
let empty_vectors_response = WaterfallResponse {
txs_seen,
page: 0,
tip: None,
tip_meta: None,
};
assert!(empty_vectors_response.is_empty());
let mut txs_seen = BTreeMap::new();
let tx_seen = TxSeen {
txid: Txid::from_str(
"0000000000000000000000000000000000000000000000000000000000000000",
)
.unwrap(),
height: 100,
block_hash: None,
block_timestamp: None,
v: V::Undefined,
};
txs_seen.insert("key1".to_string(), vec![vec![tx_seen]]);
let non_empty_response = WaterfallResponse {
txs_seen,
page: 0,
tip: None,
tip_meta: None,
};
assert!(!non_empty_response.is_empty());
}
#[cfg(feature = "blocking")]
#[test]
fn test_blocking_client_creation() {
let builder = Builder::new("https://waterfalls.example.com/api");
let _client = builder.build_blocking();
}
#[cfg(all(feature = "async", feature = "tokio"))]
#[tokio::test]
async fn test_async_client_creation() {
let builder = Builder::new("https://waterfalls.example.com/api");
let _client = builder.build_async();
}
}