1use crate::{FilesError, Result};
10use reqwest::Client;
11use serde::Serialize;
12use std::sync::Arc;
13use std::time::Duration;
14
15#[cfg(feature = "tracing")]
16use tracing::{debug, error, instrument, warn};
17
18const USER_AGENT: &str = concat!("Files.com Rust SDK ", env!("CARGO_PKG_VERSION"));
21
22#[derive(Debug, Clone)]
46pub struct FilesClientBuilder {
47 api_key: Option<String>,
48 base_url: String,
49 timeout: Duration,
50}
51
52impl Default for FilesClientBuilder {
53 fn default() -> Self {
54 Self {
55 api_key: None,
56 base_url: "https://app.files.com/api/rest/v1".to_string(),
57 timeout: Duration::from_secs(60),
58 }
59 }
60}
61
62impl FilesClientBuilder {
63 pub fn api_key<S: Into<String>>(mut self, api_key: S) -> Self {
69 self.api_key = Some(api_key.into());
70 self
71 }
72
73 pub fn base_url<S: Into<String>>(mut self, base_url: S) -> Self {
79 self.base_url = base_url.into();
80 self
81 }
82
83 pub fn timeout(mut self, timeout: Duration) -> Self {
89 self.timeout = timeout;
90 self
91 }
92
93 pub fn build(self) -> Result<FilesClient> {
101 let api_key = self
102 .api_key
103 .ok_or_else(|| FilesError::ConfigError("API key is required".to_string()))?;
104
105 let client = Client::builder()
106 .timeout(self.timeout)
107 .build()
108 .map_err(|e| FilesError::ConfigError(format!("Failed to build HTTP client: {}", e)))?;
109
110 Ok(FilesClient {
111 inner: Arc::new(FilesClientInner {
112 api_key,
113 base_url: self.base_url,
114 client,
115 }),
116 })
117 }
118}
119
120#[derive(Debug)]
122pub(crate) struct FilesClientInner {
123 pub(crate) api_key: String,
124 pub(crate) base_url: String,
125 pub(crate) client: Client,
126}
127
128#[derive(Debug, Clone)]
150pub struct FilesClient {
151 pub(crate) inner: Arc<FilesClientInner>,
152}
153
154impl FilesClient {
155 pub fn builder() -> FilesClientBuilder {
157 FilesClientBuilder::default()
158 }
159
160 #[cfg_attr(feature = "tracing", instrument(skip(self), fields(method = "GET")))]
170 pub async fn get_raw(&self, path: &str) -> Result<serde_json::Value> {
171 let url = format!("{}{}", self.inner.base_url, path);
172
173 #[cfg(feature = "tracing")]
174 debug!("Making GET request to {}", path);
175
176 let response = self
177 .inner
178 .client
179 .get(&url)
180 .header("X-FilesAPI-Key", &self.inner.api_key)
181 .header("User-Agent", USER_AGENT)
182 .send()
183 .await?;
184
185 #[cfg(feature = "tracing")]
186 debug!("GET response status: {}", response.status());
187
188 self.handle_response(response).await
189 }
190
191 #[cfg_attr(
202 feature = "tracing",
203 instrument(skip(self, body), fields(method = "POST"))
204 )]
205 pub async fn post_raw<T: Serialize>(&self, path: &str, body: T) -> Result<serde_json::Value> {
206 let url = format!("{}{}", self.inner.base_url, path);
207
208 #[cfg(feature = "tracing")]
209 debug!("Making POST request to {}", path);
210
211 let json_body = serde_json::to_string(&body).map_err(FilesError::JsonError)?;
212
213 let response = self
214 .inner
215 .client
216 .post(&url)
217 .header("X-FilesAPI-Key", &self.inner.api_key)
218 .header("User-Agent", USER_AGENT)
219 .header("Content-Type", "application/json")
220 .body(json_body)
221 .send()
222 .await?;
223
224 #[cfg(feature = "tracing")]
225 debug!("POST response status: {}", response.status());
226
227 self.handle_response(response).await
228 }
229
230 #[cfg_attr(
241 feature = "tracing",
242 instrument(skip(self, body), fields(method = "PATCH"))
243 )]
244 pub async fn patch_raw<T: Serialize>(&self, path: &str, body: T) -> Result<serde_json::Value> {
245 let url = format!("{}{}", self.inner.base_url, path);
246
247 #[cfg(feature = "tracing")]
248 debug!("Making PATCH request to {}", path);
249
250 let json_body = serde_json::to_string(&body).map_err(FilesError::JsonError)?;
251
252 let response = self
253 .inner
254 .client
255 .patch(&url)
256 .header("X-FilesAPI-Key", &self.inner.api_key)
257 .header("User-Agent", USER_AGENT)
258 .header("Content-Type", "application/json")
259 .body(json_body)
260 .send()
261 .await?;
262
263 #[cfg(feature = "tracing")]
264 debug!("PATCH response status: {}", response.status());
265
266 self.handle_response(response).await
267 }
268
269 #[cfg_attr(feature = "tracing", instrument(skip(self), fields(method = "DELETE")))]
279 pub async fn delete_raw(&self, path: &str) -> Result<serde_json::Value> {
280 let url = format!("{}{}", self.inner.base_url, path);
281
282 #[cfg(feature = "tracing")]
283 debug!("Making DELETE request to {}", path);
284
285 let response = self
286 .inner
287 .client
288 .delete(&url)
289 .header("X-FilesAPI-Key", &self.inner.api_key)
290 .header("User-Agent", USER_AGENT)
291 .send()
292 .await?;
293
294 #[cfg(feature = "tracing")]
295 debug!("DELETE response status: {}", response.status());
296
297 self.handle_response(response).await
298 }
299
300 pub async fn post_form<T: Serialize>(&self, path: &str, form: T) -> Result<serde_json::Value> {
311 let url = format!("{}{}", self.inner.base_url, path);
312
313 let response = self
314 .inner
315 .client
316 .post(&url)
317 .header("X-FilesAPI-Key", &self.inner.api_key)
318 .header("User-Agent", USER_AGENT)
319 .form(&form)
320 .send()
321 .await?;
322
323 self.handle_response(response).await
324 }
325
326 async fn handle_response(&self, response: reqwest::Response) -> Result<serde_json::Value> {
330 let status = response.status();
331
332 if status.is_success() {
333 if status.as_u16() == 204 {
335 #[cfg(feature = "tracing")]
336 debug!("Received 204 No Content response");
337 return Ok(serde_json::Value::Null);
338 }
339
340 let text = response.text().await?;
342 let deserializer = &mut serde_json::Deserializer::from_str(&text);
343 let value: serde_json::Value =
344 serde_path_to_error::deserialize(deserializer).map_err(|e| {
345 FilesError::JsonPathError {
346 path: e.path().to_string(),
347 source: e.into_inner(),
348 }
349 })?;
350 Ok(value)
351 } else {
352 let status_code = status.as_u16();
353 let error_body = response.text().await.unwrap_or_default();
354
355 #[cfg(feature = "tracing")]
356 warn!(
357 status_code = status_code,
358 error_body = %error_body,
359 "API request failed"
360 );
361
362 let message = if let Ok(json) = serde_json::from_str::<serde_json::Value>(&error_body) {
364 json.get("error")
365 .or_else(|| json.get("message"))
366 .and_then(|v| v.as_str())
367 .unwrap_or(&error_body)
368 .to_string()
369 } else {
370 error_body
371 };
372
373 let error = match status_code {
374 400 => FilesError::BadRequest {
375 message,
376 field: None,
377 },
378 401 => FilesError::AuthenticationFailed {
379 message,
380 auth_type: None,
381 },
382 403 => FilesError::Forbidden {
383 message,
384 resource: None,
385 },
386 404 => FilesError::NotFound {
387 message,
388 resource_type: None,
389 path: None,
390 },
391 409 => FilesError::Conflict {
392 message,
393 resource: None,
394 },
395 412 => FilesError::PreconditionFailed {
396 message,
397 condition: None,
398 },
399 422 => FilesError::UnprocessableEntity {
400 message,
401 field: None,
402 value: None,
403 },
404 423 => FilesError::Locked {
405 message,
406 resource: None,
407 },
408 429 => FilesError::RateLimited {
409 message,
410 retry_after: None, },
412 500 => FilesError::InternalServerError {
413 message,
414 request_id: None, },
416 503 => FilesError::ServiceUnavailable {
417 message,
418 retry_after: None, },
420 _ => FilesError::ApiError {
421 code: status_code,
422 message,
423 endpoint: None,
424 },
425 };
426
427 #[cfg(feature = "tracing")]
428 error!(error = ?error, "Returning error to caller");
429
430 Err(error)
431 }
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 #[test]
440 fn test_builder_default() {
441 let builder = FilesClientBuilder::default();
442 assert_eq!(
443 builder.base_url,
444 "https://app.files.com/api/rest/v1".to_string()
445 );
446 assert_eq!(builder.timeout, Duration::from_secs(60));
447 }
448
449 #[test]
450 fn test_builder_custom() {
451 let builder = FilesClientBuilder::default()
452 .api_key("test-key")
453 .base_url("https://custom.example.com")
454 .timeout(Duration::from_secs(120));
455
456 assert_eq!(builder.api_key, Some("test-key".to_string()));
457 assert_eq!(builder.base_url, "https://custom.example.com");
458 assert_eq!(builder.timeout, Duration::from_secs(120));
459 }
460
461 #[test]
462 fn test_builder_missing_api_key() {
463 let result = FilesClientBuilder::default().build();
464 assert!(result.is_err());
465 assert!(matches!(result.unwrap_err(), FilesError::ConfigError(_)));
466 }
467
468 #[test]
469 fn test_builder_success() {
470 let result = FilesClientBuilder::default().api_key("test-key").build();
471 assert!(result.is_ok());
472 }
473}