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 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 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;