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#[async_trait]
11pub trait IgHttpClient: Send + Sync {
12 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 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
38pub struct IgHttpClientImpl {
40 config: Arc<Config>,
41 client: Client,
42}
43
44impl IgHttpClientImpl {
45 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 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 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 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 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 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 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}