use anyhow::{anyhow, Context, Result};
use reqwest::{Method, StatusCode};
use serde_json::{json, Value};
pub const PREDICT_MAINNET_BASE: &str = "https://api.predict.fun/v1";
pub const PREDICT_TESTNET_BASE: &str = "https://api-testnet.predict.fun/v1";
#[derive(Debug, Clone)]
pub struct RawApiResponse {
pub status: StatusCode,
pub json: Option<Value>,
}
#[derive(Clone)]
pub struct PredictApiClient {
base: String,
api_key: Option<String>,
jwt: Option<String>,
http: reqwest::Client,
}
impl PredictApiClient {
pub fn new_mainnet(api_key: impl Into<String>) -> Result<Self> {
Self::new(PREDICT_MAINNET_BASE, Some(api_key.into()), None)
}
pub fn new_testnet() -> Result<Self> {
Self::new(PREDICT_TESTNET_BASE, None, None)
}
pub fn new(
base: impl Into<String>,
api_key: Option<String>,
jwt: Option<String>,
) -> Result<Self> {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.connect_timeout(std::time::Duration::from_secs(5))
.pool_max_idle_per_host(16)
.pool_idle_timeout(std::time::Duration::from_secs(90))
.tcp_keepalive(std::time::Duration::from_secs(30))
.build()
.context("failed to build predict api client")?;
Ok(Self {
base: base.into().trim_end_matches('/').to_string(),
api_key,
jwt,
http,
})
}
pub fn with_jwt(mut self, jwt: impl Into<String>) -> Self {
self.jwt = Some(jwt.into());
self
}
pub fn set_jwt(&mut self, jwt: impl Into<String>) {
self.jwt = Some(jwt.into());
}
pub fn clear_jwt(&mut self) {
self.jwt = None;
}
pub fn has_jwt(&self) -> bool {
self.jwt.is_some()
}
pub async fn auth_message(&self) -> Result<Value> {
self.get_ok("/auth/message", &[], false).await
}
pub async fn auth(&self, signer: &str, message: &str, signature: &str) -> Result<Value> {
let body = json!({
"signer": signer,
"message": message,
"signature": signature,
});
self.post_ok("/auth", &[], body, false).await
}
pub async fn create_order(&self, body: Value) -> Result<Value> {
self.post_ok("/orders", &[], body, true).await
}
pub async fn list_orders(&self, query: &[(&str, String)]) -> Result<Value> {
self.get_ok("/orders", query, true).await
}
pub async fn remove_orders(&self, body: Value) -> Result<Value> {
self.post_ok("/orders/remove", &[], body, true).await
}
pub async fn get_order(&self, hash: &str) -> Result<Value> {
self.get_ok(&format!("/orders/{}", hash), &[], true).await
}
pub async fn get_order_matches(&self, query: &[(&str, String)]) -> Result<Value> {
self.get_ok("/orders/matches", query, false).await
}
pub async fn list_markets(&self, query: &[(&str, String)]) -> Result<Value> {
self.get_ok("/markets", query, false).await
}
pub async fn get_market(&self, id: i64) -> Result<Value> {
self.get_ok(&format!("/markets/{}", id), &[], false).await
}
pub async fn get_market_stats(&self, id: i64) -> Result<Value> {
self.get_ok(&format!("/markets/{}/stats", id), &[], false)
.await
}
pub async fn get_market_last_sale(&self, id: i64) -> Result<Value> {
self.get_ok(&format!("/markets/{}/last-sale", id), &[], false)
.await
}
pub async fn get_market_orderbook(&self, id: i64) -> Result<Value> {
self.get_ok(&format!("/markets/{}/orderbook", id), &[], false)
.await
}
pub async fn get_market_timeseries(&self, id: i64, query: &[(&str, String)]) -> Result<Value> {
self.get_ok(&format!("/markets/{}/timeseries", id), query, false)
.await
}
pub async fn get_market_timeseries_latest(&self, id: i64) -> Result<Value> {
self.get_ok(&format!("/markets/{}/timeseries/latest", id), &[], false)
.await
}
pub async fn list_categories(&self, query: &[(&str, String)]) -> Result<Value> {
self.get_ok("/categories", query, false).await
}
pub async fn get_category(&self, slug: &str) -> Result<Value> {
self.get_ok(&format!("/categories/{}", slug), &[], false)
.await
}
pub async fn get_category_stats(&self, id: i64) -> Result<Value> {
self.get_ok(&format!("/categories/{}/stats", id), &[], false)
.await
}
pub async fn list_tags(&self) -> Result<Value> {
self.get_ok("/tags", &[], false).await
}
pub async fn list_positions(&self, query: &[(&str, String)]) -> Result<Value> {
self.get_ok("/positions", query, true).await
}
pub async fn list_positions_for_address(
&self,
address: &str,
query: &[(&str, String)],
) -> Result<Value> {
self.get_ok(&format!("/positions/{}", address), query, false)
.await
}
pub async fn account(&self) -> Result<Value> {
self.get_ok("/account", &[], true).await
}
pub async fn set_referral(&self, code: &str) -> Result<Value> {
let body = json!({ "code": code });
self.post_ok("/account/referral", &[], body, true).await
}
pub async fn account_activity(&self, query: &[(&str, String)]) -> Result<Value> {
self.get_ok("/account/activity", query, true).await
}
pub async fn oauth_finalize(&self, body: Value) -> Result<Value> {
self.post_ok("/oauth/finalize", &[], body, false).await
}
pub async fn oauth_orders(&self, body: Value) -> Result<Value> {
self.post_ok("/oauth/orders", &[], body, false).await
}
pub async fn oauth_create_order(&self, body: Value) -> Result<Value> {
self.post_ok("/oauth/orders/create", &[], body, false).await
}
pub async fn oauth_cancel_order(&self, body: Value) -> Result<Value> {
self.post_ok("/oauth/orders/cancel", &[], body, false).await
}
pub async fn oauth_positions(&self, body: Value) -> Result<Value> {
self.post_ok("/oauth/positions", &[], body, false).await
}
pub async fn search(&self, query: &[(&str, String)]) -> Result<Value> {
self.get_ok("/search", query, false).await
}
pub async fn yield_pending(&self, query: &[(&str, String)]) -> Result<Value> {
self.get_ok("/yield/pending", query, true).await
}
pub async fn raw_get(
&self,
path: &str,
query: &[(&str, String)],
require_jwt: bool,
) -> Result<RawApiResponse> {
self.raw_request(Method::GET, path, query, None, require_jwt)
.await
}
pub async fn raw_post(
&self,
path: &str,
query: &[(&str, String)],
body: Value,
require_jwt: bool,
) -> Result<RawApiResponse> {
self.raw_request(Method::POST, path, query, Some(body), require_jwt)
.await
}
async fn get_ok(
&self,
path: &str,
query: &[(&str, String)],
require_jwt: bool,
) -> Result<Value> {
let resp = self
.raw_request(Method::GET, path, query, None, require_jwt)
.await?;
self.expect_success(resp, "GET", path)
}
async fn post_ok(
&self,
path: &str,
query: &[(&str, String)],
body: Value,
require_jwt: bool,
) -> Result<Value> {
let resp = self
.raw_request(Method::POST, path, query, Some(body), require_jwt)
.await?;
self.expect_success(resp, "POST", path)
}
fn expect_success(&self, resp: RawApiResponse, method: &str, path: &str) -> Result<Value> {
if !resp.status.is_success() {
let body_str = resp
.json
.as_ref()
.map(|j| j.to_string())
.unwrap_or_default();
return Err(anyhow!(
"Predict API {} {} failed: status={} body={}",
method,
path,
resp.status,
&body_str[..body_str.len().min(500)]
));
}
resp.json
.ok_or_else(|| anyhow!("Predict API {} {} returned non-JSON body", method, path))
}
async fn raw_request(
&self,
method: Method,
path: &str,
query: &[(&str, String)],
body: Option<Value>,
require_jwt: bool,
) -> Result<RawApiResponse> {
if require_jwt && self.jwt.is_none() {
return Err(anyhow!(
"JWT required for {} {} but not set — call authenticate first",
method,
path
));
}
let url = format!("{}{}", self.base, path);
let mut req = self
.http
.request(method.clone(), &url)
.header("Accept", "application/json")
.header("Content-Type", "application/json");
if !query.is_empty() {
req = req.query(query);
}
if let Some(api_key) = &self.api_key {
req = req.header("x-api-key", api_key);
}
if let Some(jwt) = &self.jwt {
req = req.header("Authorization", format!("Bearer {}", jwt));
}
if let Some(v) = body {
req = req.json(&v);
}
let resp = req
.send()
.await
.with_context(|| format!("predict api {} {} failed", method, path))?;
let status = resp.status();
let json = resp.json::<Value>().await.ok();
Ok(RawApiResponse { status, json })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_construction() {
let client = PredictApiClient::new_mainnet("test-key").unwrap();
assert!(!client.has_jwt());
let client = client.with_jwt("token123");
assert!(client.has_jwt());
}
}