use std::collections::HashMap;
use async_trait::async_trait;
use serde::Deserialize;
use crate::price::config::ProviderConfig;
use crate::price::provider::{PriceProvider, ProviderError, ProviderId, ProviderQuotes, Quote};
#[derive(Debug, Deserialize)]
struct TickerEntry {
last: Option<f64>,
}
#[derive(Debug)]
pub struct BlockchainProvider {
url: String,
}
impl BlockchainProvider {
pub fn new(cfg: &ProviderConfig) -> Self {
Self {
url: cfg.url.trim_end_matches('/').to_string(),
}
}
pub(crate) fn parse(body: &str) -> Result<ProviderQuotes, ProviderError> {
let parsed: HashMap<String, TickerEntry> = serde_json::from_str(body)
.map_err(|e| ProviderError::Parse(format!("blockchain: {e}")))?;
Ok(parsed
.into_iter()
.filter_map(|(code, entry)| match entry.last {
Some(v) if v.is_finite() && v > 0.0 => {
Some((code.to_uppercase(), Quote::PerBtc(v)))
}
_ => None,
})
.collect())
}
}
#[async_trait]
impl PriceProvider for BlockchainProvider {
fn id(&self) -> ProviderId {
ProviderId::Blockchain
}
async fn fetch(&self, http: &reqwest::Client) -> Result<ProviderQuotes, ProviderError> {
let url = format!("{}/ticker", self.url);
let res = http
.get(&url)
.send()
.await
.map_err(|e| ProviderError::Http(format!("blockchain GET {url}: {e}")))?;
if !res.status().is_success() {
return Err(ProviderError::Http(format!(
"blockchain GET {url}: status {}",
res.status()
)));
}
let body = res
.text()
.await
.map_err(|e| ProviderError::Http(format!("blockchain read body: {e}")))?;
Self::parse(&body)
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_PAYLOAD: &str =
include_str!("../../../tests/fixtures/price/blockchain_ticker.json");
#[test]
fn parses_captured_payload_taking_last() {
let quotes = BlockchainProvider::parse(SAMPLE_PAYLOAD).expect("fixture must parse");
assert!(quotes.len() >= 20, "expected the major-fiat set");
assert!(quotes.contains_key("USD"));
assert!(quotes.contains_key("EUR"));
assert!(quotes.contains_key("JPY"));
assert!(!quotes.contains_key("CUP"));
assert!(!quotes.contains_key("MLC"));
}
#[test]
fn takes_last_not_buy_or_sell() {
let body = r#"{"USD": {"15m": 1.0, "last": 50000.0, "buy": 49000.0, "sell": 51000.0, "symbol": "USD"}}"#;
let quotes = BlockchainProvider::parse(body).unwrap();
assert_eq!(quotes.get("USD"), Some(&Quote::PerBtc(50_000.0)));
}
#[test]
fn drops_missing_and_non_positive_last() {
let body = r#"{
"AAA": {"15m": 1.0, "buy": 1.0, "sell": 1.0, "symbol": "AAA"},
"BBB": {"last": 0, "symbol": "BBB"},
"GBP": {"last": 47293.0, "symbol": "GBP"}
}"#;
let quotes = BlockchainProvider::parse(body).unwrap();
assert_eq!(quotes.len(), 1, "only GBP has a usable `last`");
assert_eq!(quotes.get("GBP"), Some(&Quote::PerBtc(47_293.0)));
}
#[test]
fn parse_error_is_returned() {
assert!(matches!(
BlockchainProvider::parse("not json").unwrap_err(),
ProviderError::Parse(_)
));
}
#[test]
fn new_strips_trailing_slash() {
let cfg = ProviderConfig {
enabled: true,
url: "https://blockchain.info/".into(),
fallback_urls: vec![],
api_key: None,
token: None,
only: None,
except: None,
};
assert_eq!(BlockchainProvider::new(&cfg).url, "https://blockchain.info");
}
}