Skip to main content

limitless/
client.rs

1use crate::prelude::*;
2use log::trace;
3
4use base64::Engine;
5use chrono::Utc;
6use std::time::Duration;
7
8/// The main HTTP/WebSocket client for the Limitless Exchange API.
9///
10/// Handles HMAC-SHA256 request signing using the scoped token format:
11///
12/// **Canonical message:** `{ISO-8601 timestamp}\n{HTTP METHOD}\n{request path + query}\n{body}`
13///
14/// **Headers:** `lmts-api-key`, `lmts-timestamp` (ISO-8601), `lmts-signature` (Base64).
15///
16/// Also supports legacy `X-API-Key` authentication for backward compatibility.
17#[derive(Clone)]
18pub struct Client {
19    /// Token ID (for scoped tokens) or legacy API key.
20    pub api_key: Option<String>,
21
22    /// Base64-encoded secret (for scoped HMAC tokens).
23    /// Set to `None` to fall back to legacy `X-API-Key` auth.
24    pub secret_key: Option<String>,
25
26    /// Base URL of the Limitless Exchange API.
27    pub host: String,
28
29    /// The inner `reqwest` HTTP client with connection pooling.
30    pub inner_client: ReqwestClient,
31}
32
33impl Client {
34    /// Create a new `Client`.
35    pub fn new(api_key: Option<String>, secret_key: Option<String>, host: String) -> Self {
36        let inner_client = ReqwestClient::builder()
37            .timeout(Duration::from_secs(30))
38            .build()
39            .expect("Failed to build reqwest client");
40
41        Client {
42            api_key,
43            secret_key,
44            host,
45            inner_client,
46        }
47    }
48
49    // ── Public (unsigned) GET ──────────────────────────────────────────
50
51    pub async fn get<T: DeserializeOwned + Send + 'static>(
52        &self,
53        url_path: &str,
54        request: Option<String>,
55    ) -> Result<T, LimitlessError> {
56        let mut url = format!("{}/{}", self.host, url_path);
57        if let Some(ref req) = request {
58            if !req.is_empty() {
59                url.push('?');
60                url.push_str(req);
61            }
62        }
63        trace!("GET {}", url);
64        let response = self.inner_client.get(&url).send().await?;
65        self.handler(response).await
66    }
67
68    // ── Authenticated (signed) GET ────────────────────────────────────
69
70    pub async fn get_signed<T: DeserializeOwned + Send + 'static>(
71        &self,
72        url_path: &str,
73        request: Option<String>,
74    ) -> Result<T, LimitlessError> {
75        let query_string = request.unwrap_or_default();
76        let full_path = if query_string.is_empty() {
77            url_path.to_string()
78        } else {
79            format!("{}?{}", url_path, query_string)
80        };
81
82        let headers = self.build_signed_headers("GET", &full_path, "")?;
83        let url = format!("{}/{}", self.host, full_path);
84
85        trace!("GET (signed) {}", url);
86        let response = self.inner_client.get(&url).headers(headers).send().await?;
87        self.handler(response).await
88    }
89
90    // ── Authenticated (signed) POST ───────────────────────────────────
91
92    pub async fn post_signed<T: DeserializeOwned + Send + 'static>(
93        &self,
94        url_path: &str,
95        raw_request_body: Option<String>,
96    ) -> Result<T, LimitlessError> {
97        let body = raw_request_body.unwrap_or_default();
98        let headers = self.build_signed_headers("POST", url_path, &body)?;
99        let url = format!("{}/{}", self.host, url_path);
100
101        trace!("POST (signed) {}", url);
102        let response = self
103            .inner_client
104            .post(&url)
105            .headers(headers)
106            .body(body)
107            .send()
108            .await?;
109        self.handler(response).await
110    }
111
112    // ── Authenticated (signed) DELETE ─────────────────────────────────
113
114    pub async fn delete_signed<T: DeserializeOwned + Send + 'static>(
115        &self,
116        url_path: &str,
117    ) -> Result<T, LimitlessError> {
118        let headers = self.build_signed_headers("DELETE", url_path, "")?;
119        let url = format!("{}/{}", self.host, url_path);
120
121        trace!("DELETE (signed) {}", url);
122        let response = self
123            .inner_client
124            .delete(&url)
125            .headers(headers)
126            .send()
127            .await?;
128        self.handler(response).await
129    }
130
131    // ── HMAC Signing (scoped token format) ────────────────────────────
132
133    /// Build signed headers per the Limitless scoped token spec.
134    ///
135    /// Canonical message: `{ISO-8601 timestamp}\n{HTTP METHOD}\n{path+query}\n{body}`
136    /// Secret is base64-decoded, signature is base64-encoded.
137    fn build_signed_headers(
138        &self,
139        method: &str,
140        path_and_query: &str,
141        body: &str,
142    ) -> Result<HeaderMap, LimitlessError> {
143        let mut headers = HeaderMap::new();
144        headers.insert(USER_AGENT, HeaderValue::from_static("rs_limitless"));
145
146        // If no secret is configured, fall back to legacy X-API-Key header
147        let secret = match &self.secret_key {
148            Some(s) if !s.is_empty() => s.clone(),
149            _ => {
150                // Legacy mode: use X-API-Key header only
151                if let Some(ref key) = self.api_key {
152                    headers.insert(
153                        HeaderName::from_static("x-api-key"),
154                        HeaderValue::from_str(key)?,
155                    );
156                }
157                headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
158                return Ok(headers);
159            }
160        };
161
162        // ── Scoped token mode ──
163        let timestamp = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
164        let message = format!("{}\n{}\n{}\n{}", timestamp, method, path_and_query, body);
165
166        // Decode the base64 secret
167        let secret_bytes = base64::engine::general_purpose::STANDARD
168            .decode(&secret)
169            .map_err(|e| LimitlessError::Base(format!("Invalid base64 secret: {}", e)))?;
170
171        // HMAC-SHA256
172        let mut mac = Hmac::<Sha256>::new_from_slice(&secret_bytes)
173            .map_err(|e| LimitlessError::Base(format!("HMAC init error: {}", e)))?;
174        mac.update(message.as_bytes());
175        let result = mac.finalize();
176        let code_bytes = result.into_bytes();
177
178        // Base64-encode the signature
179        let signature = base64::engine::general_purpose::STANDARD.encode(&code_bytes);
180
181        if let Some(ref key) = self.api_key {
182            headers.insert(
183                HeaderName::from_static("lmts-api-key"),
184                HeaderValue::from_str(key)?,
185            );
186        }
187
188        headers.insert(
189            HeaderName::from_static("lmts-timestamp"),
190            HeaderValue::from_str(&timestamp)?,
191        );
192        headers.insert(
193            HeaderName::from_static("lmts-signature"),
194            HeaderValue::from_str(&signature)?,
195        );
196        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
197
198        Ok(headers)
199    }
200
201    // ── Generic response handler ──────────────────────────────────────
202
203    async fn handler<T: DeserializeOwned>(
204        &self,
205        response: ReqwestResponse,
206    ) -> Result<T, LimitlessError> {
207        let status = response.status();
208
209        if status.is_success() {
210            let body = response.text().await?;
211            if body.trim().is_empty() {
212                return Err(LimitlessError::Base("Empty response body".into()));
213            }
214            serde_json::from_str(&body).map_err(LimitlessError::from)
215        } else {
216            let status_code = status.as_u16();
217            match status_code {
218                401 => {
219                    let body = response.text().await.unwrap_or_default();
220                    if let Ok(content_error) = serde_json::from_str::<LimitlessContentError>(&body)
221                    {
222                        Err(LimitlessError::ApiError(content_error))
223                    } else {
224                        Err(LimitlessError::Unauthorized)
225                    }
226                }
227                429 => Err(LimitlessError::RateLimited),
228                500 => Err(LimitlessError::InternalServerError),
229                503 => Err(LimitlessError::ServiceUnavailable),
230                _ => {
231                    let body = response.text().await.unwrap_or_default();
232                    if let Ok(content_error) = serde_json::from_str::<LimitlessContentError>(&body)
233                    {
234                        Err(LimitlessError::ApiError(content_error))
235                    } else {
236                        Err(LimitlessError::StatusCode(status_code))
237                    }
238                }
239            }
240        }
241    }
242
243    // ── WebSocket connection ──────────────────────────────────────────
244
245    /// Establish a raw WebSocket connection to the Limitless Socket.IO endpoint.
246    ///
247    /// Connects to `wss://ws.limitless.exchange/socket.io/?EIO=4&transport=websocket`
248    /// and returns a `WebSocketStream` ready for reading/writing. The caller is
249    /// responsible for the Socket.IO protocol framing (Engine.IO open, namespace
250    /// connect, event emit/receive) on top of this stream.
251    ///
252    /// # Arguments
253    ///
254    /// * `_request` — Optional initial subscription payload (sent as Socket.IO frame).
255    /// * `_authenticated` — If `true`, the `X-API-Key` / `lmts-api-key` header is sent.
256    /// * `_timeout_secs` — Optional connection timeout.
257    pub async fn wss_connect(
258        &self,
259        _request: Option<String>,
260        _authenticated: bool,
261        _timeout_secs: Option<u64>,
262    ) -> Result<WebSocketStream<MaybeTlsStream<TcpStream>>, LimitlessError> {
263        // The Limitless Exchange uses Socket.IO (Engine.IO v4) over WebSocket.
264        // The `/markets` path is the Socket.IO namespace, NOT the WebSocket
265        // endpoint. The actual WebSocket endpoint is at:
266        //   /socket.io/?EIO=4&transport=websocket
267        let ws_url =
268            WsUrl::parse("wss://ws.limitless.exchange/socket.io/?EIO=4&transport=websocket")
269                .map_err(|e| LimitlessError::Base(format!("Invalid WS URL: {}", e)))?;
270
271        let (stream, _response) = connect_async(ws_url.to_string()).await?;
272        Ok(stream)
273    }
274}