bitpanda_api/api/
client.rs

1//! # Bitpanda API client
2
3use async_recursion::async_recursion;
4
5use super::{ApiError, ApiResult};
6use crate::model::crypto_wallet::CryptoWalletTransaction;
7use crate::model::fiat_wallet::FiatWalletTransaction;
8use crate::model::ohlc::Period;
9use crate::model::{
10    Asset, AssetClass, AssetWallet, CryptoWallet, FiatWallet, OpenHighLowCloseChart, Trade,
11    TransactionStatus, TransactionType,
12};
13
14mod asset_wallet_response;
15mod crypto_wallet_response;
16mod crypto_wallet_tx_response;
17mod fiat_wallet_response;
18mod fiat_wallet_tx_response;
19mod get_assets_response;
20mod get_ohlc_response;
21mod trade_response;
22
23use asset_wallet_response::AssetWalletResponse;
24use crypto_wallet_response::CryptoWalletResponse;
25use crypto_wallet_tx_response::CryptoWalletTxResponse;
26use fiat_wallet_response::FiatWalletResponse;
27use fiat_wallet_tx_response::FiatWalletTxResponse;
28use get_assets_response::GetAssetsResponse;
29use get_ohlc_response::GetOhlcResponse;
30use trade_response::TradeResponse;
31
32const BITPANDA_API_URL: &str = "https://api.bitpanda.com/v1";
33const BITPANDA_PUBLIC_URL: &str = "https://api.bitpanda.com";
34const TRADE_DEFAULT_PAGE_SIZE: usize = 25;
35const ASSETS_DEFAULT_PAGE_SIZE: usize = 500;
36
37/// Bitpanda api client
38#[derive(Default)]
39pub struct Client {
40    x_apikey: Option<String>,
41}
42
43impl Client {
44    /// Construct client with x-apikey
45    pub fn x_apikey(mut self, apikey: impl ToString) -> Self {
46        self.x_apikey = Some(apikey.to_string());
47
48        self
49    }
50
51    // requests
52
53    /// Get asset wallets for user.
54    /// Requires APIKEY
55    pub async fn get_asset_wallets(&self) -> ApiResult<Vec<AssetWallet>> {
56        let response: AssetWalletResponse = self
57            .request_with_auth("asset-wallets")?
58            .send()
59            .await?
60            .json()
61            .await?;
62
63        Ok(response.into_asset_wallets())
64    }
65
66    /// Get crypto wallets
67    /// Requires APIKEY
68    pub async fn get_crypto_wallets(&self) -> ApiResult<Vec<CryptoWallet>> {
69        let response: CryptoWalletResponse = self
70            .request_with_auth("wallets")?
71            .send()
72            .await?
73            .json()
74            .await?;
75
76        Ok(response.into_crypto_wallets())
77    }
78
79    /// Get FIAT wallets
80    /// Requires APIKEY
81    pub async fn get_fiat_wallets(&self) -> ApiResult<Vec<FiatWallet>> {
82        let response: FiatWalletResponse = self
83            .request_with_auth("fiatwallets")?
84            .send()
85            .await?
86            .json()
87            .await?;
88
89        Ok(response.into_fiat_wallets())
90    }
91
92    /// get user's trades.
93    /// Requires APIKEY
94    pub async fn get_trades(&self) -> ApiResult<Vec<Trade>> {
95        self.get_trades_ex(None).await
96    }
97
98    /// get user's trades.
99    /// If max_results is specified, only the amount of trades specified are fetched
100    /// Requires APIKEY
101    pub async fn get_trades_ex(&self, max_results: Option<usize>) -> ApiResult<Vec<Trade>> {
102        self.do_get_trades(vec![], 0, max_results).await
103    }
104
105    /// Get crypto wallet transactions
106    /// Requires APIKEY
107    pub async fn get_crypto_wallet_transactions(&self) -> ApiResult<Vec<CryptoWalletTransaction>> {
108        self.get_crypto_wallet_transactions_ex(None, None, None)
109            .await
110    }
111
112    /// Get crypto wallet transactions
113    /// if specified, get transactions with provided filters
114    /// Requires APIKEY
115    pub async fn get_crypto_wallet_transactions_ex(
116        &self,
117        transaction_type: Option<TransactionType>,
118        status: Option<TransactionStatus>,
119        max_results: Option<usize>,
120    ) -> ApiResult<Vec<CryptoWalletTransaction>> {
121        self.do_get_crypto_wallet_transactions(vec![], transaction_type, status, 0, max_results)
122            .await
123    }
124
125    /// Get fiat wallet transactions
126    /// Requires APIKEY
127    pub async fn get_fiat_wallet_transactions(&self) -> ApiResult<Vec<FiatWalletTransaction>> {
128        self.get_fiat_wallet_transactions_ex(None, None, None).await
129    }
130
131    /// Get fiat wallet transactions
132    /// if specified, get transactions with provided filters
133    /// Requires APIKEY
134    pub async fn get_fiat_wallet_transactions_ex(
135        &self,
136        transaction_type: Option<TransactionType>,
137        status: Option<TransactionStatus>,
138        max_results: Option<usize>,
139    ) -> ApiResult<Vec<FiatWalletTransaction>> {
140        self.do_get_fiat_wallet_transactions(vec![], transaction_type, status, 0, max_results)
141            .await
142    }
143
144    /// Get assets available on Bitpanda by class
145    pub async fn get_assets(&self, asset_class: AssetClass) -> ApiResult<Vec<Asset>> {
146        self.do_get_assets(vec![], asset_class, 0).await
147    }
148
149    /// get OHLC for provided symbols
150    pub async fn get_ohlc(
151        &self,
152        period: Period,
153        pid: &str,
154        currency: &str,
155    ) -> ApiResult<OpenHighLowCloseChart> {
156        let url = format!("ohlc/{pid}/{currency}/{}", period.to_string());
157
158        Ok(self
159            .pub_request_v3(url)
160            .send()
161            .await?
162            .json::<GetOhlcResponse>()
163            .await?
164            .into_ohlc(period))
165    }
166
167    #[async_recursion]
168    async fn do_get_trades(
169        &self,
170        mut trades: Vec<Trade>,
171        page: usize,
172        max_results: Option<usize>,
173    ) -> ApiResult<Vec<Trade>> {
174        let page_size = match max_results {
175            Some(sz) if trades.len() + TRADE_DEFAULT_PAGE_SIZE > sz => {
176                sz.checked_sub(trades.len()).unwrap_or_default()
177            }
178            Some(_) | None => TRADE_DEFAULT_PAGE_SIZE,
179        };
180        let url = format!("trades?page={page}&page_size={page_size}");
181        trace!("next get trade url: {url}");
182
183        let response: TradeResponse = self.request_with_auth(url)?.send().await?.json().await?;
184
185        let next_page = response.next_page();
186
187        trades.extend(response.into_trades()?);
188
189        if let Some(max_results) = max_results {
190            if trades.len() >= max_results {
191                return Ok(trades);
192            }
193        }
194
195        if let Some(page) = next_page {
196            trace!("there are still trades to be fetched");
197            self.do_get_trades(trades, page, max_results).await
198        } else {
199            Ok(trades)
200        }
201    }
202
203    #[async_recursion]
204    async fn do_get_crypto_wallet_transactions(
205        &self,
206        mut txs: Vec<CryptoWalletTransaction>,
207        transaction_type: Option<TransactionType>,
208        status: Option<TransactionStatus>,
209        page: usize,
210        max_results: Option<usize>,
211    ) -> ApiResult<Vec<CryptoWalletTransaction>> {
212        let page_size = match max_results {
213            Some(sz) if txs.len() + TRADE_DEFAULT_PAGE_SIZE > sz => {
214                sz.checked_sub(txs.len()).unwrap_or_default()
215            }
216            Some(_) | None => TRADE_DEFAULT_PAGE_SIZE,
217        };
218
219        let transaction_type_arg = transaction_type
220            .map(|t| format!("&type={}", t.to_string()))
221            .unwrap_or_default();
222
223        let status_arg = status
224            .map(|s| format!("&status={}", s.to_string()))
225            .unwrap_or_default();
226
227        let url = format!("wallets/transactions?page={page}&page_size={page_size}{transaction_type_arg}{status_arg}");
228        trace!("next get crypto transactions url: {url}");
229
230        let response: CryptoWalletTxResponse =
231            self.request_with_auth(url)?.send().await?.json().await?;
232
233        let next_page = response.next_page();
234
235        txs.extend(response.into_transactions()?);
236
237        if let Some(max_results) = max_results {
238            if txs.len() >= max_results {
239                return Ok(txs);
240            }
241        }
242
243        if let Some(page) = next_page {
244            trace!("there are still tx to be fetched");
245            self.do_get_crypto_wallet_transactions(txs, transaction_type, status, page, max_results)
246                .await
247        } else {
248            Ok(txs)
249        }
250    }
251
252    #[async_recursion]
253    async fn do_get_fiat_wallet_transactions(
254        &self,
255        mut txs: Vec<FiatWalletTransaction>,
256        transaction_type: Option<TransactionType>,
257        status: Option<TransactionStatus>,
258        page: usize,
259        max_results: Option<usize>,
260    ) -> ApiResult<Vec<FiatWalletTransaction>> {
261        let page_size = match max_results {
262            Some(sz) if txs.len() + TRADE_DEFAULT_PAGE_SIZE > sz => {
263                sz.checked_sub(txs.len()).unwrap_or_default()
264            }
265            Some(_) | None => TRADE_DEFAULT_PAGE_SIZE,
266        };
267
268        let transaction_type_arg = transaction_type
269            .map(|t| format!("&type={}", t.to_string()))
270            .unwrap_or_default();
271
272        let status_arg = status
273            .map(|s| format!("&status={}", s.to_string()))
274            .unwrap_or_default();
275
276        let url = format!("fiatwallets/transactions?page={page}&page_size={page_size}{transaction_type_arg}{status_arg}");
277        trace!("next get crypto transactions url: {url}");
278
279        let response: FiatWalletTxResponse =
280            self.request_with_auth(url)?.send().await?.json().await?;
281        let next_page = response.next_page();
282
283        txs.extend(response.into_transactions()?);
284
285        if let Some(max_results) = max_results {
286            if txs.len() >= max_results {
287                return Ok(txs);
288            }
289        }
290
291        if let Some(page) = next_page {
292            trace!("there are still tx to be fetched");
293            self.do_get_fiat_wallet_transactions(txs, transaction_type, status, page, max_results)
294                .await
295        } else {
296            Ok(txs)
297        }
298    }
299
300    #[async_recursion]
301    async fn do_get_assets(
302        &self,
303        mut assets: Vec<Asset>,
304        asset_class: AssetClass,
305        page: usize,
306    ) -> ApiResult<Vec<Asset>> {
307        let url = format!(
308            "assets?page={page}&page_size={ASSETS_DEFAULT_PAGE_SIZE}&type[]={}",
309            asset_class.to_string()
310        );
311        trace!("next get assets url: {url}");
312
313        let response: GetAssetsResponse = self.pub_request_v3(url).send().await?.json().await?;
314
315        let next_page = response.next_page();
316
317        assets.extend(response.into_assets(asset_class));
318
319        if let Some(page) = next_page {
320            trace!("there are still assets to be fetched");
321            self.do_get_assets(assets, asset_class, page).await
322        } else {
323            Ok(assets)
324        }
325    }
326
327    fn priv_request(&self, url: impl ToString) -> reqwest::RequestBuilder {
328        reqwest::Client::new().get(format!("{BITPANDA_API_URL}/{}", url.to_string()))
329    }
330
331    fn pub_request_v3(&self, url: impl ToString) -> reqwest::RequestBuilder {
332        reqwest::Client::new().get(format!("{BITPANDA_PUBLIC_URL}/v3/{}", url.to_string()))
333    }
334
335    fn request_with_auth(&self, url: impl ToString) -> ApiResult<reqwest::RequestBuilder> {
336        if let Some(apikey) = &self.x_apikey {
337            Ok(self.priv_request(url).header("X-API-KEY", apikey))
338        } else {
339            Err(ApiError::Unauthorized)
340        }
341    }
342}
343
344#[cfg(test)]
345mod test {
346
347    use super::*;
348
349    use pretty_assertions::assert_eq;
350
351    #[tokio::test]
352    async fn should_get_asset_wallets() {
353        assert!(client().get_asset_wallets().await.is_ok());
354    }
355
356    #[tokio::test]
357    async fn should_get_crypto_wallets() {
358        assert!(client().get_crypto_wallets().await.is_ok());
359    }
360
361    #[tokio::test]
362    async fn should_get_fiat_wallets() {
363        assert!(client().get_fiat_wallets().await.is_ok());
364    }
365
366    #[tokio::test]
367    async fn should_get_all_trades() {
368        assert!(client().get_trades().await.is_ok());
369    }
370
371    #[tokio::test]
372    async fn should_get_limited_trades() {
373        assert_eq!(client().get_trades_ex(Some(128)).await.unwrap().len(), 128);
374    }
375
376    #[tokio::test]
377    async fn should_get_crypto_transactions() {
378        assert!(client().get_crypto_wallet_transactions().await.is_ok());
379    }
380
381    #[tokio::test]
382    async fn should_get_crypto_transactions_limited() {
383        assert_eq!(
384            client()
385                .get_crypto_wallet_transactions_ex(None, None, Some(45))
386                .await
387                .unwrap()
388                .len(),
389            45
390        );
391    }
392
393    #[tokio::test]
394    async fn should_get_crypto_transactions_by_type() {
395        assert!(client()
396            .get_crypto_wallet_transactions_ex(Some(TransactionType::Buy), None, Some(25))
397            .await
398            .unwrap()
399            .iter()
400            .all(|t| t.transaction_type == TransactionType::Buy));
401    }
402
403    #[tokio::test]
404    async fn should_get_crypto_transactions_by_status() {
405        assert!(client()
406            .get_crypto_wallet_transactions_ex(None, Some(TransactionStatus::Canceled), Some(25))
407            .await
408            .unwrap()
409            .iter()
410            .all(|t| t.status == TransactionStatus::Canceled));
411    }
412
413    #[tokio::test]
414    async fn should_get_fiat_transactions_limited() {
415        assert_eq!(
416            client()
417                .get_fiat_wallet_transactions_ex(None, None, Some(45))
418                .await
419                .unwrap()
420                .len(),
421            45
422        );
423    }
424
425    #[tokio::test]
426    async fn should_get_fiat_transactions_by_type() {
427        assert!(client()
428            .get_fiat_wallet_transactions_ex(Some(TransactionType::Buy), None, Some(25))
429            .await
430            .unwrap()
431            .iter()
432            .all(|t| t.transaction_type == TransactionType::Buy));
433    }
434
435    #[tokio::test]
436    async fn should_get_fiat_transactions_by_status() {
437        assert!(client()
438            .get_fiat_wallet_transactions_ex(None, Some(TransactionStatus::Canceled), Some(25))
439            .await
440            .unwrap()
441            .iter()
442            .all(|t| t.status == TransactionStatus::Canceled));
443    }
444
445    #[tokio::test]
446    async fn should_get_assets() {
447        assert!(client()
448            .get_assets(AssetClass::Cryptocurrency)
449            .await
450            .is_ok());
451    }
452
453    #[tokio::test]
454    async fn should_get_ohlc_for_btc() {
455        let client = client();
456
457        let btc = client
458            .get_assets(AssetClass::Cryptocurrency)
459            .await
460            .unwrap()
461            .into_iter()
462            .find(|asset| asset.symbol == "BTC")
463            .unwrap();
464
465        assert!(client.get_ohlc(Period::Day, &btc.pid, "EUR").await.is_ok());
466        assert!(client.get_ohlc(Period::Week, &btc.pid, "EUR").await.is_ok());
467        assert!(client
468            .get_ohlc(Period::Month, &btc.pid, "EUR")
469            .await
470            .is_ok());
471        assert!(client.get_ohlc(Period::Year, &btc.pid, "EUR").await.is_ok());
472        assert!(client
473            .get_ohlc(Period::FiveYears, &btc.pid, "EUR")
474            .await
475            .is_ok());
476    }
477
478    #[tokio::test]
479    async fn should_return_error_if_unauthorized() {
480        let client = Client::default();
481        assert!(client.get_asset_wallets().await.is_err());
482    }
483
484    fn client() -> Client {
485        log_init();
486        Client::default().x_apikey(env!("X_API_KEY"))
487    }
488
489    fn log_init() {
490        let _ = env_logger::builder().is_test(true).try_init();
491    }
492}