alpaca_http/
client.rs

1//! HTTP client for Alpaca API.
2//!
3//! This module provides the main HTTP client for interacting with the Alpaca REST API.
4
5use 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/// HTTP client for Alpaca API
15#[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    /// Create a new HTTP client
26    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    /// Create a new client from environment variables
43    pub fn from_env(environment: Environment) -> Result<Self> {
44        let credentials = Credentials::from_env()?;
45        Self::new(credentials, environment)
46    }
47
48    /// Make a GET request
49    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    /// Make a GET request with query parameters
57    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        // Serialize params to query string
63        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    /// Make a POST request
78    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    /// Make a PUT request
87    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    /// Make a PATCH request
96    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    /// Make a DELETE request
105    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    /// Make a generic request
113    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    /// Execute the request and handle the response
133    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    /// Handle the HTTP response with comprehensive error parsing.
146    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        // Extract request ID from headers for debugging
156        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        // Parse rate limit headers
163        let rate_limit_info = self.parse_rate_limit_headers(&headers);
164
165        // Check for rate limiting
166        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        // Get response text for error handling
183        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            // Try to parse structured error response
192            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            // Try to parse simple error response
209            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        // Parse successful response
234        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    /// Parse rate limit information from response headers.
243    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    /// Build the full URL for a request
274    fn build_url(&self, path: &str) -> Result<String> {
275        // Use data URL for market data endpoints
276        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    /// Build authentication headers
288    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    /// Get the current environment
313    pub fn environment(&self) -> &Environment {
314        &self.environment
315    }
316
317    /// Get the base URL
318    pub fn base_url(&self) -> &str {
319        &self.base_url
320    }
321
322    /// Get the data URL
323    pub fn data_url(&self) -> &str {
324        &self.data_url
325    }
326}
327
328/// Internal struct for parsing API error responses.
329#[derive(Debug, Deserialize)]
330struct ApiErrorResponseBody {
331    /// Alpaca-specific error code.
332    #[serde(default)]
333    code: u32,
334    /// Error message.
335    #[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}