1use alpaca_base::{
6 AlpacaError, ApiErrorCode, RateLimitInfo, Result, auth::Credentials, types::Environment,
7 utils::UrlBuilder,
8};
9use reqwest::{Client, Method, RequestBuilder, Response};
10use serde::{Deserialize, Serialize, de::DeserializeOwned};
11use std::time::Duration;
12use tracing::{debug, error, warn};
13
14#[derive(Debug, Clone)]
16pub struct AlpacaHttpClient {
17 client: Client,
18 credentials: Credentials,
19 environment: Environment,
20 base_url: String,
21 data_url: String,
22}
23
24impl AlpacaHttpClient {
25 pub fn new(credentials: Credentials, environment: Environment) -> Result<Self> {
27 let client = Client::builder()
28 .timeout(Duration::from_secs(30))
29 .user_agent("alpaca-rs/0.1.0")
30 .build()
31 .map_err(|e| AlpacaError::Http(e.to_string()))?;
32
33 Ok(Self {
34 client,
35 credentials,
36 base_url: environment.base_url().to_string(),
37 data_url: environment.data_url().to_string(),
38 environment,
39 })
40 }
41
42 pub fn from_env(environment: Environment) -> Result<Self> {
44 let credentials = Credentials::from_env()?;
45 Self::new(credentials, environment)
46 }
47
48 pub async fn get<T>(&self, path: &str) -> Result<T>
50 where
51 T: DeserializeOwned,
52 {
53 self.request::<T, ()>(Method::GET, path, None).await
54 }
55
56 pub async fn get_with_params<T, P>(&self, path: &str, params: &P) -> Result<T>
58 where
59 T: DeserializeOwned,
60 P: Serialize,
61 {
62 let query_string = serde_urlencoded::to_string(params)
64 .map_err(|e| AlpacaError::Json(format!("Failed to serialize query params: {}", e)))?;
65
66 let url = if query_string.is_empty() {
67 self.build_url(path)?
68 } else {
69 format!("{}?{}", self.build_url(path)?, query_string)
70 };
71
72 let request = self.client.get(&url).headers(self.build_headers()?);
73
74 self.execute_request(request).await
75 }
76
77 pub async fn post<T, B>(&self, path: &str, body: &B) -> Result<T>
79 where
80 T: DeserializeOwned,
81 B: Serialize,
82 {
83 self.request(Method::POST, path, Some(body)).await
84 }
85
86 pub async fn put<T, B>(&self, path: &str, body: &B) -> Result<T>
88 where
89 T: DeserializeOwned,
90 B: Serialize,
91 {
92 self.request(Method::PUT, path, Some(body)).await
93 }
94
95 pub async fn patch<T, B>(&self, path: &str, body: &B) -> Result<T>
97 where
98 T: DeserializeOwned,
99 B: Serialize,
100 {
101 self.request(Method::PATCH, path, Some(body)).await
102 }
103
104 pub async fn delete<T>(&self, path: &str) -> Result<T>
106 where
107 T: DeserializeOwned,
108 {
109 self.request::<T, ()>(Method::DELETE, path, None).await
110 }
111
112 async fn request<T, B>(&self, method: Method, path: &str, body: Option<&B>) -> Result<T>
114 where
115 T: DeserializeOwned,
116 B: Serialize,
117 {
118 let url = self.build_url(path)?;
119 let mut request = self
120 .client
121 .request(method.clone(), &url)
122 .headers(self.build_headers()?);
123
124 if let Some(body) = body {
125 request = request.json(body);
126 }
127
128 debug!("Making {} request to {}", method, url);
129 self.execute_request(request).await
130 }
131
132 async fn execute_request<T>(&self, request: RequestBuilder) -> Result<T>
134 where
135 T: DeserializeOwned,
136 {
137 let response = request
138 .send()
139 .await
140 .map_err(|e| AlpacaError::Network(e.to_string()))?;
141
142 self.handle_response(response).await
143 }
144
145 async fn handle_response<T>(&self, response: Response) -> Result<T>
147 where
148 T: DeserializeOwned,
149 {
150 let status = response.status();
151 let headers = response.headers().clone();
152
153 debug!("Response status: {}", status);
154
155 let request_id = headers
157 .get("x-request-id")
158 .or_else(|| headers.get("apca-request-id"))
159 .and_then(|h| h.to_str().ok())
160 .map(String::from);
161
162 let rate_limit_info = self.parse_rate_limit_headers(&headers);
164
165 if status == 429 {
167 let retry_after = headers
168 .get("retry-after")
169 .and_then(|h| h.to_str().ok())
170 .and_then(|s| s.parse().ok())
171 .unwrap_or(60u64);
172
173 warn!("Rate limited, retry after {} seconds", retry_after);
174
175 let info = rate_limit_info
176 .unwrap_or_default()
177 .with_retry_after(retry_after);
178
179 return Err(AlpacaError::rate_limit_with_info(info));
180 }
181
182 let response_text = response
184 .text()
185 .await
186 .map_err(|e| AlpacaError::Network(e.to_string()))?;
187
188 if !status.is_success() {
189 error!("API error response: {}", response_text);
190
191 if let Ok(error_response) = serde_json::from_str::<ApiErrorResponseBody>(&response_text)
193 {
194 let error_code = if error_response.code > 0 {
195 Some(ApiErrorCode::from_code(error_response.code))
196 } else {
197 None
198 };
199
200 return Err(AlpacaError::Api {
201 status: status.as_u16(),
202 message: error_response.message,
203 error_code,
204 request_id,
205 });
206 }
207
208 if let Ok(error_value) = serde_json::from_str::<serde_json::Value>(&response_text) {
210 let message = error_value
211 .get("message")
212 .or_else(|| error_value.get("error"))
213 .and_then(|v| v.as_str())
214 .unwrap_or(&response_text)
215 .to_string();
216
217 return Err(AlpacaError::Api {
218 status: status.as_u16(),
219 message,
220 error_code: None,
221 request_id,
222 });
223 }
224
225 return Err(AlpacaError::Api {
226 status: status.as_u16(),
227 message: response_text,
228 error_code: None,
229 request_id,
230 });
231 }
232
233 serde_json::from_str(&response_text).map_err(|e| {
235 AlpacaError::Json(format!(
236 "Failed to parse response: {} - Response: {}",
237 e, response_text
238 ))
239 })
240 }
241
242 fn parse_rate_limit_headers(
244 &self,
245 headers: &reqwest::header::HeaderMap,
246 ) -> Option<RateLimitInfo> {
247 let remaining = headers
248 .get("x-ratelimit-remaining")
249 .and_then(|h| h.to_str().ok())
250 .and_then(|s| s.parse().ok());
251
252 let limit = headers
253 .get("x-ratelimit-limit")
254 .and_then(|h| h.to_str().ok())
255 .and_then(|s| s.parse().ok());
256
257 let reset = headers
258 .get("x-ratelimit-reset")
259 .and_then(|h| h.to_str().ok())
260 .and_then(|s| s.parse().ok());
261
262 if remaining.is_some() || limit.is_some() || reset.is_some() {
263 Some(RateLimitInfo {
264 remaining,
265 limit,
266 retry_after: reset,
267 })
268 } else {
269 None
270 }
271 }
272
273 fn build_url(&self, path: &str) -> Result<String> {
275 let base_url = if path.starts_with("/v2/stocks") || path.starts_with("/v1beta1/crypto") {
277 &self.data_url
278 } else {
279 &self.base_url
280 };
281
282 UrlBuilder::new(base_url)
283 .path(path.trim_start_matches('/'))
284 .build()
285 }
286
287 fn build_headers(&self) -> Result<reqwest::header::HeaderMap> {
289 let mut headers = reqwest::header::HeaderMap::new();
290
291 headers.insert(
292 "APCA-API-KEY-ID",
293 self.credentials
294 .api_key
295 .parse()
296 .map_err(|_| AlpacaError::Auth("Invalid API key format".to_string()))?,
297 );
298
299 headers.insert(
300 "APCA-API-SECRET-KEY",
301 self.credentials
302 .secret_key
303 .parse()
304 .map_err(|_| AlpacaError::Auth("Invalid secret key format".to_string()))?,
305 );
306
307 headers.insert("Content-Type", "application/json".parse().unwrap());
308
309 Ok(headers)
310 }
311
312 pub fn environment(&self) -> &Environment {
314 &self.environment
315 }
316
317 pub fn base_url(&self) -> &str {
319 &self.base_url
320 }
321
322 pub fn data_url(&self) -> &str {
324 &self.data_url
325 }
326}
327
328#[derive(Debug, Deserialize)]
330struct ApiErrorResponseBody {
331 #[serde(default)]
333 code: u32,
334 #[serde(default)]
336 message: String,
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use alpaca_base::types::Environment;
343
344 #[test]
345 fn test_build_url() {
346 let credentials = Credentials::new("test_key".to_string(), "test_secret".to_string());
347 let client = AlpacaHttpClient::new(credentials, Environment::Paper).unwrap();
348
349 let url = client.build_url("/v2/account").unwrap();
350 assert_eq!(url, "https://paper-api.alpaca.markets/v2/account");
351
352 let data_url = client.build_url("/v2/stocks/AAPL/bars").unwrap();
353 assert_eq!(data_url, "https://data.alpaca.markets/v2/stocks/AAPL/bars");
354 }
355
356 #[test]
357 fn test_environment_urls() {
358 assert_eq!(
359 Environment::Paper.base_url(),
360 "https://paper-api.alpaca.markets"
361 );
362 assert_eq!(Environment::Live.base_url(), "https://api.alpaca.markets");
363 assert_eq!(Environment::Paper.data_url(), "https://data.alpaca.markets");
364 }
365}