1use crate::error::ApifyError;
2use crate::models::{DataResponse, ListResponse};
3use reqwest::{Client, Method, RequestBuilder};
4use serde::de::DeserializeOwned;
5use serde::Serialize;
6use std::time::Duration;
7
8const DEFAULT_BASE_URL: &str = "https://api.apify.com/v2";
9const DEFAULT_TIMEOUT_SECS: u64 = 60;
10
11#[derive(Debug, Clone)]
23pub struct HttpClient {
24 pub(crate) client: Client,
25 pub(crate) base_url: String,
26 pub(crate) token: Option<String>,
27}
28
29impl HttpClient {
30 pub fn new(token: Option<String>) -> Self {
34 let client = Client::builder()
35 .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
36 .build()
37 .expect("Failed to build HTTP client");
38
39 Self {
40 client,
41 base_url: DEFAULT_BASE_URL.to_string(),
42 token,
43 }
44 }
45
46 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
48 self.base_url = base_url.into();
49 self
50 }
51
52 pub fn with_timeout(mut self, timeout: Duration) -> Self {
54 self.client = Client::builder()
55 .timeout(timeout)
56 .build()
57 .expect("Failed to build HTTP client");
58 self
59 }
60
61 fn build_url(&self, path: &str) -> String {
62 format!("{}/{}", self.base_url.trim_end_matches('/'), path.trim_start_matches('/'))
63 }
64
65 fn authenticate(&self, builder: RequestBuilder) -> RequestBuilder {
66 match &self.token {
67 Some(token) => builder.bearer_auth(token),
68 None => builder,
69 }
70 }
71
72 pub async fn request<T: DeserializeOwned>(
77 &self,
78 method: Method,
79 path: &str,
80 body: Option<impl Serialize>,
81 ) -> Result<T, ApifyError> {
82 let url = self.build_url(path);
83 let mut builder = self.client.request(method.clone(), &url);
84 builder = self.authenticate(builder);
85
86 if let Some(body_data) = body {
87 builder = builder.json(&body_data);
88 }
89
90 let response = builder.send().await?;
91 self.handle_response(response).await
92 }
93
94 async fn handle_response<T: DeserializeOwned>(
95 &self,
96 response: reqwest::Response,
97 ) -> Result<T, ApifyError> {
98 let status = response.status();
99
100 if status.is_success() {
101 let data = response.json::<T>().await?;
102 return Ok(data);
103 }
104
105 let error_body: serde_json::Value = response.json().await.unwrap_or_default();
108 let error_type = error_body["error"]["type"]
109 .as_str()
110 .unwrap_or("unknown")
111 .to_string();
112 let message = error_body["error"]["message"]
113 .as_str()
114 .unwrap_or("Unknown error")
115 .to_string();
116
117 Err(ApifyError::from_api_response(
118 status.as_u16(),
119 error_type,
120 message,
121 ))
122 }
123
124 pub async fn request_with_retry<T: DeserializeOwned>(
129 &self,
130 method: Method,
131 path: &str,
132 body: Option<impl Serialize>,
133 ) -> Result<T, ApifyError> {
134 let body_value = match body {
135 Some(b) => Some(serde_json::to_value(b)?),
136 None => None,
137 };
138
139 let mut delay_ms: u64 = 500;
140 let max_retries = 5;
141
142 for attempt in 0..max_retries {
143 match self.request(method.clone(), path, body_value.clone()).await {
144 Ok(data) => return Ok(data),
145 Err(ApifyError::RateLimit) if attempt < max_retries - 1 => {
146 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
147 delay_ms *= 2;
148 }
149 Err(e) => return Err(e),
150 }
151 }
152
153 Err(ApifyError::RateLimit)
154 }
155}
156
157impl HttpClient {
159 pub async fn get_data<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApifyError> {
161 let resp: DataResponse<T> = self.request(Method::GET, path, None::<()>).await?;
162 Ok(resp.data)
163 }
164
165 pub async fn post_data<T: DeserializeOwned, B: Serialize>(
167 &self,
168 path: &str,
169 body: B,
170 ) -> Result<T, ApifyError> {
171 let resp: DataResponse<T> = self.request(Method::POST, path, Some(body)).await?;
172 Ok(resp.data)
173 }
174
175 pub async fn put_data<T: DeserializeOwned, B: Serialize>(
177 &self,
178 path: &str,
179 body: B,
180 ) -> Result<T, ApifyError> {
181 let resp: DataResponse<T> = self.request(Method::PUT, path, Some(body)).await?;
182 Ok(resp.data)
183 }
184
185 pub async fn delete_request(&self, path: &str) -> Result<(), ApifyError> {
187 let resp = self
188 .client
189 .delete(self.build_url(path))
190 .send()
191 .await?;
192
193 if resp.status().is_success() {
194 Ok(())
195 } else {
196 let status = resp.status();
197 let error_body: serde_json::Value = resp.json().await.unwrap_or_default();
198 let error_type = error_body["error"]["type"]
199 .as_str()
200 .unwrap_or("unknown")
201 .to_string();
202 let message = error_body["error"]["message"]
203 .as_str()
204 .unwrap_or("Unknown error")
205 .to_string();
206 Err(ApifyError::from_api_response(status.as_u16(), error_type, message))
207 }
208 }
209
210 pub async fn get_list<T: DeserializeOwned>(
212 &self,
213 path: &str,
214 ) -> Result<crate::models::ListData<T>, ApifyError> {
215 let resp: ListResponse<T> = self.request(Method::GET, path, None::<()>).await?;
216 Ok(resp.data)
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use crate::models::{ActorJobStatus, DataResponse, ListData, ListResponse, Run, Task, TaskShort};
224 use chrono::Utc;
225
226 #[tokio::test]
227 async fn test_get_task_success() {
228 let mut server = mockito::Server::new_async().await;
229
230 let task = Task {
231 id: "test-task-id".to_string(),
232 user_id: "user123".to_string(),
233 act_id: "actor456".to_string(),
234 name: "test-task".to_string(),
235 username: Some("testuser".to_string()),
236 created_at: Utc::now(),
237 modified_at: Utc::now(),
238 removed_at: None,
239 stats: None,
240 options: None,
241 input: None,
242 title: None,
243 };
244
245 let response = DataResponse { data: task };
246 let body = serde_json::to_string(&response).unwrap();
247
248 let mock = server
249 .mock("GET", "/actor-tasks/test-task-id")
250 .with_status(200)
251 .with_header("content-type", "application/json")
252 .with_body(body)
253 .create_async()
254 .await;
255
256 let client = HttpClient::new(Some("test-token".to_string()))
257 .with_base_url(server.url());
258
259 let result = client.get_data::<Task>("/actor-tasks/test-task-id").await;
260 assert!(result.is_ok());
261 let task = result.unwrap();
262 assert_eq!(task.id, "test-task-id");
263 assert_eq!(task.name, "test-task");
264
265 mock.assert_async().await;
266 }
267
268 #[tokio::test]
269 async fn test_get_run_success() {
270 let mut server = mockito::Server::new_async().await;
271
272 let run = Run {
273 id: "run-123".to_string(),
274 act_id: "actor-456".to_string(),
275 user_id: "user-789".to_string(),
276 actor_task_id: Some("task-abc".to_string()),
277 started_at: Utc::now(),
278 finished_at: None,
279 status: ActorJobStatus::Running,
280 status_message: Some("Actor is running".to_string()),
281 is_status_message_terminal: Some(false),
282 meta: None,
283 stats: None,
284 options: None,
285 build_id: "build-xyz".to_string(),
286 exit_code: None,
287 default_key_value_store_id: "kv-store-1".to_string(),
288 default_dataset_id: "dataset-1".to_string(),
289 default_request_queue_id: "queue-1".to_string(),
290 storage_ids: None,
291 build_number: Some("0.1.0".to_string()),
292 container_url: None,
293 is_container_server_ready: None,
294 git_branch_name: None,
295 usage_total_usd: None,
296 charged_event_counts: None,
297 };
298
299 let response = DataResponse { data: run };
300 let body = serde_json::to_string(&response).unwrap();
301
302 let mock = server
303 .mock("GET", "/actor-runs/run-123")
304 .with_status(200)
305 .with_header("content-type", "application/json")
306 .with_body(body)
307 .create_async()
308 .await;
309
310 let client = HttpClient::new(Some("test-token".to_string()))
311 .with_base_url(server.url());
312
313 let result = client.get_data::<Run>("/actor-runs/run-123").await;
314 assert!(result.is_ok());
315 let run = result.unwrap();
316 assert_eq!(run.id, "run-123");
317 assert_eq!(run.status, ActorJobStatus::Running);
318
319 mock.assert_async().await;
320 }
321
322 #[tokio::test]
323 async fn test_api_error_response() {
324 let mut server = mockito::Server::new_async().await;
325
326 let error_body = serde_json::json!({
327 "error": {
328 "type": "record-not-found",
329 "message": "Store was not found."
330 }
331 });
332
333 let mock = server
334 .mock("GET", "/actor-runs/nonexistent")
335 .with_status(404)
336 .with_header("content-type", "application/json")
337 .with_body(error_body.to_string())
338 .create_async()
339 .await;
340
341 let client = HttpClient::new(Some("test-token".to_string()))
342 .with_base_url(server.url());
343
344 let result = client.get_data::<Run>("/actor-runs/nonexistent").await;
345 assert!(result.is_err());
346 let err = result.unwrap_err();
347 let err_str = err.to_string();
348 assert!(err_str.contains("not found") || err_str.contains("Store was not found"));
349
350 mock.assert_async().await;
351 }
352
353 #[tokio::test]
354 async fn test_list_tasks() {
355 let mut server = mockito::Server::new_async().await;
356
357 let tasks = vec![TaskShort {
358 id: "task-1".to_string(),
359 act_id: "actor-1".to_string(),
360 name: "Task One".to_string(),
361 username: None,
362 created_at: Utc::now(),
363 modified_at: Utc::now(),
364 }];
365
366 let response = ListResponse {
367 data: ListData {
368 total: 1,
369 offset: 0,
370 limit: 10,
371 count: 1,
372 desc: false,
373 items: tasks,
374 },
375 };
376
377 let mock = server
378 .mock("GET", "/actor-tasks")
379 .with_status(200)
380 .with_header("content-type", "application/json")
381 .with_body(serde_json::to_string(&response).unwrap())
382 .create_async()
383 .await;
384
385 let client = HttpClient::new(Some("test-token".to_string()))
386 .with_base_url(server.url());
387
388 let result = client.get_list::<TaskShort>("/actor-tasks").await;
389 assert!(result.is_ok());
390 let list = result.unwrap();
391 assert_eq!(list.total, 1);
392 assert_eq!(list.items.len(), 1);
393 assert_eq!(list.items[0].name, "Task One");
394
395 mock.assert_async().await;
396 }
397}