waterfalls_client/
lib.rs

1//! An extensible blocking/async Waterfalls client
2//!
3//! This library provides an extensible blocking and
4//! async Waterfalls client to query Waterfalls's backend.
5//!
6//! The library provides the possibility to build a blocking
7//! client using [`minreq`] and an async client using [`reqwest`].
8//! The library supports communicating to Waterfalls via a proxy
9//! and also using TLS (SSL) for secure communication.
10//!
11//!
12//! ## Usage
13//!
14//! You can create a blocking client as follows:
15//!
16//! ```no_run
17//! # #[cfg(feature = "blocking")]
18//! # {
19//! use waterfalls_client::Builder;
20//! let builder = Builder::new("https://blockstream.info/testnet/api");
21//! let blocking_client = builder.build_blocking();
22//! # Ok::<(), waterfalls_client::Error>(());
23//! # }
24//! ```
25//!
26//! Here is an example of how to create an asynchronous client.
27//!
28//! ```no_run
29//! # #[cfg(all(feature = "async", feature = "tokio"))]
30//! # {
31//! use waterfalls_client::Builder;
32//! let builder = Builder::new("https://blockstream.info/testnet/api");
33//! let async_client = builder.build_async();
34//! # Ok::<(), waterfalls_client::Error>(());
35//! # }
36//! ```
37//!
38//! ## Features
39//!
40//! By default the library enables all features. To specify
41//! specific features, set `default-features` to `false` in your `Cargo.toml`
42//! and specify the features you want. This will look like this:
43//!
44//! `waterfalls-client = { version = "*", default-features = false, features =
45//! ["blocking"] }`
46//!
47//! * `blocking` enables [`minreq`], the blocking client with proxy.
48//! * `blocking-https` enables [`minreq`], the blocking client with proxy and TLS (SSL) capabilities
49//!   using the default [`minreq`] backend.
50//! * `blocking-https-rustls` enables [`minreq`], the blocking client with proxy and TLS (SSL)
51//!   capabilities using the `rustls` backend.
52//! * `blocking-https-native` enables [`minreq`], the blocking client with proxy and TLS (SSL)
53//!   capabilities using the platform's native TLS backend (likely OpenSSL).
54//! * `blocking-https-bundled` enables [`minreq`], the blocking client with proxy and TLS (SSL)
55//!   capabilities using a bundled OpenSSL library backend.
56//! * `async` enables [`reqwest`], the async client with proxy capabilities.
57//! * `async-https` enables [`reqwest`], the async client with support for proxying and TLS (SSL)
58//!   using the default [`reqwest`] TLS backend.
59//! * `async-https-native` enables [`reqwest`], the async client with support for proxying and TLS
60//!   (SSL) using the platform's native TLS backend (likely OpenSSL).
61//! * `async-https-rustls` enables [`reqwest`], the async client with support for proxying and TLS
62//!   (SSL) using the `rustls` TLS backend.
63//! * `async-https-rustls-manual-roots` enables [`reqwest`], the async client with support for
64//!   proxying and TLS (SSL) using the `rustls` TLS backend without using its the default root
65//!   certificates.
66//!
67//! [`dont remove this line or cargo doc will break`]: https://example.com
68#![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
91/// Response status codes for which the request may be retried.
92pub const RETRYABLE_ERROR_CODES: [u16; 3] = [
93    429, // TOO_MANY_REQUESTS
94    500, // INTERNAL_SERVER_ERROR
95    503, // SERVICE_UNAVAILABLE
96];
97
98/// Base backoff in milliseconds.
99#[cfg(any(feature = "blocking", feature = "async"))]
100const BASE_BACKOFF_MILLIS: std::time::Duration = std::time::Duration::from_millis(256);
101
102/// Default max retries.
103const DEFAULT_MAX_RETRIES: usize = 6;
104
105#[derive(Debug, Clone)]
106pub struct Builder {
107    /// The URL of the Waterfalls server.
108    pub base_url: String,
109    /// Optional URL of the proxy to use to make requests to the Waterfalls server
110    ///
111    /// The string should be formatted as:
112    /// `<protocol>://<user>:<password>@host:<port>`.
113    ///
114    /// Note that the format of this value and the supported protocols change
115    /// slightly between the blocking version of the client (using `minreq`)
116    /// and the async version (using `reqwest`). For more details check with
117    /// the documentation of the two crates. Both of them are compiled with
118    /// the `socks` feature enabled.
119    ///
120    /// The proxy is ignored when targeting `wasm32`.
121    pub proxy: Option<String>,
122    /// Socket timeout.
123    pub timeout: Option<u64>,
124    /// HTTP headers to set on every request made to Waterfalls server.
125    pub headers: HashMap<String, String>,
126    /// Max retries
127    pub max_retries: usize,
128}
129
130impl Builder {
131    /// Instantiate a new builder
132    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    /// Set the proxy of the builder
143    pub fn proxy(mut self, proxy: &str) -> Self {
144        self.proxy = Some(proxy.to_string());
145        self
146    }
147
148    /// Set the timeout of the builder
149    pub fn timeout(mut self, timeout: u64) -> Self {
150        self.timeout = Some(timeout);
151        self
152    }
153
154    /// Add a header to set on each request
155    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    /// Set the maximum number of times to retry a request if the response status
161    /// is one of [`RETRYABLE_ERROR_CODES`].
162    pub fn max_retries(mut self, count: usize) -> Self {
163        self.max_retries = count;
164        self
165    }
166
167    /// Build a blocking client from builder
168    #[cfg(feature = "blocking")]
169    pub fn build_blocking(self) -> BlockingClient {
170        BlockingClient::from_builder(self)
171    }
172
173    /// Build an asynchronous client from builder
174    #[cfg(all(feature = "async", feature = "tokio"))]
175    pub fn build_async(self) -> Result<AsyncClient, Error> {
176        AsyncClient::from_builder(self)
177    }
178
179    /// Build an asynchronous client from builder where the returned client uses a
180    /// user-defined [`Sleeper`].
181    #[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/// Errors that can happen during a request to `Waterfalls` servers.
188#[derive(Debug)]
189pub enum Error {
190    /// Error during `minreq` HTTP request
191    #[cfg(feature = "blocking")]
192    Minreq(::minreq::Error),
193    /// Error during reqwest HTTP request
194    #[cfg(feature = "async")]
195    Reqwest(::reqwest::Error),
196    /// HTTP response error
197    HttpResponse { status: u16, message: String },
198    /// Invalid number returned
199    Parsing(std::num::ParseIntError),
200    /// Invalid status code, unable to convert to `u16`
201    StatusCode(TryFromIntError),
202    /// Invalid Bitcoin data returned
203    BitcoinEncoding(bitcoin::consensus::encode::Error),
204    /// Invalid hex data returned (attempting to create an array)
205    HexToArray(bitcoin::hex::HexToArrayError),
206    /// Invalid hex data returned (attempting to create a vector)
207    HexToBytes(bitcoin::hex::HexToBytesError),
208    /// Transaction not found
209    TransactionNotFound(Txid),
210    /// Block Header height not found
211    HeaderHeightNotFound(u32),
212    /// Block Header hash not found
213    HeaderHashNotFound(BlockHash),
214    /// Invalid HTTP Header name specified
215    InvalidHttpHeaderName(String),
216    /// Invalid HTTP Header value specified
217    InvalidHttpHeaderValue(String),
218    /// The server sent an invalid response
219    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)); // TOO_MANY_REQUESTS
304        assert!(RETRYABLE_ERROR_CODES.contains(&500)); // INTERNAL_SERVER_ERROR
305        assert!(RETRYABLE_ERROR_CODES.contains(&503)); // SERVICE_UNAVAILABLE
306        assert!(!RETRYABLE_ERROR_CODES.contains(&404)); // NOT_FOUND should not be retryable
307    }
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); // -(3+1)
320
321        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        // Empty response
333        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        // Response with empty vectors
342        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        // Response with actual transaction
353        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        // Just test that it doesn't panic
380    }
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        // Just test that it doesn't panic
388    }
389}