acta_maker_sdk/http/
mod.rs1mod 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 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 delegated_vaults(&self, delegate: &str) -> Result<Vec<Vault>, HttpError> {
120 let _ = decode_base58_32(delegate)?;
121 let response: VaultsResponse = self
122 .get(&format!("/api/v1/vaults?delegate={delegate}"))
123 .await?;
124 Ok(response.vaults)
125 }
126
127 pub async fn vault(&self, pda: &str) -> Result<Vault, HttpError> {
128 let _ = decode_base58_32(pda)?;
129 self.get(&format!("/api/v1/vaults/{pda}")).await
130 }
131
132 pub async fn vault_positions(&self, pda: &str) -> Result<Vec<VaultPosition>, HttpError> {
134 let _ = decode_base58_32(pda)?;
135 let base = format!("/api/v1/vaults/{pda}/positions?settled=false&limit={POSITION_PAGE}");
136 let mut positions = Vec::new();
137 let mut path = base.clone();
138 loop {
139 let page: VaultPositionsResponse = self.get(&path).await?;
140 positions.extend(page.positions);
141 match page.next_cursor {
142 Some(cursor) => path = format!("{base}&cursor={cursor}"),
143 None => return Ok(positions),
144 }
145 }
146 }
147
148 pub async fn markets(&self, filter: &MarketFilter) -> Result<Vec<MarketInfo>, HttpError> {
149 let response: MarketsResponse = self
150 .get(&format!("/api/v1/markets{}", filter.query()))
151 .await?;
152 Ok(response.markets)
153 }
154
155 pub async fn tokens(&self) -> Result<BTreeMap<String, TokenInfo>, HttpError> {
156 let response: TokensResponse = self.get("/api/v1/tokens").await?;
157 Ok(response
158 .tokens
159 .into_iter()
160 .map(|token| (token.mint.clone(), token))
161 .collect())
162 }
163
164 pub async fn venue(&self) -> Result<Venue, HttpError> {
165 self.get("/api/v1/venue").await
166 }
167
168 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, HttpError> {
169 let mut request = self.client.get(format!("{}{path}", self.base_url));
170 if let Some(etag) = self.cached(path, |cached| cached.etag.clone()) {
171 request = request.header(IF_NONE_MATCH, etag);
172 }
173 let response = request.send().await?;
174 let status = response.status();
175 if status == StatusCode::NOT_MODIFIED
176 && let Some(body) = self.cached(path, |cached| Arc::clone(&cached.body))
177 {
178 return Ok(serde_json::from_slice(&body)?);
179 }
180 if !status.is_success() {
181 let body = response.text().await?;
182 return Err(HttpError::Status {
183 status: status.as_u16(),
184 body,
185 });
186 }
187 let etag = response
188 .headers()
189 .get(ETAG)
190 .and_then(|value| value.to_str().ok())
191 .map(str::to_owned);
192 let body = response.bytes().await?;
193 let value = serde_json::from_slice(&body)?;
194 if let Some(etag) = etag {
195 let _ = self.cache().insert(
196 path.to_owned(),
197 Cached {
198 etag,
199 body: Arc::from(body.as_ref()),
200 },
201 );
202 }
203 Ok(value)
204 }
205
206 fn cached<R>(&self, path: &str, read: impl FnOnce(&Cached) -> R) -> Option<R> {
207 self.cache().get(path).map(read)
208 }
209
210 fn cache(&self) -> MutexGuard<'_, HashMap<String, Cached>> {
211 match self.cache.lock() {
212 Ok(cache) => cache,
213 Err(poisoned) => poisoned.into_inner(),
214 }
215 }
216}
217
218#[cfg(test)]
219mod tests;