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/// Builder for constructing a FilesClient with custom configuration
19///
20/// Provides a fluent interface for configuring API credentials, base URL, timeouts,
21/// and other client settings before creating the final FilesClient instance.
22///
23/// # Examples
24///
25/// ```rust,no_run
26/// use files_sdk::FilesClient;
27///
28/// // Basic configuration
29/// let client = FilesClient::builder()
30///     .api_key("your-api-key")
31///     .build()?;
32///
33/// // Advanced configuration
34/// let client = FilesClient::builder()
35///     .api_key("your-api-key")
36///     .base_url("https://app.files.com/api/rest/v1".to_string())
37///     .timeout(std::time::Duration::from_secs(120))
38///     .build()?;
39/// # Ok::<(), Box<dyn std::error::Error>>(())
40/// ```
41#[derive(Debug, Clone)]
42pub struct FilesClientBuilder {
43    api_key: Option<String>,
44    base_url: String,
45    timeout: Duration,
46}
47
48impl Default for FilesClientBuilder {
49    fn default() -> Self {
50        Self {
51            api_key: None,
52            base_url: "https://app.files.com/api/rest/v1".to_string(),
53            timeout: Duration::from_secs(60),
54        }
55    }
56}
57
58impl FilesClientBuilder {
59    /// Sets the API key for authentication
60    ///
61    /// # Arguments
62    ///
63    /// * `api_key` - Your Files.com API key
64    pub fn api_key<S: Into<String>>(mut self, api_key: S) -> Self {
65        self.api_key = Some(api_key.into());
66        self
67    }
68
69    /// Sets a custom base URL for the API
70    ///
71    /// # Arguments
72    ///
73    /// * `base_url` - Custom base URL (useful for testing or regional endpoints)
74    pub fn base_url<S: Into<String>>(mut self, base_url: S) -> Self {
75        self.base_url = base_url.into();
76        self
77    }
78
79    /// Sets the request timeout duration
80    ///
81    /// # Arguments
82    ///
83    /// * `timeout` - Maximum duration for API requests
84    pub fn timeout(mut self, timeout: Duration) -> Self {
85        self.timeout = timeout;
86        self
87    }
88
89    /// Builds the FilesClient instance
90    ///
91    /// # Errors
92    ///
93    /// Returns an error if:
94    /// - API key is not set
95    /// - HTTP client cannot be constructed
96    pub fn build(self) -> Result<FilesClient> {
97        let api_key = self
98            .api_key
99            .ok_or_else(|| FilesError::ConfigError("API key is required".to_string()))?;
100
101        let client = Client::builder()
102            .timeout(self.timeout)
103            .build()
104            .map_err(|e| FilesError::ConfigError(format!("Failed to build HTTP client: {}", e)))?;
105
106        Ok(FilesClient {
107            inner: Arc::new(FilesClientInner {
108                api_key,
109                base_url: self.base_url,
110                client,
111            }),
112        })
113    }
114}
115
116/// Internal client state
117#[derive(Debug)]
118pub(crate) struct FilesClientInner {
119    pub(crate) api_key: String,
120    pub(crate) base_url: String,
121    pub(crate) client: Client,
122}
123
124/// Files.com API client
125///
126/// The main client for interacting with the Files.com API. Handles authentication,
127/// request construction, and response processing.
128///
129/// # Examples
130///
131/// ```rust,no_run
132/// use files_sdk::FilesClient;
133///
134/// # #[tokio::main]
135/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
136/// let client = FilesClient::builder()
137///     .api_key("your-api-key")
138///     .build()?;
139///
140/// // Use with handlers
141/// let file_handler = files_sdk::FileHandler::new(client.clone());
142/// # Ok(())
143/// # }
144/// ```
145#[derive(Debug, Clone)]
146pub struct FilesClient {
147    pub(crate) inner: Arc<FilesClientInner>,
148}
149
150impl FilesClient {
151    /// Creates a new FilesClientBuilder
152    pub fn builder() -> FilesClientBuilder {
153        FilesClientBuilder::default()
154    }
155
156    /// Performs a GET request to the Files.com API
157    ///
158    /// # Arguments
159    ///
160    /// * `path` - API endpoint path (without base URL)
161    ///
162    /// # Errors
163    ///
164    /// Returns an error if the request fails or returns a non-success status code
165    #[cfg_attr(feature = "tracing", instrument(skip(self), fields(method = "GET")))]
166    pub async fn get_raw(&self, path: &str) -> Result<serde_json::Value> {
167        let url = format!("{}{}", self.inner.base_url, path);
168
169        #[cfg(feature = "tracing")]
170        debug!("Making GET request to {}", path);
171
172        let response = self
173            .inner
174            .client
175            .get(&url)
176            .header("X-FilesAPI-Key", &self.inner.api_key)
177            .send()
178            .await?;
179
180        #[cfg(feature = "tracing")]
181        debug!("GET response status: {}", response.status());
182
183        self.handle_response(response).await
184    }
185
186    /// Performs a POST request to the Files.com API
187    ///
188    /// # Arguments
189    ///
190    /// * `path` - API endpoint path (without base URL)
191    /// * `body` - Request body (will be serialized to JSON)
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if the request fails or returns a non-success status code
196    #[cfg_attr(
197        feature = "tracing",
198        instrument(skip(self, body), fields(method = "POST"))
199    )]
200    pub async fn post_raw<T: Serialize>(&self, path: &str, body: T) -> Result<serde_json::Value> {
201        let url = format!("{}{}", self.inner.base_url, path);
202
203        #[cfg(feature = "tracing")]
204        debug!("Making POST request to {}", path);
205
206        let response = self
207            .inner
208            .client
209            .post(&url)
210            .header("X-FilesAPI-Key", &self.inner.api_key)
211            .json(&body)
212            .send()
213            .await?;
214
215        #[cfg(feature = "tracing")]
216        debug!("POST response status: {}", response.status());
217
218        self.handle_response(response).await
219    }
220
221    /// Performs a PATCH request to the Files.com API
222    ///
223    /// # Arguments
224    ///
225    /// * `path` - API endpoint path (without base URL)
226    /// * `body` - Request body (will be serialized to JSON)
227    ///
228    /// # Errors
229    ///
230    /// Returns an error if the request fails or returns a non-success status code
231    #[cfg_attr(
232        feature = "tracing",
233        instrument(skip(self, body), fields(method = "PATCH"))
234    )]
235    pub async fn patch_raw<T: Serialize>(&self, path: &str, body: T) -> Result<serde_json::Value> {
236        let url = format!("{}{}", self.inner.base_url, path);
237
238        #[cfg(feature = "tracing")]
239        debug!("Making PATCH request to {}", path);
240
241        let response = self
242            .inner
243            .client
244            .patch(&url)
245            .header("X-FilesAPI-Key", &self.inner.api_key)
246            .json(&body)
247            .send()
248            .await?;
249
250        #[cfg(feature = "tracing")]
251        debug!("PATCH response status: {}", response.status());
252
253        self.handle_response(response).await
254    }
255
256    /// Performs a DELETE request to the Files.com API
257    ///
258    /// # Arguments
259    ///
260    /// * `path` - API endpoint path (without base URL)
261    ///
262    /// # Errors
263    ///
264    /// Returns an error if the request fails or returns a non-success status code
265    #[cfg_attr(feature = "tracing", instrument(skip(self), fields(method = "DELETE")))]
266    pub async fn delete_raw(&self, path: &str) -> Result<serde_json::Value> {
267        let url = format!("{}{}", self.inner.base_url, path);
268
269        #[cfg(feature = "tracing")]
270        debug!("Making DELETE request to {}", path);
271
272        let response = self
273            .inner
274            .client
275            .delete(&url)
276            .header("X-FilesAPI-Key", &self.inner.api_key)
277            .send()
278            .await?;
279
280        #[cfg(feature = "tracing")]
281        debug!("DELETE response status: {}", response.status());
282
283        self.handle_response(response).await
284    }
285
286    /// Performs a POST request with form data to the Files.com API
287    ///
288    /// # Arguments
289    ///
290    /// * `path` - API endpoint path (without base URL)
291    /// * `form` - Form data as key-value pairs
292    ///
293    /// # Errors
294    ///
295    /// Returns an error if the request fails or returns a non-success status code
296    pub async fn post_form<T: Serialize>(&self, path: &str, form: T) -> Result<serde_json::Value> {
297        let url = format!("{}{}", self.inner.base_url, path);
298
299        let response = self
300            .inner
301            .client
302            .post(&url)
303            .header("X-FilesAPI-Key", &self.inner.api_key)
304            .form(&form)
305            .send()
306            .await?;
307
308        self.handle_response(response).await
309    }
310
311    /// Handles HTTP response and converts to Result
312    ///
313    /// Processes status codes and extracts error information when applicable
314    async fn handle_response(&self, response: reqwest::Response) -> Result<serde_json::Value> {
315        let status = response.status();
316
317        if status.is_success() {
318            // Handle 204 No Content
319            if status.as_u16() == 204 {
320                #[cfg(feature = "tracing")]
321                debug!("Received 204 No Content response");
322                return Ok(serde_json::Value::Null);
323            }
324
325            let value = response.json().await?;
326            Ok(value)
327        } else {
328            let status_code = status.as_u16();
329            let error_body = response.text().await.unwrap_or_default();
330
331            #[cfg(feature = "tracing")]
332            warn!(
333                status_code = status_code,
334                error_body = %error_body,
335                "API request failed"
336            );
337
338            // Try to parse error message from JSON
339            let message = if let Ok(json) = serde_json::from_str::<serde_json::Value>(&error_body) {
340                json.get("error")
341                    .or_else(|| json.get("message"))
342                    .and_then(|v| v.as_str())
343                    .unwrap_or(&error_body)
344                    .to_string()
345            } else {
346                error_body
347            };
348
349            let error = match status_code {
350                400 => FilesError::BadRequest { message },
351                401 => FilesError::AuthenticationFailed { message },
352                403 => FilesError::Forbidden { message },
353                404 => FilesError::NotFound { message },
354                409 => FilesError::Conflict { message },
355                412 => FilesError::PreconditionFailed { message },
356                422 => FilesError::UnprocessableEntity { message },
357                423 => FilesError::Locked { message },
358                429 => FilesError::RateLimited { message },
359                500 => FilesError::InternalServerError { message },
360                503 => FilesError::ServiceUnavailable { message },
361                _ => FilesError::ApiError {
362                    code: status_code,
363                    message,
364                },
365            };
366
367            #[cfg(feature = "tracing")]
368            error!(error = ?error, "Returning error to caller");
369
370            Err(error)
371        }
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn test_builder_default() {
381        let builder = FilesClientBuilder::default();
382        assert_eq!(
383            builder.base_url,
384            "https://app.files.com/api/rest/v1".to_string()
385        );
386        assert_eq!(builder.timeout, Duration::from_secs(60));
387    }
388
389    #[test]
390    fn test_builder_custom() {
391        let builder = FilesClientBuilder::default()
392            .api_key("test-key")
393            .base_url("https://custom.example.com")
394            .timeout(Duration::from_secs(120));
395
396        assert_eq!(builder.api_key, Some("test-key".to_string()));
397        assert_eq!(builder.base_url, "https://custom.example.com");
398        assert_eq!(builder.timeout, Duration::from_secs(120));
399    }
400
401    #[test]
402    fn test_builder_missing_api_key() {
403        let result = FilesClientBuilder::default().build();
404        assert!(result.is_err());
405        assert!(matches!(result.unwrap_err(), FilesError::ConfigError(_)));
406    }
407
408    #[test]
409    fn test_builder_success() {
410        let result = FilesClientBuilder::default().api_key("test-key").build();
411        assert!(result.is_ok());
412    }
413}