1mod 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
13pub use types::{
14 CountLimit, PremiumFlow, ProjectionCursor, TokenProgramKind, Vault, VaultCapacity,
15 VaultExposure, VaultExposurePosition, VaultExposureTotal, VaultFees, VaultLeg, VaultMetadata,
16 VaultPerformance, VaultPolicy, VaultPosition, VaultPositionProduct, VaultPositionRole,
17 VaultPositionStatus, VaultPositionType, VaultPremium, VaultReturns, VaultStaging, Venue,
18 WindowReturn,
19};
20use types::{MarketsResponse, TokensResponse, VaultPositionsResponse, VaultsResponse};
21
22use crate::ws::types::{MarketInfo, TokenInfo};
23
24const TIMEOUT: Duration = Duration::from_secs(10);
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 response body: {0}")]
34 Decode(#[from] serde_json::Error),
35 #[error("page cursor did not advance")]
36 InvalidCursor,
37 #[error(transparent)]
38 InvalidPage(#[from] crate::paging::InvalidPage),
39}
40
41#[derive(Debug, Clone, Default, PartialEq, Eq)]
42pub struct MarketFilter {
43 pub underlying: Option<crate::Address32>,
44 pub quote: Option<crate::Address32>,
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 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
116 .collect(
117 "/api/v1/vaults",
118 |all: &VaultsResponse| all.next_cursor.as_deref(),
119 |all, page| {
120 all.vaults.extend(page.vaults);
121 all.next_cursor = page.next_cursor;
122 },
123 )
124 .await?;
125 Ok(response.vaults)
126 }
127
128 pub async fn delegated_vaults(
131 &self,
132 delegate: crate::Address32,
133 ) -> Result<Vec<Vault>, HttpError> {
134 let response: VaultsResponse = self
135 .collect(
136 &format!("/api/v1/vaults?delegate={delegate}"),
137 |all: &VaultsResponse| all.next_cursor.as_deref(),
138 |all, page| {
139 all.vaults.extend(page.vaults);
140 all.next_cursor = page.next_cursor;
141 },
142 )
143 .await?;
144 Ok(response.vaults)
145 }
146
147 pub async fn vault(&self, pda: crate::Address32) -> Result<Vault, HttpError> {
148 self.get(&format!("/api/v1/vaults/{pda}")).await
149 }
150
151 pub async fn vault_positions(
153 &self,
154 pda: crate::Address32,
155 ) -> Result<Vec<VaultPosition>, HttpError> {
156 let response: VaultPositionsResponse = self
157 .collect(
158 &format!("/api/v1/vaults/{pda}/positions?settled=false"),
159 |all: &VaultPositionsResponse| all.next_cursor.as_deref(),
160 |all, page| {
161 all.positions.extend(page.positions);
162 all.next_cursor = page.next_cursor;
163 },
164 )
165 .await?;
166 Ok(response.positions)
167 }
168
169 pub async fn vault_exposure(&self, pda: crate::Address32) -> Result<VaultExposure, HttpError> {
171 self.collect(
172 &format!("/api/v1/vaults/{pda}/exposure"),
173 |all: &VaultExposure| all.next_cursor.as_deref(),
174 |all, page| {
175 all.positions.extend(page.positions);
176 all.next_cursor = page.next_cursor;
177 },
178 )
179 .await
180 }
181
182 pub async fn vault_exposure_page(
184 &self,
185 pda: crate::Address32,
186 limit: u16,
187 cursor: Option<&str>,
188 ) -> Result<VaultExposure, HttpError> {
189 let cursor = cursor.map_or(crate::ws::types::Cursor::Start {}, |id| {
190 crate::ws::types::Cursor::After { id: id.to_owned() }
191 });
192 let path = Self::page_path(&format!("/api/v1/vaults/{pda}/exposure"), limit, &cursor)?;
193 self.get(&path).await
194 }
195
196 pub async fn markets(&self, filter: &MarketFilter) -> Result<Vec<MarketInfo>, HttpError> {
197 let response: MarketsResponse = self
198 .collect(
199 &format!("/api/v1/markets{}", filter.query()),
200 |all: &MarketsResponse| all.next_cursor.as_deref(),
201 |all, page| {
202 all.markets.extend(page.markets);
203 all.next_cursor = page.next_cursor;
204 },
205 )
206 .await?;
207 Ok(response.markets)
208 }
209
210 pub async fn tokens(&self) -> Result<BTreeMap<crate::Address32, TokenInfo>, HttpError> {
211 let response: TokensResponse = self
212 .collect(
213 "/api/v1/tokens",
214 |all: &TokensResponse| all.next_cursor.as_deref(),
215 |all, page| {
216 all.tokens.extend(page.tokens);
217 all.next_cursor = page.next_cursor;
218 },
219 )
220 .await?;
221 Ok(response
222 .tokens
223 .into_iter()
224 .map(|token| (token.mint, token))
225 .collect())
226 }
227
228 fn page_path(
229 path: &str,
230 limit: u16,
231 cursor: &crate::ws::types::Cursor,
232 ) -> Result<String, HttpError> {
233 let mut url = reqwest::Url::parse(&format!("http://localhost{path}"))
234 .map_err(|_| HttpError::InvalidCursor)?;
235 url.query_pairs_mut()
236 .append_pair("limit", &limit.to_string());
237 if let crate::ws::types::Cursor::After { id } = cursor {
238 url.query_pairs_mut().append_pair("cursor", id);
239 }
240 Ok(format!(
241 "{}?{}",
242 url.path(),
243 url.query().unwrap_or_default()
244 ))
245 }
246
247 async fn collect<T: DeserializeOwned>(
248 &self,
249 path: &str,
250 next: impl Fn(&T) -> Option<&str>,
251 append: impl Fn(&mut T, T),
252 ) -> Result<T, HttpError> {
253 crate::paging::fetch_all(
254 |cursor| async move { self.get(&Self::page_path(path, 200, &cursor)?).await },
255 next,
256 append,
257 )
258 .await
259 }
260
261 pub async fn venue(&self) -> Result<Venue, HttpError> {
262 self.get("/api/v1/venue").await
263 }
264
265 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, HttpError> {
266 let mut request = self.client.get(format!("{}{path}", self.base_url));
267 if let Some(etag) = self.cached(path, |cached| cached.etag.clone()) {
268 request = request.header(IF_NONE_MATCH, etag);
269 }
270 let response = request.send().await?;
271 let status = response.status();
272 if status == StatusCode::NOT_MODIFIED
273 && let Some(body) = self.cached(path, |cached| Arc::clone(&cached.body))
274 {
275 return Ok(serde_json::from_slice(&body)?);
276 }
277 if !status.is_success() {
278 let body = response.text().await?;
279 return Err(HttpError::Status {
280 status: status.as_u16(),
281 body,
282 });
283 }
284 let etag = response
285 .headers()
286 .get(ETAG)
287 .and_then(|value| value.to_str().ok())
288 .map(str::to_owned);
289 let body = response.bytes().await?;
290 let value = serde_json::from_slice(&body)?;
291 if let Some(etag) = etag {
292 let _ = self.cache().insert(
293 path.to_owned(),
294 Cached {
295 etag,
296 body: Arc::from(body.as_ref()),
297 },
298 );
299 }
300 Ok(value)
301 }
302
303 fn cached<R>(&self, path: &str, read: impl FnOnce(&Cached) -> R) -> Option<R> {
304 self.cache().get(path).map(read)
305 }
306
307 fn cache(&self) -> MutexGuard<'_, HashMap<String, Cached>> {
308 match self.cache.lock() {
309 Ok(cache) => cache,
310 Err(poisoned) => poisoned.into_inner(),
311 }
312 }
313}
314
315#[cfg(test)]
316mod tests;