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 format!("HTTP {} (body unreadable: {})", status, e)
303 }
304 };
305
306 let error_msg = serde_json::from_str::<ErrorResponse>(&error_text)
307 .map(|err| err.get_message())
308 .unwrap_or(error_text);
309
310 Self::map_status_error(status, error_msg)
311 }
312
313 fn map_status_error(status: StatusCode, message: String) -> ApiError {
315 match status {
316 StatusCode::UNAUTHORIZED => ApiError::Unauthorized(message),
317 StatusCode::NOT_FOUND => ApiError::NotFound(message),
318 StatusCode::BAD_REQUEST => ApiError::BadRequest(message),
319 StatusCode::FORBIDDEN => ApiError::Forbidden(message),
320 StatusCode::CONFLICT => ApiError::Conflict(message),
321 StatusCode::TOO_MANY_REQUESTS => ApiError::RateLimited(message),
322 _ if status.is_server_error() => ApiError::ServerError(message),
323 _ => ApiError::UnexpectedStatus(status.as_u16(), message),
324 }
325 }
326
327 fn is_retryable_status(status: StatusCode) -> bool {
329 status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS
330 }
331
332 fn validate_base58(value: &str, field_name: &str) -> ApiResult<()> {
338 if value.is_empty() {
339 return Err(ApiError::InvalidParameter(format!("{} cannot be empty", field_name)));
340 }
341 bs58::decode(value)
342 .into_vec()
343 .map_err(|_| ApiError::InvalidParameter(format!("{} is not valid Base58", field_name)))?;
344 Ok(())
345 }
346
347 fn validate_signature(sig: &str) -> ApiResult<()> {
349 if sig.len() != 128 {
350 return Err(ApiError::InvalidParameter(
351 format!("Signature must be 128 hex characters, got {}", sig.len())
352 ));
353 }
354 for chunk in sig.as_bytes().chunks(2) {
356 let hex_str = std::str::from_utf8(chunk).unwrap_or("");
357 u8::from_str_radix(hex_str, 16)
358 .map_err(|_| ApiError::InvalidParameter("Signature must contain only hex characters".to_string()))?;
359 }
360 Ok(())
361 }
362
363 fn validate_limit(limit: u32, max: u32) -> ApiResult<()> {
365 if limit == 0 || limit > max {
366 return Err(ApiError::InvalidParameter(format!("Limit must be 1-{}", max)));
367 }
368 Ok(())
369 }
370
371 pub async fn health_check(&self) -> ApiResult<()> {
379 let url = format!("{}/health", self.base_url);
380 let response = self.http_client.get(&url).send().await?;
382 if response.status().is_success() {
383 Ok(())
384 } else {
385 Err(ApiError::ServerError("Health check failed".to_string()))
386 }
387 }
388
389 pub async fn get_markets(&self) -> ApiResult<MarketsResponse> {
397 let url = format!("{}/api/markets", self.base_url);
398 self.get(&url).await
399 }
400
401 pub async fn get_market(&self, market_pubkey: &str) -> ApiResult<MarketInfoResponse> {
405 Self::validate_base58(market_pubkey, "market_pubkey")?;
406 let url = format!("{}/api/markets/{}", self.base_url, urlencoding::encode(market_pubkey));
407 self.get(&url).await
408 }
409
410 pub async fn get_market_by_slug(&self, slug: &str) -> ApiResult<MarketInfoResponse> {
412 if slug.is_empty() {
413 return Err(ApiError::InvalidParameter("slug cannot be empty".to_string()));
414 }
415 let url = format!("{}/api/markets/by-slug/{}", self.base_url, urlencoding::encode(slug));
416 self.get(&url).await
417 }
418
419 pub async fn get_deposit_assets(&self, market_pubkey: &str) -> ApiResult<DepositAssetsResponse> {
421 Self::validate_base58(market_pubkey, "market_pubkey")?;
422 let url = format!("{}/api/markets/{}/deposit-assets", self.base_url, urlencoding::encode(market_pubkey));
423 self.get(&url).await
424 }
425
426 pub async fn get_orderbook(
439 &self,
440 orderbook_id: &str,
441 depth: Option<u32>,
442 ) -> ApiResult<OrderbookResponse> {
443 let mut url = format!("{}/api/orderbook/{}", self.base_url, urlencoding::encode(orderbook_id));
444 if let Some(d) = depth {
445 url.push_str(&format!("?depth={}", d));
446 }
447 self.get(&url).await
448 }
449
450 pub async fn submit_order(&self, request: SubmitOrderRequest) -> ApiResult<OrderResponse> {
458 Self::validate_base58(&request.maker, "maker")?;
459 Self::validate_base58(&request.market_pubkey, "market_pubkey")?;
460 Self::validate_base58(&request.base_token, "base_token")?;
461 Self::validate_base58(&request.quote_token, "quote_token")?;
462 Self::validate_signature(&request.signature)?;
463
464 let url = format!("{}/api/orders/submit", self.base_url);
465 self.post(&url, &request).await
466 }
467
468 pub async fn submit_full_order(
489 &self,
490 order: &FullOrder,
491 orderbook_id: impl Into<String>,
492 ) -> ApiResult<OrderResponse> {
493 let request = order.to_submit_request(orderbook_id);
494 self.submit_order(request).await
495 }
496
497 pub async fn cancel_order(&self, order_hash: &str, maker: &str) -> ApiResult<CancelResponse> {
501 Self::validate_base58(maker, "maker")?;
502
503 let url = format!("{}/api/orders/cancel", self.base_url);
504 let request = CancelOrderRequest {
505 order_hash: order_hash.to_string(),
506 maker: maker.to_string(),
507 };
508 self.post(&url, &request).await
509 }
510
511 pub async fn cancel_all_orders(
515 &self,
516 user_pubkey: &str,
517 market_pubkey: Option<&str>,
518 ) -> ApiResult<CancelAllResponse> {
519 Self::validate_base58(user_pubkey, "user_pubkey")?;
520 if let Some(market) = market_pubkey {
521 Self::validate_base58(market, "market_pubkey")?;
522 }
523
524 let url = format!("{}/api/orders/cancel-all", self.base_url);
525 let request = CancelAllOrdersRequest {
526 user_pubkey: user_pubkey.to_string(),
527 market_pubkey: market_pubkey.map(|s| s.to_string()),
528 };
529 self.post(&url, &request).await
530 }
531
532 pub async fn get_user_positions(&self, user_pubkey: &str) -> ApiResult<PositionsResponse> {
538 Self::validate_base58(user_pubkey, "user_pubkey")?;
539 let url = format!("{}/api/users/{}/positions", self.base_url, urlencoding::encode(user_pubkey));
540 self.get(&url).await
541 }
542
543 pub async fn get_user_market_positions(
545 &self,
546 user_pubkey: &str,
547 market_pubkey: &str,
548 ) -> ApiResult<MarketPositionsResponse> {
549 Self::validate_base58(user_pubkey, "user_pubkey")?;
550 Self::validate_base58(market_pubkey, "market_pubkey")?;
551
552 let url = format!(
553 "{}/api/users/{}/markets/{}/positions",
554 self.base_url,
555 urlencoding::encode(user_pubkey),
556 urlencoding::encode(market_pubkey)
557 );
558 self.get(&url).await
559 }
560
561 pub async fn get_user_orders(&self, user_pubkey: &str) -> ApiResult<UserOrdersResponse> {
563 Self::validate_base58(user_pubkey, "user_pubkey")?;
564
565 let url = format!("{}/api/users/orders", self.base_url);
566 let request = GetUserOrdersRequest {
567 user_pubkey: user_pubkey.to_string(),
568 };
569 self.post(&url, &request).await
570 }
571
572 pub async fn get_price_history(
578 &self,
579 params: PriceHistoryParams,
580 ) -> ApiResult<PriceHistoryResponse> {
581 if let Some(limit) = params.limit {
582 Self::validate_limit(limit, MAX_PAGINATION_LIMIT)?;
583 }
584
585 let mut url = format!(
586 "{}/api/price-history?orderbook_id={}",
587 self.base_url,
588 urlencoding::encode(¶ms.orderbook_id)
589 );
590
591 if let Some(resolution) = params.resolution {
592 url.push_str(&format!("&resolution={}", urlencoding::encode(&resolution.to_string())));
593 }
594 if let Some(from) = params.from {
595 url.push_str(&format!("&from={}", from));
596 }
597 if let Some(to) = params.to {
598 url.push_str(&format!("&to={}", to));
599 }
600 if let Some(cursor) = params.cursor {
601 url.push_str(&format!("&cursor={}", cursor));
602 }
603 if let Some(limit) = params.limit {
604 url.push_str(&format!("&limit={}", limit));
605 }
606 if let Some(include_ohlcv) = params.include_ohlcv {
607 url.push_str(&format!("&include_ohlcv={}", include_ohlcv));
608 }
609
610 self.get(&url).await
611 }
612
613 pub async fn get_trades(&self, params: TradesParams) -> ApiResult<TradesResponse> {
619 if let Some(ref user_pubkey) = params.user_pubkey {
620 Self::validate_base58(user_pubkey, "user_pubkey")?;
621 }
622 if let Some(limit) = params.limit {
623 Self::validate_limit(limit, MAX_PAGINATION_LIMIT)?;
624 }
625
626 let mut url = format!(
627 "{}/api/trades?orderbook_id={}",
628 self.base_url,
629 urlencoding::encode(¶ms.orderbook_id)
630 );
631
632 if let Some(user_pubkey) = params.user_pubkey {
633 url.push_str(&format!("&user_pubkey={}", urlencoding::encode(&user_pubkey)));
634 }
635 if let Some(from) = params.from {
636 url.push_str(&format!("&from={}", from));
637 }
638 if let Some(to) = params.to {
639 url.push_str(&format!("&to={}", to));
640 }
641 if let Some(cursor) = params.cursor {
642 url.push_str(&format!("&cursor={}", cursor));
643 }
644 if let Some(limit) = params.limit {
645 url.push_str(&format!("&limit={}", limit));
646 }
647
648 self.get(&url).await
649 }
650
651 pub async fn admin_health_check(&self) -> ApiResult<AdminResponse> {
657 let url = format!("{}/api/admin/test", self.base_url);
658 self.get(&url).await
659 }
660
661 pub async fn create_orderbook(
663 &self,
664 request: CreateOrderbookRequest,
665 ) -> ApiResult<CreateOrderbookResponse> {
666 Self::validate_base58(&request.market_pubkey, "market_pubkey")?;
667 Self::validate_base58(&request.base_token, "base_token")?;
668 Self::validate_base58(&request.quote_token, "quote_token")?;
669
670 let url = format!("{}/api/admin/create-orderbook", self.base_url);
671 self.post(&url, &request).await
672 }
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678 use crate::shared::Resolution;
679
680 #[test]
681 fn test_client_creation() {
682 let client = LightconeApiClient::new("https://api.lightcone.xyz").unwrap();
683 assert_eq!(client.base_url(), "https://api.lightcone.xyz");
684 }
685
686 #[test]
687 fn test_client_builder() {
688 let client = LightconeApiClient::builder("https://api.lightcone.xyz/")
689 .timeout_secs(60)
690 .header("X-Custom", "test")
691 .build()
692 .unwrap();
693
694 assert_eq!(client.base_url(), "https://api.lightcone.xyz");
696 }
697
698 #[test]
699 fn test_price_history_params() {
700 let params = PriceHistoryParams::new("orderbook1")
701 .with_resolution(Resolution::OneHour)
702 .with_time_range(1000, 2000)
703 .with_limit(100)
704 .with_ohlcv();
705
706 assert_eq!(params.orderbook_id, "orderbook1");
707 assert_eq!(params.resolution, Some(Resolution::OneHour));
708 assert_eq!(params.from, Some(1000));
709 assert_eq!(params.to, Some(2000));
710 assert_eq!(params.limit, Some(100));
711 assert_eq!(params.include_ohlcv, Some(true));
712 }
713
714 #[test]
715 fn test_trades_params() {
716 let params = TradesParams::new("orderbook1")
717 .with_user("user123")
718 .with_time_range(1000, 2000)
719 .with_cursor(50)
720 .with_limit(100);
721
722 assert_eq!(params.orderbook_id, "orderbook1");
723 assert_eq!(params.user_pubkey, Some("user123".to_string()));
724 assert_eq!(params.from, Some(1000));
725 assert_eq!(params.to, Some(2000));
726 assert_eq!(params.cursor, Some(50));
727 assert_eq!(params.limit, Some(100));
728 }
729
730 #[test]
731 fn test_create_orderbook_request() {
732 let request = CreateOrderbookRequest::new("market1", "base1", "quote1").with_tick_size(500);
733
734 assert_eq!(request.market_pubkey, "market1");
735 assert_eq!(request.base_token, "base1");
736 assert_eq!(request.quote_token, "quote1");
737 assert_eq!(request.tick_size, Some(500));
738 }
739
740 #[test]
741 fn test_retry_config() {
742 let config = RetryConfig::new(3)
743 .with_base_delay_ms(200)
744 .with_max_delay_ms(5000);
745
746 assert_eq!(config.max_retries, 3);
747 assert_eq!(config.base_delay_ms, 200);
748 assert_eq!(config.max_delay_ms, 5000);
749 }
750
751 #[test]
752 fn test_client_with_retry() {
753 let client = LightconeApiClient::builder("https://api.lightcone.xyz")
754 .with_retry(RetryConfig::new(3))
755 .build()
756 .unwrap();
757
758 assert_eq!(client.retry_config.max_retries, 3);
759 }
760
761 #[test]
762 fn test_retry_delay_calculation() {
763 let config = RetryConfig {
764 max_retries: 5,
765 base_delay_ms: 100,
766 max_delay_ms: 1000,
767 };
768
769 let delay0 = config.delay_for_attempt(0);
771 assert!(delay0.as_millis() >= 75 && delay0.as_millis() <= 100);
772
773 let delay1 = config.delay_for_attempt(1);
775 assert!(delay1.as_millis() >= 150 && delay1.as_millis() <= 200);
776
777 let delay3 = config.delay_for_attempt(3);
779 assert!(delay3.as_millis() >= 600 && delay3.as_millis() <= 800);
780
781 let delay10 = config.delay_for_attempt(10);
783 assert!(delay10.as_millis() >= 750 && delay10.as_millis() <= 1000);
784 }
785}