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 response = self
212 .inner
213 .client
214 .post(&url)
215 .header("X-FilesAPI-Key", &self.inner.api_key)
216 .header("User-Agent", USER_AGENT)
217 .header("Content-Type", "application/json")
218 .json(&body)
219 .send()
220 .await?;
221
222 #[cfg(feature = "tracing")]
223 debug!("POST response status: {}", response.status());
224
225 self.handle_response(response).await
226 }
227
228 #[cfg_attr(
239 feature = "tracing",
240 instrument(skip(self, body), fields(method = "PATCH"))
241 )]
242 pub async fn patch_raw<T: Serialize>(&self, path: &str, body: T) -> Result<serde_json::Value> {
243 let url = format!("{}{}", self.inner.base_url, path);
244
245 #[cfg(feature = "tracing")]
246 debug!("Making PATCH request to {}", path);
247
248 let response = self
249 .inner
250 .client
251 .patch(&url)
252 .header("X-FilesAPI-Key", &self.inner.api_key)
253 .header("User-Agent", USER_AGENT)
254 .header("Content-Type", "application/json")
255 .json(&body)
256 .send()
257 .await?;
258
259 #[cfg(feature = "tracing")]
260 debug!("PATCH response status: {}", response.status());
261
262 self.handle_response(response).await
263 }
264
265 #[cfg_attr(feature = "tracing", instrument(skip(self), fields(method = "DELETE")))]
275 pub async fn delete_raw(&self, path: &str) -> Result<serde_json::Value> {
276 let url = format!("{}{}", self.inner.base_url, path);
277
278 #[cfg(feature = "tracing")]
279 debug!("Making DELETE request to {}", path);
280
281 let response = self
282 .inner
283 .client
284 .delete(&url)
285 .header("X-FilesAPI-Key", &self.inner.api_key)
286 .header("User-Agent", USER_AGENT)
287 .send()
288 .await?;
289
290 #[cfg(feature = "tracing")]
291 debug!("DELETE response status: {}", response.status());
292
293 self.handle_response(response).await
294 }
295
296 pub async fn post_form<T: Serialize>(&self, path: &str, form: T) -> Result<serde_json::Value> {
307 let url = format!("{}{}", self.inner.base_url, path);
308
309 let response = self
310 .inner
311 .client
312 .post(&url)
313 .header("X-FilesAPI-Key", &self.inner.api_key)
314 .header("User-Agent", USER_AGENT)
315 .form(&form)
316 .send()
317 .await?;
318
319 self.handle_response(response).await
320 }
321
322 async fn handle_response(&self, response: reqwest::Response) -> Result<serde_json::Value> {
326 let status = response.status();
327
328 if status.is_success() {
329 if status.as_u16() == 204 {
331 #[cfg(feature = "tracing")]
332 debug!("Received 204 No Content response");
333 return Ok(serde_json::Value::Null);
334 }
335
336 let value = response.json().await?;
337 Ok(value)
338 } else {
339 let status_code = status.as_u16();
340 let error_body = response.text().await.unwrap_or_default();
341
342 #[cfg(feature = "tracing")]
343 warn!(
344 status_code = status_code,
345 error_body = %error_body,
346 "API request failed"
347 );
348
349 let message = if let Ok(json) = serde_json::from_str::<serde_json::Value>(&error_body) {
351 json.get("error")
352 .or_else(|| json.get("message"))
353 .and_then(|v| v.as_str())
354 .unwrap_or(&error_body)
355 .to_string()
356 } else {
357 error_body
358 };
359
360 let error = match status_code {
361 400 => FilesError::BadRequest {
362 message,
363 field: None,
364 },
365 401 => FilesError::AuthenticationFailed {
366 message,
367 auth_type: None,
368 },
369 403 => FilesError::Forbidden {
370 message,
371 resource: None,
372 },
373 404 => FilesError::NotFound {
374 message,
375 resource_type: None,
376 path: None,
377 },
378 409 => FilesError::Conflict {
379 message,
380 resource: None,
381 },
382 412 => FilesError::PreconditionFailed {
383 message,
384 condition: None,
385 },
386 422 => FilesError::UnprocessableEntity {
387 message,
388 field: None,
389 value: None,
390 },
391 423 => FilesError::Locked {
392 message,
393 resource: None,
394 },
395 429 => FilesError::RateLimited {
396 message,
397 retry_after: None, },
399 500 => FilesError::InternalServerError {
400 message,
401 request_id: None, },
403 503 => FilesError::ServiceUnavailable {
404 message,
405 retry_after: None, },
407 _ => FilesError::ApiError {
408 code: status_code,
409 message,
410 endpoint: None,
411 },
412 };
413
414 #[cfg(feature = "tracing")]
415 error!(error = ?error, "Returning error to caller");
416
417 Err(error)
418 }
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 #[test]
427 fn test_builder_default() {
428 let builder = FilesClientBuilder::default();
429 assert_eq!(
430 builder.base_url,
431 "https://app.files.com/api/rest/v1".to_string()
432 );
433 assert_eq!(builder.timeout, Duration::from_secs(60));
434 }
435
436 #[test]
437 fn test_builder_custom() {
438 let builder = FilesClientBuilder::default()
439 .api_key("test-key")
440 .base_url("https://custom.example.com")
441 .timeout(Duration::from_secs(120));
442
443 assert_eq!(builder.api_key, Some("test-key".to_string()));
444 assert_eq!(builder.base_url, "https://custom.example.com");
445 assert_eq!(builder.timeout, Duration::from_secs(120));
446 }
447
448 #[test]
449 fn test_builder_missing_api_key() {
450 let result = FilesClientBuilder::default().build();
451 assert!(result.is_err());
452 assert!(matches!(result.unwrap_err(), FilesError::ConfigError(_)));
453 }
454
455 #[test]
456 fn test_builder_success() {
457 let result = FilesClientBuilder::default().api_key("test-key").build();
458 assert!(result.is_ok());
459 }
460}