Skip to main content

acta_maker_sdk/http/
mod.rs

1//! Read-only client for the public Acta HTTP API.
2
3mod types;
4
5use std::collections::{BTreeMap, HashMap};
6use std::sync::{Arc, Mutex, MutexGuard};
7use std::time::Duration;
8
9use reqwest::StatusCode;
10use reqwest::header::{ETAG, IF_NONE_MATCH};
11use serde::de::DeserializeOwned;
12
13use types::{MarketsResponse, TokensResponse, VaultPositionsResponse, VaultsResponse};
14pub use types::{
15    ProjectionCursor, TokenProgramKind, TrackRecord, Vault, VaultPerformance, VaultPolicy,
16    VaultPosition, VaultPositionRole, VaultPositionStatus, VaultPositionType, Venue,
17};
18
19use crate::ws::types::{MarketInfo, TokenInfo};
20
21use crate::wire::{WireError, decode_base58_32};
22
23const TIMEOUT: Duration = Duration::from_secs(10);
24const POSITION_PAGE: u16 = 200;
25
26#[derive(Debug, thiserror::Error)]
27#[non_exhaustive]
28pub enum HttpError {
29    #[error("request failed: {0}")]
30    Request(#[from] reqwest::Error),
31    #[error("HTTP {status}: {body}")]
32    Status { status: u16, body: String },
33    #[error("invalid address: {0}")]
34    Address(#[from] WireError),
35    #[error("invalid response body: {0}")]
36    Decode(#[from] serde_json::Error),
37}
38
39#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct MarketFilter {
41    pub underlying: Option<String>,
42    pub quote: Option<String>,
43    pub expiry: Option<u64>,
44    pub is_put: Option<bool>,
45    pub active: Option<bool>,
46}
47
48impl MarketFilter {
49    fn query(&self) -> String {
50        let mut params = Vec::new();
51        if let Some(underlying) = &self.underlying {
52            params.push(format!("underlying={underlying}"));
53        }
54        if let Some(quote) = &self.quote {
55            params.push(format!("quote={quote}"));
56        }
57        if let Some(expiry) = self.expiry {
58            params.push(format!("expiry={expiry}"));
59        }
60        if let Some(is_put) = self.is_put {
61            params.push(format!("is_put={is_put}"));
62        }
63        if let Some(active) = self.active {
64            params.push(format!("active={active}"));
65        }
66        if params.is_empty() {
67            String::new()
68        } else {
69            format!("?{}", params.join("&"))
70        }
71    }
72}
73
74#[derive(Debug, Clone)]
75pub struct HttpClient {
76    client: reqwest::Client,
77    base_url: String,
78    cache: Arc<Mutex<HashMap<String, Cached>>>,
79}
80
81#[derive(Debug)]
82struct Cached {
83    etag: String,
84    body: Arc<[u8]>,
85}
86
87impl HttpClient {
88    /// `base_url` is the API origin, e.g. `https://beta-api.acta.markets`.
89    pub fn new(base_url: impl Into<String>) -> Result<Self, HttpError> {
90        let client = reqwest::Client::builder().timeout(TIMEOUT).build()?;
91        Ok(Self::with_client(client, base_url))
92    }
93
94    #[must_use]
95    pub fn with_client(client: reqwest::Client, base_url: impl Into<String>) -> Self {
96        let mut base_url = base_url.into();
97        while base_url.ends_with('/') {
98            let _ = base_url.pop();
99        }
100        Self {
101            client,
102            base_url,
103            cache: Arc::default(),
104        }
105    }
106
107    #[must_use]
108    pub fn base_url(&self) -> &str {
109        &self.base_url
110    }
111
112    pub async fn vaults(&self) -> Result<Vec<Vault>, HttpError> {
113        let response: VaultsResponse = self.get("/api/v1/vaults").await?;
114        Ok(response.vaults)
115    }
116
117    pub async fn vault(&self, pda: &str) -> Result<Vault, HttpError> {
118        let _ = decode_base58_32(pda)?;
119        self.get(&format!("/api/v1/vaults/{pda}")).await
120    }
121
122    /// Every unsettled position of the vault, following the server cursor.
123    pub async fn vault_positions(&self, pda: &str) -> Result<Vec<VaultPosition>, HttpError> {
124        let _ = decode_base58_32(pda)?;
125        let base = format!("/api/v1/vaults/{pda}/positions?settled=false&limit={POSITION_PAGE}");
126        let mut positions = Vec::new();
127        let mut path = base.clone();
128        loop {
129            let page: VaultPositionsResponse = self.get(&path).await?;
130            positions.extend(page.positions);
131            match page.next_cursor {
132                Some(cursor) => path = format!("{base}&cursor={cursor}"),
133                None => return Ok(positions),
134            }
135        }
136    }
137
138    pub async fn markets(&self, filter: &MarketFilter) -> Result<Vec<MarketInfo>, HttpError> {
139        let response: MarketsResponse = self
140            .get(&format!("/api/v1/markets{}", filter.query()))
141            .await?;
142        Ok(response.markets)
143    }
144
145    pub async fn tokens(&self) -> Result<BTreeMap<String, TokenInfo>, HttpError> {
146        let response: TokensResponse = self.get("/api/v1/tokens").await?;
147        Ok(response
148            .tokens
149            .into_iter()
150            .map(|token| (token.mint.clone(), token))
151            .collect())
152    }
153
154    pub async fn venue(&self) -> Result<Venue, HttpError> {
155        self.get("/api/v1/venue").await
156    }
157
158    async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, HttpError> {
159        let mut request = self.client.get(format!("{}{path}", self.base_url));
160        if let Some(etag) = self.cached(path, |cached| cached.etag.clone()) {
161            request = request.header(IF_NONE_MATCH, etag);
162        }
163        let response = request.send().await?;
164        let status = response.status();
165        if status == StatusCode::NOT_MODIFIED
166            && let Some(body) = self.cached(path, |cached| Arc::clone(&cached.body))
167        {
168            return Ok(serde_json::from_slice(&body)?);
169        }
170        if !status.is_success() {
171            let body = response.text().await?;
172            return Err(HttpError::Status {
173                status: status.as_u16(),
174                body,
175            });
176        }
177        let etag = response
178            .headers()
179            .get(ETAG)
180            .and_then(|value| value.to_str().ok())
181            .map(str::to_owned);
182        let body = response.bytes().await?;
183        let value = serde_json::from_slice(&body)?;
184        if let Some(etag) = etag {
185            let _ = self.cache().insert(
186                path.to_owned(),
187                Cached {
188                    etag,
189                    body: Arc::from(body.as_ref()),
190                },
191            );
192        }
193        Ok(value)
194    }
195
196    fn cached<R>(&self, path: &str, read: impl FnOnce(&Cached) -> R) -> Option<R> {
197        self.cache().get(path).map(read)
198    }
199
200    fn cache(&self) -> MutexGuard<'_, HashMap<String, Cached>> {
201        match self.cache.lock() {
202            Ok(cache) => cache,
203            Err(poisoned) => poisoned.into_inner(),
204        }
205    }
206}
207
208#[cfg(test)]
209mod tests;