Skip to main content

polyoxide_data/
client.rs

1use polyoxide_core::{
2    HttpClient, HttpClientBuilder, RateLimiter, RetryConfig, DEFAULT_POOL_SIZE, DEFAULT_TIMEOUT_MS,
3};
4
5use crate::{
6    api::{
7        accounting::AccountingApi,
8        builders::BuildersApi,
9        health::Health,
10        holders::Holders,
11        leaderboard::LeaderboardApi,
12        live_volume::LiveVolumeApi,
13        market_positions::MarketPositionsApi,
14        open_interest::OpenInterestApi,
15        trades::Trades,
16        users::{UserApi, UserTraded},
17    },
18    error::DataApiError,
19};
20
21const DEFAULT_BASE_URL: &str = "https://data-api.polymarket.com";
22
23/// Main Data API client
24#[derive(Clone)]
25pub struct DataApi {
26    pub(crate) http_client: HttpClient,
27}
28
29impl DataApi {
30    /// Create a new Data API client with default configuration
31    pub fn new() -> Result<Self, DataApiError> {
32        Self::builder().build()
33    }
34
35    /// Create a builder for configuring the client
36    pub fn builder() -> DataApiBuilder {
37        DataApiBuilder::new()
38    }
39
40    /// Get health namespace
41    pub fn health(&self) -> Health {
42        Health {
43            http_client: self.http_client.clone(),
44        }
45    }
46
47    /// Get user namespace for user-specific operations
48    pub fn user(&self, user_address: impl Into<String>) -> UserApi {
49        UserApi {
50            http_client: self.http_client.clone(),
51            user_address: user_address.into(),
52        }
53    }
54
55    /// Alias for `user()` - for backwards compatibility
56    pub fn positions(&self, user_address: impl Into<String>) -> UserApi {
57        self.user(user_address)
58    }
59
60    /// Get traded namespace for backwards compatibility
61    pub fn traded(&self, user_address: impl Into<String>) -> Traded {
62        Traded {
63            user_api: self.user(user_address),
64        }
65    }
66
67    /// Get trades namespace
68    pub fn trades(&self) -> Trades {
69        Trades {
70            http_client: self.http_client.clone(),
71        }
72    }
73
74    /// Get holders namespace
75    pub fn holders(&self) -> Holders {
76        Holders {
77            http_client: self.http_client.clone(),
78        }
79    }
80
81    /// Get open interest namespace
82    pub fn open_interest(&self) -> OpenInterestApi {
83        OpenInterestApi {
84            http_client: self.http_client.clone(),
85        }
86    }
87
88    /// Get live volume namespace
89    pub fn live_volume(&self) -> LiveVolumeApi {
90        LiveVolumeApi {
91            http_client: self.http_client.clone(),
92        }
93    }
94
95    /// Get builders namespace
96    pub fn builders(&self) -> BuildersApi {
97        BuildersApi {
98            http_client: self.http_client.clone(),
99        }
100    }
101
102    /// Get leaderboard namespace
103    pub fn leaderboard(&self) -> LeaderboardApi {
104        LeaderboardApi {
105            http_client: self.http_client.clone(),
106        }
107    }
108
109    /// Get market-positions namespace (`/v1/market-positions`)
110    pub fn market_positions(&self) -> MarketPositionsApi {
111        MarketPositionsApi {
112            http_client: self.http_client.clone(),
113        }
114    }
115
116    /// Get accounting namespace (`/v1/accounting/snapshot`, returns ZIP bytes)
117    pub fn accounting(&self) -> AccountingApi {
118        AccountingApi {
119            http_client: self.http_client.clone(),
120        }
121    }
122}
123
124/// Builder for configuring Data API client
125pub struct DataApiBuilder {
126    base_url: String,
127    timeout_ms: u64,
128    pool_size: usize,
129    retry_config: Option<RetryConfig>,
130    max_concurrent: Option<usize>,
131}
132
133impl DataApiBuilder {
134    fn new() -> Self {
135        Self {
136            base_url: DEFAULT_BASE_URL.to_string(),
137            timeout_ms: DEFAULT_TIMEOUT_MS,
138            pool_size: DEFAULT_POOL_SIZE,
139            retry_config: None,
140            max_concurrent: None,
141        }
142    }
143
144    /// Set base URL for the API
145    pub fn base_url(mut self, url: impl Into<String>) -> Self {
146        self.base_url = url.into();
147        self
148    }
149
150    /// Set request timeout in milliseconds
151    pub fn timeout_ms(mut self, timeout: u64) -> Self {
152        self.timeout_ms = timeout;
153        self
154    }
155
156    /// Set connection pool size
157    pub fn pool_size(mut self, size: usize) -> Self {
158        self.pool_size = size;
159        self
160    }
161
162    /// Set retry configuration for 429 responses
163    pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
164        self.retry_config = Some(config);
165        self
166    }
167
168    /// Set the maximum number of concurrent in-flight requests.
169    ///
170    /// Default: 4. Prevents Cloudflare 1015 errors from request bursts.
171    pub fn max_concurrent(mut self, max: usize) -> Self {
172        self.max_concurrent = Some(max);
173        self
174    }
175
176    /// Build the Data API client
177    pub fn build(self) -> Result<DataApi, DataApiError> {
178        let mut builder = HttpClientBuilder::new(&self.base_url)
179            .timeout_ms(self.timeout_ms)
180            .pool_size(self.pool_size)
181            .with_rate_limiter(RateLimiter::data_default())
182            .with_max_concurrent(self.max_concurrent.unwrap_or(4));
183        if let Some(config) = self.retry_config {
184            builder = builder.with_retry_config(config);
185        }
186        let http_client = builder.build()?;
187
188        Ok(DataApi { http_client })
189    }
190}
191
192impl Default for DataApiBuilder {
193    fn default() -> Self {
194        Self::new()
195    }
196}
197
198/// Wrapper for backwards compatibility with traded() API
199pub struct Traded {
200    user_api: UserApi,
201}
202
203impl Traded {
204    /// Get total markets traded by the user
205    pub async fn get(self) -> std::result::Result<UserTraded, DataApiError> {
206        self.user_api.traded().await
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn test_builder_default() {
216        let builder = DataApiBuilder::default();
217        assert_eq!(builder.base_url, DEFAULT_BASE_URL);
218    }
219
220    #[test]
221    fn test_builder_custom_retry_config() {
222        let config = RetryConfig {
223            max_retries: 5,
224            initial_backoff_ms: 1000,
225            max_backoff_ms: 30_000,
226        };
227        let builder = DataApiBuilder::new().with_retry_config(config);
228        let config = builder.retry_config.unwrap();
229        assert_eq!(config.max_retries, 5);
230        assert_eq!(config.initial_backoff_ms, 1000);
231    }
232
233    #[test]
234    fn test_builder_custom_max_concurrent() {
235        let builder = DataApiBuilder::new().max_concurrent(10);
236        assert_eq!(builder.max_concurrent, Some(10));
237    }
238
239    #[tokio::test]
240    async fn test_default_concurrency_limit_is_4() {
241        let data = DataApi::new().unwrap();
242        let mut permits = Vec::new();
243        for _ in 0..4 {
244            permits.push(data.http_client.acquire_concurrency().await);
245        }
246        assert!(permits.iter().all(|p| p.is_some()));
247
248        let result = tokio::time::timeout(
249            std::time::Duration::from_millis(50),
250            data.http_client.acquire_concurrency(),
251        )
252        .await;
253        assert!(
254            result.is_err(),
255            "5th permit should block with default limit of 4"
256        );
257    }
258
259    #[test]
260    fn test_builder_build_success() {
261        let data = DataApi::builder().build();
262        assert!(data.is_ok());
263    }
264}