1use 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#[derive(Default)]
39pub struct Client {
40 x_apikey: Option<String>,
41}
42
43impl Client {
44 pub fn x_apikey(mut self, apikey: impl ToString) -> Self {
46 self.x_apikey = Some(apikey.to_string());
47
48 self
49 }
50
51 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 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 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 pub async fn get_trades(&self) -> ApiResult<Vec<Trade>> {
95 self.get_trades_ex(None).await
96 }
97
98 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 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 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 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 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 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 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}