ig_client/transport/
http_client.rs

1use async_trait::async_trait;
2use reqwest::{Client, Method, RequestBuilder, Response, StatusCode};
3use serde::{Serialize, de::DeserializeOwned};
4use std::sync::Arc;
5use tracing::{debug, error, info};
6
7use crate::{config::Config, error::AppError, session::interface::IgSession};
8
9/// Interface for the IG HTTP client
10#[async_trait]
11pub trait IgHttpClient: Send + Sync {
12    /// Makes an HTTP request to the IG API
13    async fn request<T, R>(
14        &self,
15        method: Method,
16        path: &str,
17        session: &IgSession,
18        body: Option<&T>,
19        version: &str,
20    ) -> Result<R, AppError>
21    where
22        for<'de> R: DeserializeOwned + 'static,
23        T: Serialize + Send + Sync + 'static;
24
25    /// Makes an unauthenticated HTTP request (for login)
26    async fn request_no_auth<T, R>(
27        &self,
28        method: Method,
29        path: &str,
30        body: Option<&T>,
31        version: &str,
32    ) -> Result<R, AppError>
33    where
34        for<'de> R: DeserializeOwned + 'static,
35        T: Serialize + Send + Sync + 'static;
36}
37
38/// Implementation of the HTTP client for IG
39pub struct IgHttpClientImpl {
40    config: Arc<Config>,
41    client: Client,
42}
43
44impl IgHttpClientImpl {
45    /// Creates a new instance of the HTTP client
46    pub fn new(config: Arc<Config>) -> Self {
47        let client = Client::builder()
48            .user_agent("ig-client/0.1.0")
49            .timeout(std::time::Duration::from_secs(config.rest_api.timeout))
50            .build()
51            .expect("Failed to create HTTP client");
52
53        Self { config, client }
54    }
55
56    /// Builds the complete URL for a request
57    fn build_url(&self, path: &str) -> String {
58        format!(
59            "{}/{}",
60            self.config.rest_api.base_url.trim_end_matches('/'),
61            path.trim_start_matches('/')
62        )
63    }
64
65    /// Adds common headers to all requests
66    fn add_common_headers(&self, builder: RequestBuilder, version: &str) -> RequestBuilder {
67        builder
68            .header("X-IG-API-KEY", &self.config.credentials.api_key)
69            .header("Content-Type", "application/json; charset=UTF-8")
70            .header("Accept", "application/json; charset=UTF-8")
71            .header("Version", version)
72    }
73
74    /// Adds authentication headers to a request
75    fn add_auth_headers(&self, builder: RequestBuilder, session: &IgSession) -> RequestBuilder {
76        builder
77            .header("CST", &session.cst)
78            .header("X-SECURITY-TOKEN", &session.token)
79    }
80
81    /// Processes the HTTP response
82    async fn process_response<R>(&self, response: Response) -> Result<R, AppError>
83    where
84        R: DeserializeOwned,
85    {
86        let status = response.status();
87        let url = response.url().to_string();
88
89        match status {
90            StatusCode::OK | StatusCode::CREATED | StatusCode::ACCEPTED => {
91                // Clone the response to get the raw body for debugging
92                let response_bytes = response.bytes().await?;
93                let response_text = String::from_utf8_lossy(&response_bytes);
94                debug!("Raw response from {}: {}", url, response_text);
95
96                // Try to deserialize the response
97                match serde_json::from_slice::<R>(&response_bytes) {
98                    Ok(json) => {
99                        debug!("Request to {} successfully deserialized", url);
100                        Ok(json)
101                    }
102                    Err(e) => {
103                        error!("Failed to deserialize response from {}: {}", url, e);
104                        error!("Response body: {}", response_text);
105                        Err(AppError::Deserialization(format!(
106                            "Failed to deserialize response: {}",
107                            e
108                        )))
109                    }
110                }
111            }
112            StatusCode::UNAUTHORIZED => {
113                error!("Unauthorized request to {}", url);
114                Err(AppError::Unauthorized)
115            }
116            StatusCode::NOT_FOUND => {
117                error!("Resource not found at {}", url);
118                Err(AppError::NotFound)
119            }
120            StatusCode::TOO_MANY_REQUESTS => {
121                error!("Rate limit exceeded for {}", url);
122                Err(AppError::RateLimitExceeded)
123            }
124            _ => {
125                let error_text = response
126                    .text()
127                    .await
128                    .unwrap_or_else(|_| "Unknown error".to_string());
129                error!(
130                    "Request to {} failed with status {}: {}",
131                    url, status, error_text
132                );
133                Err(AppError::Unexpected(status))
134            }
135        }
136    }
137}
138
139#[async_trait]
140impl IgHttpClient for IgHttpClientImpl {
141    async fn request<T, R>(
142        &self,
143        method: Method,
144        path: &str,
145        session: &IgSession,
146        body: Option<&T>,
147        version: &str,
148    ) -> Result<R, AppError>
149    where
150        for<'de> R: DeserializeOwned + 'static,
151        T: Serialize + Send + Sync + 'static,
152    {
153        let url = self.build_url(path);
154        debug!("Making {} request to {}", method, url);
155
156        let mut builder = self.client.request(method, &url);
157        builder = self.add_common_headers(builder, version);
158        builder = self.add_auth_headers(builder, session);
159
160        if let Some(data) = body {
161            builder = builder.json(data);
162        }
163
164        let response = builder.send().await?;
165        self.process_response::<R>(response).await
166    }
167
168    async fn request_no_auth<T, R>(
169        &self,
170        method: Method,
171        path: &str,
172        body: Option<&T>,
173        version: &str,
174    ) -> Result<R, AppError>
175    where
176        for<'de> R: DeserializeOwned + 'static,
177        T: Serialize + Send + Sync + 'static,
178    {
179        let url = self.build_url(path);
180        info!("Making unauthenticated {} request to {}", method, url);
181
182        let mut builder = self.client.request(method, &url);
183        builder = self.add_common_headers(builder, version);
184
185        if let Some(data) = body {
186            builder = builder.json(data);
187        }
188
189        let response = builder.send().await?;
190        self.process_response::<R>(response).await
191    }
192}