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 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    /// Performs a PATCH request to the Files.com API
229    ///
230    /// # Arguments
231    ///
232    /// * `path` - API endpoint path (without base URL)
233    /// * `body` - Request body (will be serialized to JSON)
234    ///
235    /// # Errors
236    ///
237    /// Returns an error if the request fails or returns a non-success status code
238    #[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    /// Performs a DELETE request to the Files.com API
266    ///
267    /// # Arguments
268    ///
269    /// * `path` - API endpoint path (without base URL)
270    ///
271    /// # Errors
272    ///
273    /// Returns an error if the request fails or returns a non-success status code
274    #[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    /// Performs a POST request with form data to the Files.com API
297    ///
298    /// # Arguments
299    ///
300    /// * `path` - API endpoint path (without base URL)
301    /// * `form` - Form data as key-value pairs
302    ///
303    /// # Errors
304    ///
305    /// Returns an error if the request fails or returns a non-success status code
306    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    /// Handles HTTP response and converts to Result
323    ///
324    /// Processes status codes and extracts error information when applicable
325    async fn handle_response(&self, response: reqwest::Response) -> Result<serde_json::Value> {
326        let status = response.status();
327
328        if status.is_success() {
329            // Handle 204 No Content
330            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            // Try to parse error message from JSON
350            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, // TODO: Parse Retry-After header
398                },
399                500 => FilesError::InternalServerError {
400                    message,
401                    request_id: None, // TODO: Parse request ID from headers
402                },
403                503 => FilesError::ServiceUnavailable {
404                    message,
405                    retry_after: None, // TODO: Parse Retry-After header
406                },
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}