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
18#[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 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 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 pub fn timeout(mut self, timeout: Duration) -> Self {
85 self.timeout = timeout;
86 self
87 }
88
89 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#[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#[derive(Debug, Clone)]
146pub struct FilesClient {
147 pub(crate) inner: Arc<FilesClientInner>,
148}
149
150impl FilesClient {
151 pub fn builder() -> FilesClientBuilder {
153 FilesClientBuilder::default()
154 }
155
156 #[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 #[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 #[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 #[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 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 async fn handle_response(&self, response: reqwest::Response) -> Result<serde_json::Value> {
315 let status = response.status();
316
317 if status.is_success() {
318 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 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}