files_sdk/
client.rs

1//! Files.com API client core implementation
2//!
3//! This module contains the core HTTP client for interacting with the Files.com REST API.
4//! It provides authentication handling, request/response processing, and error management.
5//!
6//! The client is designed around a builder pattern for flexible configuration and supports
7//! both typed and untyped API interactions.
8
9use 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
18/// User-Agent header value
19/// Format: "Files.com Rust SDK {version}"
20const USER_AGENT: &str = concat!("Files.com Rust SDK ", env!("CARGO_PKG_VERSION"));
21
22/// Builder for constructing a FilesClient with custom configuration
23///
24/// Provides a fluent interface for configuring API credentials, base URL, timeouts,
25/// and other client settings before creating the final FilesClient instance.
26///
27/// # Examples
28///
29/// ```rust,no_run
30/// use files_sdk::FilesClient;
31///
32/// // Basic configuration
33/// let client = FilesClient::builder()
34///     .api_key("your-api-key")
35///     .build()?;
36///
37/// // Advanced configuration
38/// let client = FilesClient::builder()
39///     .api_key("your-api-key")
40///     .base_url("https://app.files.com/api/rest/v1".to_string())
41///     .timeout(std::time::Duration::from_secs(120))
42///     .build()?;
43/// # Ok::<(), Box<dyn std::error::Error>>(())
44/// ```
45#[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    /// Sets the API key for authentication
64    ///
65    /// # Arguments
66    ///
67    /// * `api_key` - Your Files.com API key
68    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    /// Sets a custom base URL for the API
74    ///
75    /// # Arguments
76    ///
77    /// * `base_url` - Custom base URL (useful for testing or regional endpoints)
78    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    /// Sets the request timeout duration
84    ///
85    /// # Arguments
86    ///
87    /// * `timeout` - Maximum duration for API requests
88    pub fn timeout(mut self, timeout: Duration) -> Self {
89        self.timeout = timeout;
90        self
91    }
92
93    /// Builds the FilesClient instance
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if:
98    /// - API key is not set
99    /// - HTTP client cannot be constructed
100    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/// Internal client state
121#[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/// Files.com API client
129///
130/// The main client for interacting with the Files.com API. Handles authentication,
131/// request construction, and response processing.
132///
133/// # Examples
134///
135/// ```rust,no_run
136/// use files_sdk::FilesClient;
137///
138/// # #[tokio::main]
139/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
140/// let client = FilesClient::builder()
141///     .api_key("your-api-key")
142///     .build()?;
143///
144/// // Use with handlers
145/// let file_handler = files_sdk::FileHandler::new(client.clone());
146/// # Ok(())
147/// # }
148/// ```
149#[derive(Debug, Clone)]
150pub struct FilesClient {
151    pub(crate) inner: Arc<FilesClientInner>,
152}
153
154impl FilesClient {
155    /// Creates a new FilesClientBuilder
156    pub fn builder() -> FilesClientBuilder {
157        FilesClientBuilder::default()
158    }
159
160    /// Performs a GET request to the Files.com API
161    ///
162    /// # Arguments
163    ///
164    /// * `path` - API endpoint path (without base URL)
165    ///
166    /// # Errors
167    ///
168    /// Returns an error if the request fails or returns a non-success status code
169    #[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    /// Performs a POST request to the Files.com API
192    ///
193    /// # Arguments
194    ///
195    /// * `path` - API endpoint path (without base URL)
196    /// * `body` - Request body (will be serialized to JSON)
197    ///
198    /// # Errors
199    ///
200    /// Returns an error if the request fails or returns a non-success status code
201    #[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    /// Performs a PATCH request to the Files.com API
231    ///
232    /// # Arguments
233    ///
234    /// * `path` - API endpoint path (without base URL)
235    /// * `body` - Request body (will be serialized to JSON)
236    ///
237    /// # Errors
238    ///
239    /// Returns an error if the request fails or returns a non-success status code
240    #[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    /// Performs a DELETE request to the Files.com API
270    ///
271    /// # Arguments
272    ///
273    /// * `path` - API endpoint path (without base URL)
274    ///
275    /// # Errors
276    ///
277    /// Returns an error if the request fails or returns a non-success status code
278    #[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    /// Performs a POST request with form data to the Files.com API
301    ///
302    /// # Arguments
303    ///
304    /// * `path` - API endpoint path (without base URL)
305    /// * `form` - Form data as key-value pairs
306    ///
307    /// # Errors
308    ///
309    /// Returns an error if the request fails or returns a non-success status code
310    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    /// Handles HTTP response and converts to Result
327    ///
328    /// Processes status codes and extracts error information when applicable
329    async fn handle_response(&self, response: reqwest::Response) -> Result<serde_json::Value> {
330        let status = response.status();
331
332        if status.is_success() {
333            // Handle 204 No Content
334            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            // Use serde_path_to_error for better error messages
341            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            // Try to parse error message from JSON
363            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, // TODO: Parse Retry-After header
411                },
412                500 => FilesError::InternalServerError {
413                    message,
414                    request_id: None, // TODO: Parse request ID from headers
415                },
416                503 => FilesError::ServiceUnavailable {
417                    message,
418                    retry_after: None, // TODO: Parse Retry-After header
419                },
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}