Skip to main content

polymarket_client/
public_client.rs

1use crate::environment::Environment;
2use crate::error::{
3    unexpected_response, user_input, FetchMarketError, FetchMidpointError, FetchOrderBookError,
4    ListEventsError, ListMarketsError, RateLimitError, TransportError, UserInputError,
5};
6use crate::http::ServiceClient;
7use crate::pagination::{ListEventsPaginator, ListMarketsPaginator, Page, Paginator};
8use crate::params::{as_reqwest_pairs, events_query, markets_query};
9use polymarket_bindings::clob::{MidpointResponse, OrderBook};
10use polymarket_bindings::gamma::{
11    Event, GammaMarket, ListEventsKeysetRaw, ListEventsKeysetResponse, ListMarketsKeysetRaw,
12    ListMarketsKeysetResponse, Market,
13};
14use polymarket_types::{MarketId, PaginationCursor, TokenId};
15
16/// Read-only Polymarket client for discovery and market data.
17#[derive(Clone)]
18pub struct PublicClient {
19    environment: Environment,
20    gamma: ServiceClient,
21    clob: ServiceClient,
22    #[cfg(feature = "account")]
23    pub(crate) data: polymarket_client_sdk_v2::data::Client,
24    #[cfg(feature = "websockets")]
25    pub(crate) ws: std::sync::Arc<crate::subscriptions::WebSocketClients>,
26}
27
28/// Builder for [`PublicClient`].
29#[derive(Clone, Debug, Default)]
30pub struct PublicClientBuilder {
31    environment: Option<Environment>,
32}
33
34impl PublicClientBuilder {
35    #[must_use]
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    #[must_use]
41    pub fn environment(mut self, environment: Environment) -> Self {
42        self.environment = Some(environment);
43        self
44    }
45
46    pub fn build(self) -> Result<PublicClient, TransportError> {
47        PublicClient::with_environment(self.environment.unwrap_or_else(Environment::production))
48    }
49}
50
51impl PublicClient {
52    pub fn new(environment: Environment) -> Self {
53        Self::with_environment(environment).expect("failed to construct HTTP clients")
54    }
55
56    pub fn builder() -> PublicClientBuilder {
57        PublicClientBuilder::new()
58    }
59
60    pub fn with_environment(environment: Environment) -> Result<Self, TransportError> {
61        Ok(Self {
62            gamma: ServiceClient::new(environment.gamma)?,
63            clob: ServiceClient::new(environment.clob)?,
64            #[cfg(feature = "account")]
65            data: polymarket_client_sdk_v2::data::Client::new(environment.data)
66                .map_err(|e| TransportError(e.to_string()))?,
67            #[cfg(feature = "websockets")]
68            ws: std::sync::Arc::new(
69                crate::subscriptions::WebSocketClients::new(&environment)
70                    .map_err(|e| TransportError(e.to_string()))?,
71            ),
72            environment,
73        })
74    }
75
76    #[must_use]
77    pub fn environment(&self) -> &Environment {
78        &self.environment
79    }
80
81    pub fn list_markets(
82        &self,
83        request: ListMarketsRequest,
84    ) -> Result<ListMarketsPaginator, UserInputError> {
85        validate_page_size(request.page_size)?;
86
87        let gamma = self.gamma.clone();
88        let base_request = request.clone();
89        let initial_cursor = request.cursor;
90
91        Ok(Paginator::new(
92            move |cursor| {
93                let gamma = gamma.clone();
94                let mut req = base_request.clone();
95                req.cursor = cursor;
96                Box::pin(async move { fetch_markets_page(&gamma, &req).await })
97            },
98            initial_cursor,
99        ))
100    }
101
102    pub async fn fetch_market(
103        &self,
104        request: FetchMarketRequest,
105    ) -> Result<Market, FetchMarketError> {
106        let path = match request {
107            FetchMarketRequest::Id { id } => {
108                let id = MarketId::parse(id)
109                    .map_err(|e| FetchMarketError::from(user_input(e.message)))?;
110                format!("/markets/{id}")
111            }
112            FetchMarketRequest::Slug { slug } => {
113                if slug.trim().is_empty() {
114                    return Err(FetchMarketError::from(user_input("slug cannot be empty")));
115                }
116                format!("/markets/slug/{slug}")
117            }
118            FetchMarketRequest::Url { url } => {
119                let slug = parse_polymarket_slug(&url, "market")
120                    .map_err(|e| FetchMarketError::from(user_input(e)))?;
121                format!("/markets/slug/{slug}")
122            }
123        };
124
125        let response = self
126            .gamma
127            .get(&path, &[])
128            .await
129            .map_err(FetchMarketError::from)?;
130        let response = ServiceClient::ensure_success(response)
131            .await
132            .map_err(FetchMarketError::from)?;
133        let raw: GammaMarket = ServiceClient::json(response)
134            .await
135            .map_err(FetchMarketError::from)?;
136
137        polymarket_bindings::gamma::try_normalize_market(raw)
138            .map_err(|e| FetchMarketError::from(unexpected_response(e)))
139    }
140
141    pub fn list_events(
142        &self,
143        request: ListEventsRequest,
144    ) -> Result<ListEventsPaginator, UserInputError> {
145        validate_page_size(request.page_size)?;
146
147        let gamma = self.gamma.clone();
148        let base_request = request.clone();
149        let initial_cursor = request.cursor;
150
151        Ok(Paginator::new(
152            move |cursor| {
153                let gamma = gamma.clone();
154                let mut req = base_request.clone();
155                req.cursor = cursor;
156                Box::pin(async move { fetch_events_page(&gamma, &req).await })
157            },
158            initial_cursor,
159        ))
160    }
161
162    pub async fn fetch_midpoint(
163        &self,
164        request: FetchMidpointRequest,
165    ) -> Result<String, FetchMidpointError> {
166        let token_id = TokenId::parse(request.token_id)
167            .map_err(|e| FetchMidpointError::from(user_input(e.message)))?;
168
169        let query = vec![("token_id", token_id.as_str().to_string())];
170        let response = self
171            .clob
172            .get("/midpoint", &query)
173            .await
174            .map_err(FetchMidpointError::from)?;
175        let response = ServiceClient::ensure_success(response)
176            .await
177            .map_err(FetchMidpointError::from)?;
178        let parsed: MidpointResponse = ServiceClient::json(response)
179            .await
180            .map_err(FetchMidpointError::from)?;
181
182        parsed
183            .into_mid()
184            .map_err(|e| FetchMidpointError::from(unexpected_response(e)))
185    }
186
187    pub async fn fetch_order_book(
188        &self,
189        request: FetchOrderBookRequest,
190    ) -> Result<OrderBook, FetchOrderBookError> {
191        let token_id = TokenId::parse(request.token_id)
192            .map_err(|e| FetchOrderBookError::from(user_input(e.message)))?;
193
194        let query = vec![("token_id", token_id.as_str().to_string())];
195        let response = self
196            .clob
197            .get("/book", &query)
198            .await
199            .map_err(FetchOrderBookError::from)?;
200        let response = ServiceClient::ensure_success(response)
201            .await
202            .map_err(FetchOrderBookError::from)?;
203        ServiceClient::json(response)
204            .await
205            .map_err(FetchOrderBookError::from)
206    }
207}
208
209#[derive(Clone, Debug, Default)]
210pub struct ListMarketsRequest {
211    pub closed: Option<bool>,
212    pub page_size: Option<u32>,
213    pub cursor: Option<PaginationCursor>,
214    pub order: Option<String>,
215    pub ascending: Option<bool>,
216    pub slug: Option<Vec<String>>,
217    pub tag_id: Option<i64>,
218}
219
220#[derive(Clone, Debug, Default)]
221pub struct ListEventsRequest {
222    pub closed: Option<bool>,
223    pub page_size: Option<u32>,
224    pub cursor: Option<PaginationCursor>,
225    pub order: Option<String>,
226    pub ascending: Option<bool>,
227    pub featured: Option<bool>,
228}
229
230pub enum FetchMarketRequest {
231    Id { id: String },
232    Slug { slug: String },
233    Url { url: String },
234}
235
236pub struct FetchMidpointRequest {
237    pub token_id: String,
238}
239
240pub struct FetchOrderBookRequest {
241    pub token_id: String,
242}
243
244async fn fetch_markets_page(
245    gamma: &ServiceClient,
246    request: &ListMarketsRequest,
247) -> Result<Page<Vec<Market>>, ListMarketsError> {
248    let query = markets_query(request);
249    let pairs = as_reqwest_pairs(&query);
250    let response = gamma.get("/markets/keyset", &pairs).await?;
251    if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
252        return Err(ListMarketsError::from(RateLimitError));
253    }
254    let response = ServiceClient::ensure_success(response).await?;
255    let raw: ListMarketsKeysetRaw = ServiceClient::json(response).await?;
256    let parsed = ListMarketsKeysetResponse::from_raw(raw);
257
258    Ok(Page {
259        has_more: parsed.next_cursor.is_some(),
260        next_cursor: parsed.next_cursor,
261        items: parsed.items,
262    })
263}
264
265async fn fetch_events_page(
266    gamma: &ServiceClient,
267    request: &ListEventsRequest,
268) -> Result<Page<Vec<Event>>, ListEventsError> {
269    let query = events_query(request);
270    let pairs = as_reqwest_pairs(&query);
271    let response = gamma.get("/events/keyset", &pairs).await?;
272    if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
273        return Err(ListEventsError::from(RateLimitError));
274    }
275    let response = ServiceClient::ensure_success(response).await?;
276    let raw: ListEventsKeysetRaw = ServiceClient::json(response).await?;
277    let parsed = ListEventsKeysetResponse::from_raw(raw);
278
279    Ok(Page {
280        has_more: parsed.next_cursor.is_some(),
281        next_cursor: parsed.next_cursor,
282        items: parsed.items,
283    })
284}
285
286fn validate_page_size(page_size: Option<u32>) -> Result<(), UserInputError> {
287    if let Some(size) = page_size {
288        if size == 0 {
289            return Err(user_input("page_size must be positive"));
290        }
291    }
292    Ok(())
293}
294
295fn parse_polymarket_slug(url: &str, kind: &str) -> Result<String, String> {
296    let parsed = url::Url::parse(url).map_err(|e| format!("invalid url: {e}"))?;
297    let segments: Vec<_> = parsed
298        .path_segments()
299        .ok_or_else(|| "url has no path".to_string())?
300        .filter(|s| !s.is_empty())
301        .collect();
302
303    let pos = segments
304        .iter()
305        .position(|s| *s == kind)
306        .ok_or_else(|| format!("url does not contain /{kind}/ segment"))?;
307    let slug = segments
308        .get(pos + 1)
309        .ok_or_else(|| format!("missing slug after /{kind}/"))?;
310    Ok((*slug).to_string())
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn parses_market_url() {
319        let slug = parse_polymarket_slug(
320            "https://polymarket.com/market/eth-flipped-in-2026",
321            "market",
322        )
323        .unwrap();
324        assert_eq!(slug, "eth-flipped-in-2026");
325    }
326}