1#![cfg_attr(not(feature = "minreq"), doc = "[`minreq`]: https://docs.rs/minreq")]
69#![cfg_attr(not(feature = "reqwest"), doc = "[`reqwest`]: https://docs.rs/reqwest")]
70#![allow(clippy::result_large_err)]
71
72use std::collections::HashMap;
73use std::fmt;
74use std::num::TryFromIntError;
75
76#[cfg(feature = "async")]
77pub use r#async::Sleeper;
78
79pub mod api;
80#[cfg(feature = "async")]
81pub mod r#async;
82#[cfg(feature = "blocking")]
83pub mod blocking;
84
85pub use api::*;
86#[cfg(feature = "blocking")]
87pub use blocking::BlockingClient;
88#[cfg(feature = "async")]
89pub use r#async::AsyncClient;
90
91pub const RETRYABLE_ERROR_CODES: [u16; 3] = [
93 429, 500, 503, ];
97
98#[cfg(any(feature = "blocking", feature = "async"))]
100const BASE_BACKOFF_MILLIS: std::time::Duration = std::time::Duration::from_millis(256);
101
102const DEFAULT_MAX_RETRIES: usize = 6;
104
105#[derive(Debug, Clone)]
106pub struct Builder {
107 pub base_url: String,
109 pub proxy: Option<String>,
122 pub timeout: Option<u64>,
124 pub headers: HashMap<String, String>,
126 pub max_retries: usize,
128}
129
130impl Builder {
131 pub fn new(base_url: &str) -> Self {
133 Builder {
134 base_url: base_url.to_string(),
135 proxy: None,
136 timeout: None,
137 headers: HashMap::new(),
138 max_retries: DEFAULT_MAX_RETRIES,
139 }
140 }
141
142 pub fn proxy(mut self, proxy: &str) -> Self {
144 self.proxy = Some(proxy.to_string());
145 self
146 }
147
148 pub fn timeout(mut self, timeout: u64) -> Self {
150 self.timeout = Some(timeout);
151 self
152 }
153
154 pub fn header(mut self, key: &str, value: &str) -> Self {
156 self.headers.insert(key.to_string(), value.to_string());
157 self
158 }
159
160 pub fn max_retries(mut self, count: usize) -> Self {
163 self.max_retries = count;
164 self
165 }
166
167 #[cfg(feature = "blocking")]
169 pub fn build_blocking(self) -> BlockingClient {
170 BlockingClient::from_builder(self)
171 }
172
173 #[cfg(all(feature = "async", feature = "tokio"))]
175 pub fn build_async(self) -> Result<AsyncClient, Error> {
176 AsyncClient::from_builder(self)
177 }
178
179 #[cfg(feature = "async")]
182 pub fn build_async_with_sleeper<S: Sleeper>(self) -> Result<AsyncClient<S>, Error> {
183 AsyncClient::from_builder(self)
184 }
185}
186
187#[derive(Debug)]
189pub enum Error {
190 #[cfg(feature = "blocking")]
192 Minreq(::minreq::Error),
193 #[cfg(feature = "async")]
195 Reqwest(::reqwest::Error),
196 HttpResponse { status: u16, message: String },
198 Parsing(std::num::ParseIntError),
200 StatusCode(TryFromIntError),
202 BitcoinEncoding(bitcoin::consensus::encode::Error),
204 HexToArray(bitcoin::hex::HexToArrayError),
206 HexToBytes(bitcoin::hex::HexToBytesError),
208 TransactionNotFound(Txid),
210 HeaderHeightNotFound(u32),
212 HeaderHashNotFound(BlockHash),
214 InvalidHttpHeaderName(String),
216 InvalidHttpHeaderValue(String),
218 InvalidResponse,
220}
221
222impl fmt::Display for Error {
223 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224 write!(f, "{self:?}")
225 }
226}
227
228macro_rules! impl_error {
229 ( $from:ty, $to:ident ) => {
230 impl_error!($from, $to, Error);
231 };
232 ( $from:ty, $to:ident, $impl_for:ty ) => {
233 impl std::convert::From<$from> for $impl_for {
234 fn from(err: $from) -> Self {
235 <$impl_for>::$to(err)
236 }
237 }
238 };
239}
240
241impl std::error::Error for Error {}
242#[cfg(feature = "blocking")]
243impl_error!(::minreq::Error, Minreq, Error);
244#[cfg(feature = "async")]
245impl_error!(::reqwest::Error, Reqwest, Error);
246impl_error!(std::num::ParseIntError, Parsing, Error);
247impl_error!(bitcoin::consensus::encode::Error, BitcoinEncoding, Error);
248impl_error!(bitcoin::hex::HexToArrayError, HexToArray, Error);
249impl_error!(bitcoin::hex::HexToBytesError, HexToBytes, Error);
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use std::collections::HashMap;
255 use std::str::FromStr;
256
257 #[test]
258 fn test_builder() {
259 let builder = Builder::new("https://waterfalls.example.com/api");
260 assert_eq!(builder.base_url, "https://waterfalls.example.com/api");
261 assert_eq!(builder.proxy, None);
262 assert_eq!(builder.timeout, None);
263 assert_eq!(builder.max_retries, DEFAULT_MAX_RETRIES);
264 assert!(builder.headers.is_empty());
265 }
266
267 #[test]
268 fn test_builder_with_proxy() {
269 let builder =
270 Builder::new("https://waterfalls.example.com/api").proxy("socks5://127.0.0.1:9050");
271 assert_eq!(builder.proxy, Some("socks5://127.0.0.1:9050".to_string()));
272 }
273
274 #[test]
275 fn test_builder_with_timeout() {
276 let builder = Builder::new("https://waterfalls.example.com/api").timeout(30);
277 assert_eq!(builder.timeout, Some(30));
278 }
279
280 #[test]
281 fn test_builder_with_headers() {
282 let builder = Builder::new("https://waterfalls.example.com/api")
283 .header("User-Agent", "test-client")
284 .header("Authorization", "Bearer token");
285
286 let expected_headers: HashMap<String, String> = [
287 ("User-Agent".to_string(), "test-client".to_string()),
288 ("Authorization".to_string(), "Bearer token".to_string()),
289 ]
290 .into();
291
292 assert_eq!(builder.headers, expected_headers);
293 }
294
295 #[test]
296 fn test_builder_with_max_retries() {
297 let builder = Builder::new("https://waterfalls.example.com/api").max_retries(10);
298 assert_eq!(builder.max_retries, 10);
299 }
300
301 #[test]
302 fn test_retryable_error_codes() {
303 assert!(RETRYABLE_ERROR_CODES.contains(&429)); assert!(RETRYABLE_ERROR_CODES.contains(&500)); assert!(RETRYABLE_ERROR_CODES.contains(&503)); assert!(!RETRYABLE_ERROR_CODES.contains(&404)); }
308
309 #[test]
310 fn test_v_serialization() {
311 use crate::api::V;
312
313 let undefined = V::Undefined;
314 let vout = V::Vout(5);
315 let vin = V::Vin(3);
316
317 assert_eq!(undefined.raw(), 0);
318 assert_eq!(vout.raw(), 5);
319 assert_eq!(vin.raw(), -4); assert_eq!(V::from_raw(0), V::Undefined);
322 assert_eq!(V::from_raw(5), V::Vout(5));
323 assert_eq!(V::from_raw(-4), V::Vin(3));
324 }
325
326 #[test]
327 fn test_waterfall_response_is_empty() {
328 use crate::api::{TxSeen, WaterfallResponse, V};
329 use bitcoin::Txid;
330 use std::collections::BTreeMap;
331
332 let empty_response = WaterfallResponse {
334 txs_seen: BTreeMap::new(),
335 page: 0,
336 tip: None,
337 tip_meta: None,
338 };
339 assert!(empty_response.is_empty());
340
341 let mut txs_seen = BTreeMap::new();
343 txs_seen.insert("key1".to_string(), vec![vec![]]);
344 let empty_vectors_response = WaterfallResponse {
345 txs_seen,
346 page: 0,
347 tip: None,
348 tip_meta: None,
349 };
350 assert!(empty_vectors_response.is_empty());
351
352 let mut txs_seen = BTreeMap::new();
354 let tx_seen = TxSeen {
355 txid: Txid::from_str(
356 "0000000000000000000000000000000000000000000000000000000000000000",
357 )
358 .unwrap(),
359 height: 100,
360 block_hash: None,
361 block_timestamp: None,
362 v: V::Undefined,
363 };
364 txs_seen.insert("key1".to_string(), vec![vec![tx_seen]]);
365 let non_empty_response = WaterfallResponse {
366 txs_seen,
367 page: 0,
368 tip: None,
369 tip_meta: None,
370 };
371 assert!(!non_empty_response.is_empty());
372 }
373
374 #[cfg(feature = "blocking")]
375 #[test]
376 fn test_blocking_client_creation() {
377 let builder = Builder::new("https://waterfalls.example.com/api");
378 let _client = builder.build_blocking();
379 }
381
382 #[cfg(all(feature = "async", feature = "tokio"))]
383 #[tokio::test]
384 async fn test_async_client_creation() {
385 let builder = Builder::new("https://waterfalls.example.com/api");
386 let _client = builder.build_async();
387 }
389}