Skip to main content

predict_fun_sdk/
api.rs

1//! Predict.fun REST API client.
2//!
3//! Thin wrapper around the [OpenAPI surface](https://api.predict.fun/docs)
4//! with typed methods for all 30 endpoints.
5
6use anyhow::{anyhow, Context, Result};
7use reqwest::{Method, StatusCode};
8use serde_json::{json, Value};
9
10pub const PREDICT_MAINNET_BASE: &str = "https://api.predict.fun/v1";
11pub const PREDICT_TESTNET_BASE: &str = "https://api-testnet.predict.fun/v1";
12
13/// Raw API response for diagnostics. Only returned by `raw_get`/`raw_post`.
14#[derive(Debug, Clone)]
15pub struct RawApiResponse {
16    pub status: StatusCode,
17    pub json: Option<Value>,
18}
19
20/// Thin REST wrapper around Predict's OpenAPI surface (BNB Chain).
21///
22/// Most methods return `serde_json::Value` intentionally to keep it resilient
23/// to API/schema drift while we stabilize integration.
24///
25/// Connection pooling: HTTP/2 with keep-alive, 16 idle conns per host.
26#[derive(Clone)]
27pub struct PredictApiClient {
28    base: String,
29    api_key: Option<String>,
30    jwt: Option<String>,
31    http: reqwest::Client,
32}
33
34impl PredictApiClient {
35    pub fn new_mainnet(api_key: impl Into<String>) -> Result<Self> {
36        Self::new(PREDICT_MAINNET_BASE, Some(api_key.into()), None)
37    }
38
39    pub fn new_testnet() -> Result<Self> {
40        Self::new(PREDICT_TESTNET_BASE, None, None)
41    }
42
43    pub fn new(
44        base: impl Into<String>,
45        api_key: Option<String>,
46        jwt: Option<String>,
47    ) -> Result<Self> {
48        let http = reqwest::Client::builder()
49            .timeout(std::time::Duration::from_secs(10))
50            .connect_timeout(std::time::Duration::from_secs(5))
51            .pool_max_idle_per_host(16)
52            .pool_idle_timeout(std::time::Duration::from_secs(90))
53            .tcp_keepalive(std::time::Duration::from_secs(30))
54            .build()
55            .context("failed to build predict api client")?;
56
57        Ok(Self {
58            base: base.into().trim_end_matches('/').to_string(),
59            api_key,
60            jwt,
61            http,
62        })
63    }
64
65    pub fn with_jwt(mut self, jwt: impl Into<String>) -> Self {
66        self.jwt = Some(jwt.into());
67        self
68    }
69
70    pub fn set_jwt(&mut self, jwt: impl Into<String>) {
71        self.jwt = Some(jwt.into());
72    }
73
74    pub fn clear_jwt(&mut self) {
75        self.jwt = None;
76    }
77
78    pub fn has_jwt(&self) -> bool {
79        self.jwt.is_some()
80    }
81
82    // === Auth ===
83
84    pub async fn auth_message(&self) -> Result<Value> {
85        self.get_ok("/auth/message", &[], false).await
86    }
87
88    pub async fn auth(&self, signer: &str, message: &str, signature: &str) -> Result<Value> {
89        let body = json!({
90            "signer": signer,
91            "message": message,
92            "signature": signature,
93        });
94        self.post_ok("/auth", &[], body, false).await
95    }
96
97    // === Orders ===
98
99    pub async fn create_order(&self, body: Value) -> Result<Value> {
100        self.post_ok("/orders", &[], body, true).await
101    }
102
103    pub async fn list_orders(&self, query: &[(&str, String)]) -> Result<Value> {
104        self.get_ok("/orders", query, true).await
105    }
106
107    pub async fn remove_orders(&self, body: Value) -> Result<Value> {
108        self.post_ok("/orders/remove", &[], body, true).await
109    }
110
111    pub async fn get_order(&self, hash: &str) -> Result<Value> {
112        self.get_ok(&format!("/orders/{}", hash), &[], true).await
113    }
114
115    pub async fn get_order_matches(&self, query: &[(&str, String)]) -> Result<Value> {
116        self.get_ok("/orders/matches", query, false).await
117    }
118
119    // === Markets ===
120
121    pub async fn list_markets(&self, query: &[(&str, String)]) -> Result<Value> {
122        self.get_ok("/markets", query, false).await
123    }
124
125    pub async fn get_market(&self, id: i64) -> Result<Value> {
126        self.get_ok(&format!("/markets/{}", id), &[], false).await
127    }
128
129    pub async fn get_market_stats(&self, id: i64) -> Result<Value> {
130        self.get_ok(&format!("/markets/{}/stats", id), &[], false)
131            .await
132    }
133
134    pub async fn get_market_last_sale(&self, id: i64) -> Result<Value> {
135        self.get_ok(&format!("/markets/{}/last-sale", id), &[], false)
136            .await
137    }
138
139    pub async fn get_market_orderbook(&self, id: i64) -> Result<Value> {
140        self.get_ok(&format!("/markets/{}/orderbook", id), &[], false)
141            .await
142    }
143
144    pub async fn get_market_timeseries(&self, id: i64, query: &[(&str, String)]) -> Result<Value> {
145        self.get_ok(&format!("/markets/{}/timeseries", id), query, false)
146            .await
147    }
148
149    pub async fn get_market_timeseries_latest(&self, id: i64) -> Result<Value> {
150        self.get_ok(&format!("/markets/{}/timeseries/latest", id), &[], false)
151            .await
152    }
153
154    // === Categories / tags ===
155
156    pub async fn list_categories(&self, query: &[(&str, String)]) -> Result<Value> {
157        self.get_ok("/categories", query, false).await
158    }
159
160    pub async fn get_category(&self, slug: &str) -> Result<Value> {
161        self.get_ok(&format!("/categories/{}", slug), &[], false)
162            .await
163    }
164
165    pub async fn get_category_stats(&self, id: i64) -> Result<Value> {
166        self.get_ok(&format!("/categories/{}/stats", id), &[], false)
167            .await
168    }
169
170    pub async fn list_tags(&self) -> Result<Value> {
171        self.get_ok("/tags", &[], false).await
172    }
173
174    // === Positions ===
175
176    pub async fn list_positions(&self, query: &[(&str, String)]) -> Result<Value> {
177        self.get_ok("/positions", query, true).await
178    }
179
180    pub async fn list_positions_for_address(
181        &self,
182        address: &str,
183        query: &[(&str, String)],
184    ) -> Result<Value> {
185        self.get_ok(&format!("/positions/{}", address), query, false)
186            .await
187    }
188
189    // === Account ===
190
191    pub async fn account(&self) -> Result<Value> {
192        self.get_ok("/account", &[], true).await
193    }
194
195    pub async fn set_referral(&self, code: &str) -> Result<Value> {
196        let body = json!({ "code": code });
197        self.post_ok("/account/referral", &[], body, true).await
198    }
199
200    pub async fn account_activity(&self, query: &[(&str, String)]) -> Result<Value> {
201        self.get_ok("/account/activity", query, true).await
202    }
203
204    // === OAuth ===
205
206    pub async fn oauth_finalize(&self, body: Value) -> Result<Value> {
207        self.post_ok("/oauth/finalize", &[], body, false).await
208    }
209
210    pub async fn oauth_orders(&self, body: Value) -> Result<Value> {
211        self.post_ok("/oauth/orders", &[], body, false).await
212    }
213
214    pub async fn oauth_create_order(&self, body: Value) -> Result<Value> {
215        self.post_ok("/oauth/orders/create", &[], body, false).await
216    }
217
218    pub async fn oauth_cancel_order(&self, body: Value) -> Result<Value> {
219        self.post_ok("/oauth/orders/cancel", &[], body, false).await
220    }
221
222    pub async fn oauth_positions(&self, body: Value) -> Result<Value> {
223        self.post_ok("/oauth/positions", &[], body, false).await
224    }
225
226    // === Search / Yield ===
227
228    pub async fn search(&self, query: &[(&str, String)]) -> Result<Value> {
229        self.get_ok("/search", query, false).await
230    }
231
232    pub async fn yield_pending(&self, query: &[(&str, String)]) -> Result<Value> {
233        self.get_ok("/yield/pending", query, true).await
234    }
235
236    // === Raw helpers (for probes / diagnostics) ===
237
238    pub async fn raw_get(
239        &self,
240        path: &str,
241        query: &[(&str, String)],
242        require_jwt: bool,
243    ) -> Result<RawApiResponse> {
244        self.raw_request(Method::GET, path, query, None, require_jwt)
245            .await
246    }
247
248    pub async fn raw_post(
249        &self,
250        path: &str,
251        query: &[(&str, String)],
252        body: Value,
253        require_jwt: bool,
254    ) -> Result<RawApiResponse> {
255        self.raw_request(Method::POST, path, query, Some(body), require_jwt)
256            .await
257    }
258
259    // === Internal ===
260
261    async fn get_ok(
262        &self,
263        path: &str,
264        query: &[(&str, String)],
265        require_jwt: bool,
266    ) -> Result<Value> {
267        let resp = self
268            .raw_request(Method::GET, path, query, None, require_jwt)
269            .await?;
270        self.expect_success(resp, "GET", path)
271    }
272
273    async fn post_ok(
274        &self,
275        path: &str,
276        query: &[(&str, String)],
277        body: Value,
278        require_jwt: bool,
279    ) -> Result<Value> {
280        let resp = self
281            .raw_request(Method::POST, path, query, Some(body), require_jwt)
282            .await?;
283        self.expect_success(resp, "POST", path)
284    }
285
286    fn expect_success(&self, resp: RawApiResponse, method: &str, path: &str) -> Result<Value> {
287        if !resp.status.is_success() {
288            let body_str = resp
289                .json
290                .as_ref()
291                .map(|j| j.to_string())
292                .unwrap_or_default();
293            return Err(anyhow!(
294                "Predict API {} {} failed: status={} body={}",
295                method,
296                path,
297                resp.status,
298                &body_str[..body_str.len().min(500)]
299            ));
300        }
301
302        resp.json
303            .ok_or_else(|| anyhow!("Predict API {} {} returned non-JSON body", method, path))
304    }
305
306    async fn raw_request(
307        &self,
308        method: Method,
309        path: &str,
310        query: &[(&str, String)],
311        body: Option<Value>,
312        require_jwt: bool,
313    ) -> Result<RawApiResponse> {
314        if require_jwt && self.jwt.is_none() {
315            return Err(anyhow!(
316                "JWT required for {} {} but not set — call authenticate first",
317                method,
318                path
319            ));
320        }
321
322        let url = format!("{}{}", self.base, path);
323        let mut req = self
324            .http
325            .request(method.clone(), &url)
326            .header("Accept", "application/json")
327            .header("Content-Type", "application/json");
328
329        if !query.is_empty() {
330            req = req.query(query);
331        }
332
333        if let Some(api_key) = &self.api_key {
334            req = req.header("x-api-key", api_key);
335        }
336
337        if let Some(jwt) = &self.jwt {
338            req = req.header("Authorization", format!("Bearer {}", jwt));
339        }
340
341        if let Some(v) = body {
342            req = req.json(&v);
343        }
344
345        let resp = req
346            .send()
347            .await
348            .with_context(|| format!("predict api {} {} failed", method, path))?;
349
350        let status = resp.status();
351        let json = resp.json::<Value>().await.ok();
352
353        Ok(RawApiResponse { status, json })
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn client_construction() {
363        let client = PredictApiClient::new_mainnet("test-key").unwrap();
364        assert!(!client.has_jwt());
365
366        let client = client.with_jwt("token123");
367        assert!(client.has_jwt());
368    }
369}