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