1use std::num::NonZeroU32;
5use std::fmt::Debug;
6use std::time::Duration;
7use reqwest::{Method, Url};
9use reqwest;
10use governor::{
11 Quota,
12 RateLimiter,
13 clock::DefaultClock,
14 state::{InMemoryState, NotKeyed}
15};
16use anyhow;
17use anyhow::Context;
18use chrono::{DateTime, Utc};
19
20pub(crate) const COINBASE_API_URL: &str = "https://api.pro.coinbase.com";
22pub(crate) const DEFAULT_REQUEST_TIMEOUT: u8 = 30;
23pub(crate) const DEFAULT_RATE_LIMIT: u8 = 3;
24pub(crate) const DEFAULT_BURST_SIZE: u8 = 6;
25pub(crate) const APP_USER_AGENT: &str = concat!(
26 env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")
27);
28
29#[derive(Debug)]
31pub struct CoinbasePublicClient {
32 api_url: &'static str,
33 http_client: reqwest::Client,
34 request_timeout: u8,
35 rate_limiter: Option<RateLimiter<NotKeyed, InMemoryState, DefaultClock>>,
36}
37
38#[derive(Debug, Clone)]
40pub enum OBLevel {
41 Level1 = 1,
42 Level2 = 2,
43 Level3 = 3,
44}
45
46impl OBLevel {
49 fn param_tuple(&self) -> (String, String) {
50 ("level".to_owned(), (self.to_owned() as u8).to_string())
51 }
52}
53
54#[derive(Debug, Clone)]
56pub enum Granularity {
57 Minute1 = 60,
58 Minute5 = 300,
59 Minute15 = 900,
60 Hour1 = 3600,
61 Hour6 = 21600,
62 Hour24 = 86400,
63}
64
65impl Granularity {
66 fn param_tuple(&self) -> (String, String) {
67 ("granularity".to_owned(), (self.to_owned() as u32).to_string())
68 }
69}
70
71type Params = Vec<(String, String)>;
72
73impl CoinbasePublicClient {
74 pub fn new() -> Self {
76 Self::builder().build()
77 }
78
79 pub fn builder() -> CoinbaseClientBuilder<'static> {
101 CoinbaseClientBuilder::new()
102 }
103
104 pub async fn get_products(&self) -> Result<String, anyhow::Error> {
106 let endpoint = "/products";
107 Ok(self.get_json(endpoint, None).await?)
108 }
109
110 pub async fn get_product(&self, product_id: &str) -> Result<String, anyhow::Error> {
117 let endpoint = "/products/".to_owned() + product_id;
118 Ok(self.get_json(&endpoint, None).await?)
119 }
120
121 pub async fn get_product_orderbook(&self, product_id: &str, level: OBLevel) -> Result<String, anyhow::Error> {
132 let params: Params = vec![level.param_tuple()];
133 let endpoint = format!("/products/{}/book", product_id);
134 Ok(self.get_json(&endpoint, Some(params)).await?)
135 }
136
137 pub async fn get_product_ticker(&self, product_id: &str) -> Result<String, anyhow::Error> {
144 let endpoint = format!("/products/{}/ticker", product_id);
145 Ok(self.get_json(&endpoint, None).await?)
146 }
147
148 pub async fn get_product_trades(&self, product_id: &str, after: Option<u64>) -> Result<String, anyhow::Error> {
157 let endpoint = format!("/products/{}/trades", product_id);
158
159 let maybe_params: Option<Params> = after
160 .map(|after| vec![("after".to_owned(), (after + 1).to_string())]);
161
162 Ok(self.get_json(&endpoint, maybe_params).await?)
163 }
164
165 pub async fn get_product_historic_rates(
183 &self,
184 product_id: &str,
185 start_opt: Option<DateTime<Utc>>,
186 end_opt: Option<DateTime<Utc>>,
187 granularity_opt: Option<Granularity>
188 ) -> Result<String, anyhow::Error> {
189 let endpoint = format!("/products/{}/candles", product_id);
190
191 let mut params: Params = Vec::new();
192 if start_opt.is_some() {
193 params.push(("start".to_owned(), start_opt.unwrap().to_owned().to_rfc3339()))
194 }
195 if end_opt.is_some() {
196 params.push(("end".to_owned(), end_opt.unwrap().to_owned().to_rfc3339()))
197 }
198 if granularity_opt.is_some() { params.push(granularity_opt.unwrap().param_tuple()); }
199
200 let maybe_params: Option<Params> = match params.is_empty() {
201 true => None,
202 false => Some(params)
203 };
204
205 Ok(self.get_json(&endpoint, maybe_params).await?)
206 }
207
208 pub async fn get_product_24h_stats(&self, product_id: &str) -> Result<String, anyhow::Error> {
213 let endpoint = format!("/products/{}/stats", product_id);
214 Ok(self.get_json(&endpoint, None).await?)
215 }
216
217 pub async fn get_currencies(&self) -> Result<String, anyhow::Error> {
219 let endpoint = "/currencies";
220 Ok(self.get_json(endpoint, None).await?)
221 }
222
223
224 pub async fn get_time(&self) -> Result<String, anyhow::Error> {
226 let endpoint = "/time";
227 Ok(self.get_json(endpoint, None).await?)
228 }
229
230 async fn get_json(&self, endpoint: &str, params: Option<Params>) -> Result<String, anyhow::Error> {
232 let url_str = self.api_url.to_owned() + endpoint;
233
234 let url = match params {
235 Some(params) => {
236 Url::parse_with_params(&url_str, ¶ms)
237 .context("failed to parse url string with params")?
238 },
239 None => {
240 Url::parse(&url_str).context("failed to parse url string")?
241 }
242 };
243
244 if self.rate_limiter.is_some() {
245 self.rate_limiter.as_ref().unwrap().until_ready().await;
246 }
247
248 let result= self.http_client
249 .request(Method::GET, url)
250 .timeout(Duration::from_secs(self.request_timeout as u64))
251 .send().await.context("failure while sending request")?
252 .text().await.context("failure while decoding response to text")?;
253
254 Ok(result)
255 }
256}
257
258pub struct CoinbaseClientBuilder<'a> {
260 api_url: Option<&'a str>,
261 request_timeout: Option<u8>,
262 rate_limit: Option<u8>,
263 burst_size: Option<u8>,
264}
265
266impl CoinbaseClientBuilder<'static> {
267 pub fn new() -> Self {
268 Self {
269 api_url: None,
270 request_timeout: None,
271 rate_limit: None,
272 burst_size: None,
273 }
274 }
275
276 pub fn api_url(self, value: &'static str) -> Self {
277 Self {
278 api_url: Some(value),
279 ..self
280 }
281 }
282
283 pub fn request_timeout(self, value: u8) -> Self {
284 Self {
285 request_timeout: Some(value),
286 ..self
287 }
288 }
289
290 pub fn rate_limit(self, value: u8) -> Self {
291 Self {
292 rate_limit: Some(value),
293 ..self
294 }
295 }
296
297 pub fn burst_size(self, value: u8) -> Self {
298 Self {
299 burst_size: Some(value),
300 ..self
301 }
302 }
303
304 pub fn build(self) -> CoinbasePublicClient {
305 let rate_limit = self.rate_limit.unwrap_or(DEFAULT_RATE_LIMIT);
306 let burst_size = self.burst_size.unwrap_or(DEFAULT_BURST_SIZE);
307
308 CoinbasePublicClient {
309 api_url: self.api_url.unwrap_or(COINBASE_API_URL),
310 http_client: reqwest::Client::builder()
311 .user_agent(APP_USER_AGENT)
312 .build()
313 .unwrap_or_else(|_| reqwest::Client::new()),
314 request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT),
315 rate_limiter: {
316 if rate_limit > 0 {
317 let mut quota = Quota::per_second(NonZeroU32::new(rate_limit as u32).unwrap());
318 if burst_size > 0 {
319 quota = quota.allow_burst(NonZeroU32::new(burst_size as u32).unwrap())
320 };
321 Some(RateLimiter::direct(quota))
322 } else { None }
323 },
324 }
325 }
326}
327
328impl Default for CoinbasePublicClient {
329 fn default() -> Self {
330 CoinbasePublicClient::new()
331 }
332}
333
334impl Default for CoinbaseClientBuilder<'_> {
335 fn default() -> Self {
336 CoinbaseClientBuilder::new()
337 }
338}
339
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use chrono::prelude::*;
345 use std::time::SystemTime;
346 use lazy_static::lazy_static;
347
348 fn print_type_of<T>(_: &T) {
349 println!("{}", std::any::type_name::<T>());
350 }
351
352 fn now_minus_x_sec(x: u64) -> DateTime<Utc> {
353 let now: DateTime<Utc> = SystemTime::now().into();
354 let x = chrono::Duration::seconds(x as i64);
355 now.checked_sub_signed(x).unwrap()
356 }
357
358 lazy_static! {
359 static ref client: CoinbasePublicClient = CoinbasePublicClient::builder()
360 .rate_limit(1)
361 .burst_size(1)
362 .build()
363 ;
364 }
365
366 #[tokio::test]
367 async fn test_time() {
368 let response = client.get_time().await;
369 assert!(response.is_ok());
370 }
371
372 #[tokio::test]
373 async fn test_currencies() {
374 let response = client.get_currencies().await;
375 assert!(response.is_ok());
376 }
377
378 #[tokio::test]
379 async fn test_24h_stats() {
380 let response = client.get_product_24h_stats("ETH-USD").await;
381 assert!(response.is_ok());
382 }
383
384 #[tokio::test]
385 async fn test_candles() {
386 let candles = client.get_product_historic_rates(
387 "eth-usd", None, None, None
388 ).await;
389 assert!(candles.is_ok())
390 }
391
392 #[tokio::test]
393 async fn test_trades() {
394 let trades = client.get_product_trades("eth-usd", None).await;
396 assert!(trades.is_ok())
397 }
398
399 #[tokio::test]
400 async fn test_ticker() {
401 let ticker = client.get_product_ticker("eth-usd").await;
402 assert!(ticker.is_ok())
403 }
404
405 #[tokio::test]
406 async fn test_orderbook() {
407 let eth_usd_str = "eth-usd";
408 let orderbook_lvl1 = client
409 .get_product_orderbook(eth_usd_str, OBLevel::Level1).await;
410 assert!(orderbook_lvl1.is_ok());
411 let orderbook_lvl2 = client
412 .get_product_orderbook(eth_usd_str, OBLevel::Level2).await;
413 assert!(orderbook_lvl2.is_ok());
414 let orderbook_lvl3 = client
415 .get_product_orderbook(eth_usd_str, OBLevel::Level3).await;
416 assert!(orderbook_lvl3.is_ok());
417 }
418
419 #[tokio::test]
420 async fn get_products() {
421 let products = client.get_products().await;
422 assert!(products.is_ok());
423 }
424
425 #[tokio::test]
426 async fn get_product() {
427 let product_ids: Vec<&str> = vec![
428 "ETH-USD", "btc-usd", "sol-usd"
429 ];
430 for product_str in product_ids {
431 let result = client.get_product(product_str).await;
432 assert!(result.is_ok());
433 }
434 }
435}