1use std::time::Duration;
28
29use reqwest::{Client, StatusCode};
30
31use crate::api::error::{ApiError, ApiResult, ErrorResponse};
32use crate::api::types::*;
33use crate::program::orders::FullOrder;
34
35const DEFAULT_TIMEOUT_SECS: u64 = 30;
37
38const MAX_PAGINATION_LIMIT: u32 = 500;
40
41#[derive(Debug, Clone)]
43pub struct RetryConfig {
44 pub max_retries: u32,
46 pub base_delay_ms: u64,
48 pub max_delay_ms: u64,
50}
51
52impl Default for RetryConfig {
53 fn default() -> Self {
54 Self {
55 max_retries: 0,
56 base_delay_ms: 100,
57 max_delay_ms: 10_000,
58 }
59 }
60}
61
62impl RetryConfig {
63 pub fn new(max_retries: u32) -> Self {
65 Self {
66 max_retries,
67 ..Default::default()
68 }
69 }
70
71 pub fn with_base_delay_ms(mut self, ms: u64) -> Self {
73 self.base_delay_ms = ms;
74 self
75 }
76
77 pub fn with_max_delay_ms(mut self, ms: u64) -> Self {
79 self.max_delay_ms = ms;
80 self
81 }
82
83 fn delay_for_attempt(&self, attempt: u32) -> Duration {
85 let exp_delay = self.base_delay_ms.saturating_mul(1 << attempt.min(10));
86 let capped_delay = exp_delay.min(self.max_delay_ms);
87 let jitter_range = capped_delay / 4;
89 let jitter = rand::random::<u64>() % (jitter_range + 1);
90 Duration::from_millis(capped_delay - jitter_range + jitter)
91 }
92}
93
94#[derive(Debug, Clone)]
96pub struct LightconeApiClientBuilder {
97 base_url: String,
98 timeout: Duration,
99 default_headers: Vec<(String, String)>,
100 retry_config: RetryConfig,
101}
102
103impl LightconeApiClientBuilder {
104 pub fn new(base_url: impl Into<String>) -> Self {
106 Self {
107 base_url: base_url.into().trim_end_matches('/').to_string(),
108 timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
109 default_headers: Vec::new(),
110 retry_config: RetryConfig::default(),
111 }
112 }
113
114 pub fn timeout(mut self, timeout: Duration) -> Self {
116 self.timeout = timeout;
117 self
118 }
119
120 pub fn timeout_secs(mut self, secs: u64) -> Self {
122 self.timeout = Duration::from_secs(secs);
123 self
124 }
125
126 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
128 self.default_headers.push((name.into(), value.into()));
129 self
130 }
131
132 pub fn with_retry(mut self, config: RetryConfig) -> Self {
138 self.retry_config = config;
139 self
140 }
141
142 pub fn build(self) -> ApiResult<LightconeApiClient> {
144 let mut builder = Client::builder()
145 .timeout(self.timeout)
146 .pool_max_idle_per_host(10);
147
148 let mut headers = reqwest::header::HeaderMap::new();
150 headers.insert(
151 reqwest::header::CONTENT_TYPE,
152 reqwest::header::HeaderValue::from_static("application/json"),
153 );
154 headers.insert(
155 reqwest::header::ACCEPT,
156 reqwest::header::HeaderValue::from_static("application/json"),
157 );
158
159 for (name, value) in self.default_headers {
160 let header_name = reqwest::header::HeaderName::try_from(name.as_str())
161 .map_err(|e| ApiError::InvalidParameter(format!("Invalid header name '{}': {}", name, e)))?;
162 let header_value = reqwest::header::HeaderValue::from_str(&value)
163 .map_err(|e| ApiError::InvalidParameter(format!("Invalid header value for '{}': {}", name, e)))?;
164 headers.insert(header_name, header_value);
165 }
166
167 builder = builder.default_headers(headers);
168
169 let http_client = builder.build()?;
170
171 Ok(LightconeApiClient {
172 http_client,
173 base_url: self.base_url,
174 retry_config: self.retry_config,
175 })
176 }
177}
178
179#[derive(Debug, Clone)]
184pub struct LightconeApiClient {
185 http_client: Client,
186 base_url: String,
187 retry_config: RetryConfig,
188}
189
190impl LightconeApiClient {
191 pub fn new(base_url: impl Into<String>) -> ApiResult<Self> {
199 LightconeApiClientBuilder::new(base_url).build()
200 }
201
202 pub fn builder(base_url: impl Into<String>) -> LightconeApiClientBuilder {
204 LightconeApiClientBuilder::new(base_url)
205 }
206
207 pub fn base_url(&self) -> &str {
209 &self.base_url
210 }
211
212 async fn get<T: serde::de::DeserializeOwned>(&self, url: &str) -> ApiResult<T> {
218 self.execute_with_retry(|| self.http_client.get(url).send()).await
219 }
220
221 async fn post<T: serde::de::DeserializeOwned, B: serde::Serialize + Clone>(
223 &self,
224 url: &str,
225 body: &B,
226 ) -> ApiResult<T> {
227 self.execute_with_retry(|| self.http_client.post(url).json(body).send()).await
228 }
229
230 async fn execute_with_retry<T, F, Fut>(&self, request_fn: F) -> ApiResult<T>
232 where
233 F: Fn() -> Fut,
234 Fut: std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
235 T: serde::de::DeserializeOwned,
236 {
237 let mut attempt = 0;
238
239 loop {
240 let result = request_fn().await;
241
242 match result {
243 Ok(response) => {
244 let status = response.status();
245
246 if status.is_success() {
247 return response.json::<T>().await.map_err(|e| {
248 ApiError::Deserialize(format!("Failed to deserialize response: {}", e))
249 });
250 }
251
252 let error = self.parse_error_response(response).await;
254
255 if attempt < self.retry_config.max_retries && Self::is_retryable_status(status) {
257 let delay = self.retry_config.delay_for_attempt(attempt);
258 tracing::debug!(
259 attempt = attempt + 1,
260 max_retries = self.retry_config.max_retries,
261 delay_ms = delay.as_millis(),
262 status = %status,
263 "Retrying request after error"
264 );
265 tokio::time::sleep(delay).await;
266 attempt += 1;
267 continue;
268 }
269
270 return Err(error);
271 }
272 Err(e) => {
273 let is_retryable = e.is_connect() || e.is_timeout() || e.is_request();
274
275 if attempt < self.retry_config.max_retries && is_retryable {
276 let delay = self.retry_config.delay_for_attempt(attempt);
277 tracing::debug!(
278 attempt = attempt + 1,
279 max_retries = self.retry_config.max_retries,
280 delay_ms = delay.as_millis(),
281 error = %e,
282 "Retrying request after network error"
283 );
284 tokio::time::sleep(delay).await;
285 attempt += 1;
286 continue;
287 }
288
289 return Err(ApiError::Http(e));
290 }
291 }
292 }
293 }
294
295 async fn parse_error_response(&self, response: reqwest::Response) -> ApiError {
297 let status = response.status();
298 let error_text = match response.text().await {
299 Ok(text) => text,
300 Err(e) => {
301 tracing::warn!("Failed to read error response body: {}", e);
302 return Self::map_status_error(
303 status,
304 ErrorResponse::from_text(format!("HTTP {} (body unreadable: {})", status, e)),
305 );
306 }
307 };
308
309 let error_response = serde_json::from_str::<ErrorResponse>(&error_text)
310 .unwrap_or_else(|_| ErrorResponse::from_text(error_text));
311
312 Self::map_status_error(status, error_response)
313 }
314
315 fn map_status_error(status: StatusCode, response: ErrorResponse) -> ApiError {
317 match status {
318 StatusCode::UNAUTHORIZED => ApiError::Unauthorized(response),
319 StatusCode::NOT_FOUND => ApiError::NotFound(response),
320 StatusCode::BAD_REQUEST => ApiError::BadRequest(response),
321 StatusCode::FORBIDDEN => ApiError::Forbidden(response),
322 StatusCode::CONFLICT => ApiError::Conflict(response),
323 StatusCode::TOO_MANY_REQUESTS => ApiError::RateLimited(response),
324 _ if status.is_server_error() => ApiError::ServerError(response),
325 _ => ApiError::UnexpectedStatus(status.as_u16(), response),
326 }
327 }
328
329 fn is_retryable_status(status: StatusCode) -> bool {
331 status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS
332 }
333
334 fn validate_base58(value: &str, field_name: &str) -> ApiResult<()> {
340 if value.is_empty() {
341 return Err(ApiError::InvalidParameter(format!("{} cannot be empty", field_name)));
342 }
343 bs58::decode(value)
344 .into_vec()
345 .map_err(|_| ApiError::InvalidParameter(format!("{} is not valid Base58", field_name)))?;
346 Ok(())
347 }
348
349 fn validate_signature(sig: &str) -> ApiResult<()> {
351 if sig.len() != 128 {
352 return Err(ApiError::InvalidParameter(
353 format!("Signature must be 128 hex characters, got {}", sig.len())
354 ));
355 }
356 for chunk in sig.as_bytes().chunks(2) {
358 let hex_str = std::str::from_utf8(chunk).unwrap_or("");
359 u8::from_str_radix(hex_str, 16)
360 .map_err(|_| ApiError::InvalidParameter("Signature must contain only hex characters".to_string()))?;
361 }
362 Ok(())
363 }
364
365 fn validate_limit(limit: u32, max: u32) -> ApiResult<()> {
367 if limit == 0 || limit > max {
368 return Err(ApiError::InvalidParameter(format!("Limit must be 1-{}", max)));
369 }
370 Ok(())
371 }
372
373 pub async fn health_check(&self) -> ApiResult<()> {
381 let url = format!("{}/health", self.base_url);
382 let response = self.http_client.get(&url).send().await?;
384 if response.status().is_success() {
385 Ok(())
386 } else {
387 Err(ApiError::ServerError(ErrorResponse::from_text("Health check failed".to_string())))
388 }
389 }
390
391 pub async fn get_markets(&self) -> ApiResult<MarketsResponse> {
399 let url = format!("{}/api/markets", self.base_url);
400 self.get(&url).await
401 }
402
403 pub async fn get_market(&self, market_pubkey: &str) -> ApiResult<MarketInfoResponse> {
407 Self::validate_base58(market_pubkey, "market_pubkey")?;
408 let url = format!("{}/api/markets/{}", self.base_url, urlencoding::encode(market_pubkey));
409 self.get(&url).await
410 }
411
412 pub async fn get_market_by_slug(&self, slug: &str) -> ApiResult<MarketInfoResponse> {
414 if slug.is_empty() {
415 return Err(ApiError::InvalidParameter("slug cannot be empty".to_string()));
416 }
417 let url = format!("{}/api/markets/by-slug/{}", self.base_url, urlencoding::encode(slug));
418 self.get(&url).await
419 }
420
421 pub async fn get_deposit_assets(&self, market_pubkey: &str) -> ApiResult<DepositAssetsResponse> {
423 Self::validate_base58(market_pubkey, "market_pubkey")?;
424 let url = format!("{}/api/markets/{}/deposit-assets", self.base_url, urlencoding::encode(market_pubkey));
425 self.get(&url).await
426 }
427
428 pub async fn get_orderbook(
441 &self,
442 orderbook_id: &str,
443 depth: Option<u32>,
444 ) -> ApiResult<OrderbookResponse> {
445 let mut url = format!("{}/api/orderbook/{}", self.base_url, urlencoding::encode(orderbook_id));
446 if let Some(d) = depth {
447 url.push_str(&format!("?depth={}", d));
448 }
449 self.get(&url).await
450 }
451
452 pub async fn submit_order(&self, request: SubmitOrderRequest) -> ApiResult<OrderResponse> {
460 Self::validate_base58(&request.maker, "maker")?;
461 Self::validate_base58(&request.market_pubkey, "market_pubkey")?;
462 Self::validate_base58(&request.base_token, "base_token")?;
463 Self::validate_base58(&request.quote_token, "quote_token")?;
464 Self::validate_signature(&request.signature)?;
465
466 let url = format!("{}/api/orders/submit", self.base_url);
467 self.post(&url, &request).await
468 }
469
470 pub async fn submit_full_order(
491 &self,
492 order: &FullOrder,
493 orderbook_id: impl Into<String>,
494 ) -> ApiResult<OrderResponse> {
495 let request = order.to_submit_request(orderbook_id);
496 self.submit_order(request).await
497 }
498
499 pub async fn cancel_order(&self, order_hash: &str, maker: &str) -> ApiResult<CancelResponse> {
503 Self::validate_base58(maker, "maker")?;
504
505 let url = format!("{}/api/orders/cancel", self.base_url);
506 let request = CancelOrderRequest {
507 order_hash: order_hash.to_string(),
508 maker: maker.to_string(),
509 };
510 self.post(&url, &request).await
511 }
512
513 pub async fn cancel_all_orders(
517 &self,
518 user_pubkey: &str,
519 market_pubkey: Option<&str>,
520 ) -> ApiResult<CancelAllResponse> {
521 Self::validate_base58(user_pubkey, "user_pubkey")?;
522 if let Some(market) = market_pubkey {
523 Self::validate_base58(market, "market_pubkey")?;
524 }
525
526 let url = format!("{}/api/orders/cancel-all", self.base_url);
527 let request = CancelAllOrdersRequest {
528 user_pubkey: user_pubkey.to_string(),
529 market_pubkey: market_pubkey.map(|s| s.to_string()),
530 };
531 self.post(&url, &request).await
532 }
533
534 pub async fn get_user_positions(&self, user_pubkey: &str) -> ApiResult<PositionsResponse> {
540 Self::validate_base58(user_pubkey, "user_pubkey")?;
541 let url = format!("{}/api/users/{}/positions", self.base_url, urlencoding::encode(user_pubkey));
542 self.get(&url).await
543 }
544
545 pub async fn get_user_market_positions(
547 &self,
548 user_pubkey: &str,
549 market_pubkey: &str,
550 ) -> ApiResult<MarketPositionsResponse> {
551 Self::validate_base58(user_pubkey, "user_pubkey")?;
552 Self::validate_base58(market_pubkey, "market_pubkey")?;
553
554 let url = format!(
555 "{}/api/users/{}/markets/{}/positions",
556 self.base_url,
557 urlencoding::encode(user_pubkey),
558 urlencoding::encode(market_pubkey)
559 );
560 self.get(&url).await
561 }
562
563 pub async fn get_user_orders(&self, user_pubkey: &str) -> ApiResult<UserOrdersResponse> {
565 Self::validate_base58(user_pubkey, "user_pubkey")?;
566
567 let url = format!("{}/api/users/orders", self.base_url);
568 let request = GetUserOrdersRequest {
569 user_pubkey: user_pubkey.to_string(),
570 };
571 self.post(&url, &request).await
572 }
573
574 pub async fn get_price_history(
580 &self,
581 params: PriceHistoryParams,
582 ) -> ApiResult<PriceHistoryResponse> {
583 if let Some(limit) = params.limit {
584 Self::validate_limit(limit, MAX_PAGINATION_LIMIT)?;
585 }
586
587 let mut url = format!(
588 "{}/api/price-history?orderbook_id={}",
589 self.base_url,
590 urlencoding::encode(¶ms.orderbook_id)
591 );
592
593 if let Some(resolution) = params.resolution {
594 url.push_str(&format!("&resolution={}", urlencoding::encode(&resolution.to_string())));
595 }
596 if let Some(from) = params.from {
597 url.push_str(&format!("&from={}", from));
598 }
599 if let Some(to) = params.to {
600 url.push_str(&format!("&to={}", to));
601 }
602 if let Some(cursor) = params.cursor {
603 url.push_str(&format!("&cursor={}", cursor));
604 }
605 if let Some(limit) = params.limit {
606 url.push_str(&format!("&limit={}", limit));
607 }
608 if let Some(include_ohlcv) = params.include_ohlcv {
609 url.push_str(&format!("&include_ohlcv={}", include_ohlcv));
610 }
611
612 self.get(&url).await
613 }
614
615 pub async fn get_trades(&self, params: TradesParams) -> ApiResult<TradesResponse> {
621 if let Some(ref user_pubkey) = params.user_pubkey {
622 Self::validate_base58(user_pubkey, "user_pubkey")?;
623 }
624 if let Some(limit) = params.limit {
625 Self::validate_limit(limit, MAX_PAGINATION_LIMIT)?;
626 }
627
628 let mut url = format!(
629 "{}/api/trades?orderbook_id={}",
630 self.base_url,
631 urlencoding::encode(¶ms.orderbook_id)
632 );
633
634 if let Some(user_pubkey) = params.user_pubkey {
635 url.push_str(&format!("&user_pubkey={}", urlencoding::encode(&user_pubkey)));
636 }
637 if let Some(from) = params.from {
638 url.push_str(&format!("&from={}", from));
639 }
640 if let Some(to) = params.to {
641 url.push_str(&format!("&to={}", to));
642 }
643 if let Some(cursor) = params.cursor {
644 url.push_str(&format!("&cursor={}", cursor));
645 }
646 if let Some(limit) = params.limit {
647 url.push_str(&format!("&limit={}", limit));
648 }
649
650 self.get(&url).await
651 }
652
653 pub async fn admin_health_check(&self) -> ApiResult<AdminResponse> {
659 let url = format!("{}/api/admin/test", self.base_url);
660 self.get(&url).await
661 }
662
663 pub async fn create_orderbook(
665 &self,
666 request: CreateOrderbookRequest,
667 ) -> ApiResult<CreateOrderbookResponse> {
668 Self::validate_base58(&request.market_pubkey, "market_pubkey")?;
669 Self::validate_base58(&request.base_token, "base_token")?;
670 Self::validate_base58(&request.quote_token, "quote_token")?;
671
672 let url = format!("{}/api/admin/create-orderbook", self.base_url);
673 self.post(&url, &request).await
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680 use crate::shared::Resolution;
681
682 #[test]
683 fn test_client_creation() {
684 let client = LightconeApiClient::new("https://api.lightcone.xyz").unwrap();
685 assert_eq!(client.base_url(), "https://api.lightcone.xyz");
686 }
687
688 #[test]
689 fn test_client_builder() {
690 let client = LightconeApiClient::builder("https://api.lightcone.xyz/")
691 .timeout_secs(60)
692 .header("X-Custom", "test")
693 .build()
694 .unwrap();
695
696 assert_eq!(client.base_url(), "https://api.lightcone.xyz");
698 }
699
700 #[test]
701 fn test_price_history_params() {
702 let params = PriceHistoryParams::new("orderbook1")
703 .with_resolution(Resolution::OneHour)
704 .with_time_range(1000, 2000)
705 .with_limit(100)
706 .with_ohlcv();
707
708 assert_eq!(params.orderbook_id, "orderbook1");
709 assert_eq!(params.resolution, Some(Resolution::OneHour));
710 assert_eq!(params.from, Some(1000));
711 assert_eq!(params.to, Some(2000));
712 assert_eq!(params.limit, Some(100));
713 assert_eq!(params.include_ohlcv, Some(true));
714 }
715
716 #[test]
717 fn test_trades_params() {
718 let params = TradesParams::new("orderbook1")
719 .with_user("user123")
720 .with_time_range(1000, 2000)
721 .with_cursor(50)
722 .with_limit(100);
723
724 assert_eq!(params.orderbook_id, "orderbook1");
725 assert_eq!(params.user_pubkey, Some("user123".to_string()));
726 assert_eq!(params.from, Some(1000));
727 assert_eq!(params.to, Some(2000));
728 assert_eq!(params.cursor, Some(50));
729 assert_eq!(params.limit, Some(100));
730 }
731
732 #[test]
733 fn test_create_orderbook_request() {
734 let request = CreateOrderbookRequest::new("market1", "base1", "quote1").with_tick_size(500);
735
736 assert_eq!(request.market_pubkey, "market1");
737 assert_eq!(request.base_token, "base1");
738 assert_eq!(request.quote_token, "quote1");
739 assert_eq!(request.tick_size, Some(500));
740 }
741
742 #[test]
743 fn test_retry_config() {
744 let config = RetryConfig::new(3)
745 .with_base_delay_ms(200)
746 .with_max_delay_ms(5000);
747
748 assert_eq!(config.max_retries, 3);
749 assert_eq!(config.base_delay_ms, 200);
750 assert_eq!(config.max_delay_ms, 5000);
751 }
752
753 #[test]
754 fn test_client_with_retry() {
755 let client = LightconeApiClient::builder("https://api.lightcone.xyz")
756 .with_retry(RetryConfig::new(3))
757 .build()
758 .unwrap();
759
760 assert_eq!(client.retry_config.max_retries, 3);
761 }
762
763 #[test]
764 fn test_retry_delay_calculation() {
765 let config = RetryConfig {
766 max_retries: 5,
767 base_delay_ms: 100,
768 max_delay_ms: 1000,
769 };
770
771 let delay0 = config.delay_for_attempt(0);
773 assert!(delay0.as_millis() >= 75 && delay0.as_millis() <= 100);
774
775 let delay1 = config.delay_for_attempt(1);
777 assert!(delay1.as_millis() >= 150 && delay1.as_millis() <= 200);
778
779 let delay3 = config.delay_for_attempt(3);
781 assert!(delay3.as_millis() >= 600 && delay3.as_millis() <= 800);
782
783 let delay10 = config.delay_for_attempt(10);
785 assert!(delay10.as_millis() >= 750 && delay10.as_millis() <= 1000);
786 }
787}