diff --git a/Cargo.lock b/Cargo.lock index a254fcdc2..e2b4da1eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2209,10 +2209,10 @@ dependencies = [ name = "price-info" version = "1.7.0" dependencies = [ - "ethcore-util 1.8.0", "fetch 0.1.0", "futures 0.1.11 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.3.7 (registry+https://github.com/rust-lang/crates.io-index)", + "parking_lot 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "serde_json 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", ] diff --git a/price-info/Cargo.toml b/price-info/Cargo.toml index b0226df8e..a6cc13eb7 100644 --- a/price-info/Cargo.toml +++ b/price-info/Cargo.toml @@ -13,4 +13,4 @@ log = "0.3" serde_json = "1.0" [dev-dependencies] -ethcore-util = { path = "../util" } +parking_lot = "0.4" diff --git a/price-info/src/lib.rs b/price-info/src/lib.rs index ec6fcfb5d..ba5719f40 100644 --- a/price-info/src/lib.rs +++ b/price-info/src/lib.rs @@ -26,6 +26,207 @@ extern crate log; pub extern crate fetch; -mod price_info; +use std::cmp; +use std::fmt; +use std::io; +use std::io::Read; -pub use price_info::*; +use fetch::{Client as FetchClient, Fetch}; +use futures::Future; +use serde_json::Value; + +/// Current ETH price information. +#[derive(Debug)] +pub struct PriceInfo { + /// Current ETH price in USD. + pub ethusd: f32, +} + +/// Price info error. +#[derive(Debug)] +pub enum Error { + /// The API returned an unexpected status code. + StatusCode(&'static str), + /// The API returned an unexpected status content. + UnexpectedResponse(String), + /// There was an error when trying to reach the API. + Fetch(fetch::Error), + /// IO error when reading API response. + Io(io::Error), +} + +impl From for Error { + fn from(err: io::Error) -> Self { Error::Io(err) } +} + +impl From for Error { + fn from(err: fetch::Error) -> Self { Error::Fetch(err) } +} + +/// A client to get the current ETH price using an external API. +pub struct Client { + api_endpoint: String, + fetch: F, +} + +impl fmt::Debug for Client { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + fmt.debug_struct("price_info::Client") + .field("api_endpoint", &self.api_endpoint) + .finish() + } +} + +impl cmp::PartialEq for Client { + fn eq(&self, other: &Client) -> bool { + self.api_endpoint == other.api_endpoint + } +} + +impl Client { + /// Creates a new instance of the `Client` given a `fetch::Client`. + pub fn new(fetch: F) -> Client { + let api_endpoint = "http://api.etherscan.io/api?module=stats&action=ethprice".to_owned(); + Client { api_endpoint, fetch } + } + + /// Gets the current ETH price and calls `set_price` with the result. + pub fn get(&self, set_price: G) { + self.fetch.forget(self.fetch.fetch(&self.api_endpoint) + .map_err(|err| Error::Fetch(err)) + .and_then(move |mut response| { + if !response.is_success() { + return Err(Error::StatusCode(response.status().canonical_reason().unwrap_or("unknown"))); + } + let mut result = String::new(); + response.read_to_string(&mut result)?; + + let value: Option = serde_json::from_str(&result).ok(); + + let ethusd = value + .as_ref() + .and_then(|value| value.pointer("/result/ethusd")) + .and_then(|obj| obj.as_str()) + .and_then(|s| s.parse().ok()); + + match ethusd { + Some(ethusd) => { + set_price(PriceInfo { ethusd }); + Ok(()) + }, + None => Err(Error::UnexpectedResponse(result)), + } + }) + .map_err(|err| { + warn!("Failed to auto-update latest ETH price: {:?}", err); + err + }) + ); + } +} + +#[cfg(test)] +mod test { + extern crate parking_lot; + + use self::parking_lot::Mutex; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + use fetch; + use fetch::Fetch; + use futures; + use futures::future::{Future, FutureResult}; + use Client; + + #[derive(Clone)] + struct FakeFetch(Option, Arc>); + impl Fetch for FakeFetch { + type Result = FutureResult; + fn new() -> Result where Self: Sized { Ok(FakeFetch(None, Default::default())) } + fn fetch_with_abort(&self, url: &str, _abort: fetch::Abort) -> Self::Result { + assert_eq!(url, "http://api.etherscan.io/api?module=stats&action=ethprice"); + let mut val = self.1.lock(); + *val = *val + 1; + if let Some(ref response) = self.0 { + let data = ::std::io::Cursor::new(response.clone()); + futures::future::ok(fetch::Response::from_reader(data)) + } else { + futures::future::ok(fetch::Response::not_found()) + } + } + + // this guarantees that the calls to price_info::Client::get will block for execution + fn forget(&self, f: F) where + F: Future + Send + 'static, + I: Send + 'static, + E: Send + 'static { + let _ = f.wait(); + } + } + + fn price_info_ok(response: &str) -> Client { + Client::new(FakeFetch(Some(response.to_owned()), Default::default())) + } + + fn price_info_not_found() -> Client { + Client::new(FakeFetch::new().unwrap()) + } + + #[test] + fn should_get_price_info() { + // given + let response = r#"{ + "status": "1", + "message": "OK", + "result": { + "ethbtc": "0.0891", + "ethbtc_timestamp": "1499894236", + "ethusd": "209.55", + "ethusd_timestamp": "1499894229" + } + }"#; + + let price_info = price_info_ok(response); + + // when + price_info.get(|price| { + + // then + assert_eq!(price.ethusd, 209.55); + }); + } + + #[test] + fn should_not_call_set_price_if_response_is_malformed() { + // given + let response = "{}"; + + let price_info = price_info_ok(response); + let b = Arc::new(AtomicBool::new(false)); + + // when + let bb = b.clone(); + price_info.get(move |_| { + bb.store(true, Ordering::Relaxed); + }); + + // then + assert_eq!(b.load(Ordering::Relaxed), false); + } + + #[test] + fn should_not_call_set_price_if_response_is_invalid() { + // given + let price_info = price_info_not_found(); + let b = Arc::new(AtomicBool::new(false)); + + // when + let bb = b.clone(); + price_info.get(move |_| { + bb.store(true, Ordering::Relaxed); + }); + + // then + assert_eq!(b.load(Ordering::Relaxed), false); + } +} diff --git a/price-info/src/price_info.rs b/price-info/src/price_info.rs deleted file mode 100644 index 36ca033d2..000000000 --- a/price-info/src/price_info.rs +++ /dev/null @@ -1,222 +0,0 @@ -// Copyright 2015-2017 Parity Technologies (UK) Ltd. -// This file is part of Parity. - -// Parity is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. - -// Parity is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. - -// You should have received a copy of the GNU General Public License -// along with Parity. If not, see . - -use std::cmp; -use std::fmt; -use std::io; -use std::io::Read; -use std::str::FromStr; - -use fetch; -use fetch::{Client as FetchClient, Fetch}; -use futures::Future; -use serde_json; -use serde_json::Value; - -/// Current ETH price information. -#[derive(Debug)] -pub struct PriceInfo { - /// Current ETH price in USD. - pub ethusd: f32, -} - -/// Price info error. -#[derive(Debug)] -pub enum Error { - /// The API returned an unexpected status code or content. - UnexpectedResponse(&'static str, String), - /// There was an error when trying to reach the API. - Fetch(fetch::Error), - /// IO error when reading API response. - Io(io::Error), -} - -impl From for Error { - fn from(err: io::Error) -> Self { Error::Io(err) } -} - -impl From for Error { - fn from(err: fetch::Error) -> Self { Error::Fetch(err) } -} - -/// A client to get the current ETH price using an external API. -pub struct Client { - api_endpoint: String, - fetch: F, -} - -impl fmt::Debug for Client { - fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { - fmt.debug_struct("price_info::Client") - .field("api_endpoint", &self.api_endpoint) - .finish() - } -} - -impl cmp::PartialEq for Client { - fn eq(&self, other: &Client) -> bool { - self.api_endpoint == other.api_endpoint - } -} - -impl Client { - /// Creates a new instance of the `Client` given a `fetch::Client`. - pub fn new(fetch: F) -> Client { - let api_endpoint = "http://api.etherscan.io/api?module=stats&action=ethprice".to_owned(); - Client { api_endpoint, fetch } - } - - /// Gets the current ETH price and calls `set_price` with the result. - pub fn get(&self, set_price: G) { - self.fetch.forget(self.fetch.fetch(&self.api_endpoint) - .map_err(|err| Error::Fetch(err)) - .and_then(move |mut response| { - let mut result = String::new(); - response.read_to_string(&mut result)?; - - if response.is_success() { - let value: Result = serde_json::from_str(&result); - if let Ok(v) = value { - let obj = v.pointer("/result/ethusd").and_then(|obj| { - match *obj { - Value::String(ref s) => FromStr::from_str(s).ok(), - _ => None, - } - }); - - if let Some(ethusd) = obj { - set_price(PriceInfo { ethusd }); - return Ok(()); - } - } - } - - let status = response.status().canonical_reason().unwrap_or("unknown"); - Err(Error::UnexpectedResponse(status, result)) - }) - .map_err(|err| { - warn!("Failed to auto-update latest ETH price: {:?}", err); - err - }) - ); - } -} - -#[cfg(test)] -mod test { - extern crate ethcore_util as util; - - use self::util::Mutex; - use std::sync::Arc; - use std::sync::atomic::{AtomicBool, Ordering}; - use fetch; - use fetch::Fetch; - use futures; - use futures::future::{Future, FutureResult}; - use price_info::Client; - - #[derive(Clone)] - struct FakeFetch(Option, Arc>); - impl Fetch for FakeFetch { - type Result = FutureResult; - fn new() -> Result where Self: Sized { Ok(FakeFetch(None, Default::default())) } - fn fetch_with_abort(&self, url: &str, _abort: fetch::Abort) -> Self::Result { - assert_eq!(url, "http://api.etherscan.io/api?module=stats&action=ethprice"); - let mut val = self.1.lock(); - *val = *val + 1; - if let Some(ref response) = self.0 { - let data = ::std::io::Cursor::new(response.clone()); - futures::future::ok(fetch::Response::from_reader(data)) - } else { - futures::future::ok(fetch::Response::not_found()) - } - } - - // this guarantees that the calls to price_info::Client::get will block for execution - fn forget(&self, f: F) where - F: Future + Send + 'static, - I: Send + 'static, - E: Send + 'static { - let _ = f.wait(); - } - } - - fn price_info_ok(response: &str) -> Client { - Client::new(FakeFetch(Some(response.to_owned()), Default::default())) - } - - fn price_info_not_found() -> Client { - Client::new(FakeFetch::new().unwrap()) - } - - #[test] - fn should_get_price_info() { - // given - let response = r#"{ - "status": "1", - "message": "OK", - "result": { - "ethbtc": "0.0891", - "ethbtc_timestamp": "1499894236", - "ethusd": "209.55", - "ethusd_timestamp": "1499894229" - } - }"#; - - let price_info = price_info_ok(response); - - // when - price_info.get(|price| { - - // then - assert_eq!(price.ethusd, 209.55); - }); - } - - #[test] - fn should_not_call_set_price_if_response_is_malformed() { - // given - let response = "{}"; - - let price_info = price_info_ok(response); - let b = Arc::new(AtomicBool::new(false)); - - // when - let bb = b.clone(); - price_info.get(move |_| { - bb.store(true, Ordering::Relaxed); - }); - - // then - assert_eq!(b.load(Ordering::Relaxed), false); - } - - #[test] - fn should_not_call_set_price_if_response_is_invalid() { - // given - let price_info = price_info_not_found(); - let b = Arc::new(AtomicBool::new(false)); - - // when - let bb = b.clone(); - price_info.get(move |_| { - bb.store(true, Ordering::Relaxed); - }); - - // then - assert_eq!(b.load(Ordering::Relaxed), false); - } -}