use crate::{FilesError, Result};
use reqwest::Client;
use serde::Serialize;
use std::sync::Arc;
use std::time::Duration;
#[cfg(feature = "tracing")]
use tracing::{debug, error, instrument, warn};
const USER_AGENT: &str = concat!("Files.com Rust SDK ", env!("CARGO_PKG_VERSION"));
#[derive(Debug, Clone)]
pub struct FilesClientBuilder {
api_key: Option<String>,
base_url: String,
timeout: Duration,
}
impl Default for FilesClientBuilder {
fn default() -> Self {
Self {
api_key: None,
base_url: "https://app.files.com/api/rest/v1".to_string(),
timeout: Duration::from_secs(60),
}
}
}
impl FilesClientBuilder {
pub fn api_key<S: Into<String>>(mut self, api_key: S) -> Self {
self.api_key = Some(api_key.into());
self
}
pub fn base_url<S: Into<String>>(mut self, base_url: S) -> Self {
self.base_url = base_url.into();
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn build(self) -> Result<FilesClient> {
let api_key = self
.api_key
.ok_or_else(|| FilesError::ConfigError("API key is required".to_string()))?;
let client = Client::builder()
.timeout(self.timeout)
.build()
.map_err(|e| FilesError::ConfigError(format!("Failed to build HTTP client: {}", e)))?;
Ok(FilesClient {
inner: Arc::new(FilesClientInner {
api_key,
base_url: self.base_url,
client,
}),
})
}
}
#[derive(Debug)]
pub(crate) struct FilesClientInner {
pub(crate) api_key: String,
pub(crate) base_url: String,
pub(crate) client: Client,
}
#[derive(Debug, Clone)]
pub struct FilesClient {
pub(crate) inner: Arc<FilesClientInner>,
}
impl FilesClient {
pub fn builder() -> FilesClientBuilder {
FilesClientBuilder::default()
}
#[cfg_attr(feature = "tracing", instrument(skip(self), fields(method = "GET")))]
pub async fn get_raw(&self, path: &str) -> Result<serde_json::Value> {
let url = format!("{}{}", self.inner.base_url, path);
#[cfg(feature = "tracing")]
debug!("Making GET request to {}", path);
let response = self
.inner
.client
.get(&url)
.header("X-FilesAPI-Key", &self.inner.api_key)
.header("User-Agent", USER_AGENT)
.send()
.await?;
#[cfg(feature = "tracing")]
debug!("GET response status: {}", response.status());
self.handle_response(response).await
}
#[cfg_attr(
feature = "tracing",
instrument(skip(self, body), fields(method = "POST"))
)]
pub async fn post_raw<T: Serialize>(&self, path: &str, body: T) -> Result<serde_json::Value> {
let url = format!("{}{}", self.inner.base_url, path);
#[cfg(feature = "tracing")]
debug!("Making POST request to {}", path);
let json_body = serde_json::to_string(&body).map_err(FilesError::JsonError)?;
let response = self
.inner
.client
.post(&url)
.header("X-FilesAPI-Key", &self.inner.api_key)
.header("User-Agent", USER_AGENT)
.header("Content-Type", "application/json")
.body(json_body)
.send()
.await?;
#[cfg(feature = "tracing")]
debug!("POST response status: {}", response.status());
self.handle_response(response).await
}
#[cfg_attr(
feature = "tracing",
instrument(skip(self, body), fields(method = "PATCH"))
)]
pub async fn patch_raw<T: Serialize>(&self, path: &str, body: T) -> Result<serde_json::Value> {
let url = format!("{}{}", self.inner.base_url, path);
#[cfg(feature = "tracing")]
debug!("Making PATCH request to {}", path);
let json_body = serde_json::to_string(&body).map_err(FilesError::JsonError)?;
let response = self
.inner
.client
.patch(&url)
.header("X-FilesAPI-Key", &self.inner.api_key)
.header("User-Agent", USER_AGENT)
.header("Content-Type", "application/json")
.body(json_body)
.send()
.await?;
#[cfg(feature = "tracing")]
debug!("PATCH response status: {}", response.status());
self.handle_response(response).await
}
#[cfg_attr(feature = "tracing", instrument(skip(self), fields(method = "DELETE")))]
pub async fn delete_raw(&self, path: &str) -> Result<serde_json::Value> {
let url = format!("{}{}", self.inner.base_url, path);
#[cfg(feature = "tracing")]
debug!("Making DELETE request to {}", path);
let response = self
.inner
.client
.delete(&url)
.header("X-FilesAPI-Key", &self.inner.api_key)
.header("User-Agent", USER_AGENT)
.send()
.await?;
#[cfg(feature = "tracing")]
debug!("DELETE response status: {}", response.status());
self.handle_response(response).await
}
pub async fn post_form<T: Serialize>(&self, path: &str, form: T) -> Result<serde_json::Value> {
let url = format!("{}{}", self.inner.base_url, path);
let response = self
.inner
.client
.post(&url)
.header("X-FilesAPI-Key", &self.inner.api_key)
.header("User-Agent", USER_AGENT)
.form(&form)
.send()
.await?;
self.handle_response(response).await
}
async fn handle_response(&self, response: reqwest::Response) -> Result<serde_json::Value> {
let status = response.status();
if status.is_success() {
if status.as_u16() == 204 {
#[cfg(feature = "tracing")]
debug!("Received 204 No Content response");
return Ok(serde_json::Value::Null);
}
let text = response.text().await?;
let deserializer = &mut serde_json::Deserializer::from_str(&text);
let value: serde_json::Value =
serde_path_to_error::deserialize(deserializer).map_err(|e| {
FilesError::JsonPathError {
path: e.path().to_string(),
source: e.into_inner(),
}
})?;
Ok(value)
} else {
let status_code = status.as_u16();
let error_body = response.text().await.unwrap_or_default();
#[cfg(feature = "tracing")]
warn!(
status_code = status_code,
error_body = %error_body,
"API request failed"
);
let message = if let Ok(json) = serde_json::from_str::<serde_json::Value>(&error_body) {
json.get("error")
.or_else(|| json.get("message"))
.and_then(|v| v.as_str())
.unwrap_or(&error_body)
.to_string()
} else {
error_body
};
let error = match status_code {
400 => FilesError::BadRequest {
message,
field: None,
},
401 => FilesError::AuthenticationFailed {
message,
auth_type: None,
},
403 => FilesError::Forbidden {
message,
resource: None,
},
404 => FilesError::NotFound {
message,
resource_type: None,
path: None,
},
409 => FilesError::Conflict {
message,
resource: None,
},
412 => FilesError::PreconditionFailed {
message,
condition: None,
},
422 => FilesError::UnprocessableEntity {
message,
field: None,
value: None,
},
423 => FilesError::Locked {
message,
resource: None,
},
429 => FilesError::RateLimited {
message,
retry_after: None, },
500 => FilesError::InternalServerError {
message,
request_id: None, },
503 => FilesError::ServiceUnavailable {
message,
retry_after: None, },
_ => FilesError::ApiError {
code: status_code,
message,
endpoint: None,
},
};
#[cfg(feature = "tracing")]
error!(error = ?error, "Returning error to caller");
Err(error)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builder_default() {
let builder = FilesClientBuilder::default();
assert_eq!(
builder.base_url,
"https://app.files.com/api/rest/v1".to_string()
);
assert_eq!(builder.timeout, Duration::from_secs(60));
}
#[test]
fn test_builder_custom() {
let builder = FilesClientBuilder::default()
.api_key("test-key")
.base_url("https://custom.example.com")
.timeout(Duration::from_secs(120));
assert_eq!(builder.api_key, Some("test-key".to_string()));
assert_eq!(builder.base_url, "https://custom.example.com");
assert_eq!(builder.timeout, Duration::from_secs(120));
}
#[test]
fn test_builder_missing_api_key() {
let result = FilesClientBuilder::default().build();
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), FilesError::ConfigError(_)));
}
#[test]
fn test_builder_success() {
let result = FilesClientBuilder::default().api_key("test-key").build();
assert!(result.is_ok());
}
}