use crate::error::AppwriteError;
use crate::error::Result;
use crate::input_file::InputFile;
use arc_swap::ArcSwap;
use reqwest::{header::HeaderMap, multipart, Client as HttpClient, Method, Response};
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use url::Url;
const DEFAULT_TIMEOUT: u64 = 10;
const DEFAULT_CHUNK_SIZE: usize = 5 * 1024 * 1024;
#[derive(Debug, Clone, Copy)]
pub struct UploadProgress {
pub bytes_uploaded: u64,
pub total_bytes: u64,
pub chunks_uploaded: u64,
pub total_chunks: u64,
}
impl UploadProgress {
pub fn percentage(&self) -> f64 {
if self.total_bytes == 0 {
return 0.0;
}
(self.bytes_uploaded as f64 / self.total_bytes as f64) * 100.0
}
}
pub struct UploadOptions<F> {
pub upload_id: Option<String>,
pub on_progress: Option<F>,
}
impl<F> Default for UploadOptions<F> {
fn default() -> Self {
Self {
upload_id: None,
on_progress: None,
}
}
}
#[derive(Debug, Clone)]
pub struct Client {
state: Arc<ArcSwap<ClientState>>,
}
#[derive(Debug, Clone)]
struct ClientState {
config: Config,
http: HttpClient,
http_no_redirect: HttpClient,
}
#[derive(Debug, Clone)]
struct Config {
endpoint: String,
headers: HeaderMap,
self_signed: bool,
chunk_size: usize,
timeout_secs: u64,
}
impl Default for Client {
fn default() -> Self {
Self::new()
}
}
impl Client {
pub fn new() -> Self {
let mut headers = HeaderMap::new();
headers.insert("X-Appwrite-Response-Format", "1.9.1".parse().unwrap());
headers.insert("user-agent", format!("AppwriteRustSDK/0.3.0 ({}; {})", std::env::consts::OS, std::env::consts::ARCH).parse().unwrap());
headers.insert("x-sdk-name", "Rust".parse().unwrap());
headers.insert("x-sdk-platform", "server".parse().unwrap());
headers.insert("x-sdk-language", "rust".parse().unwrap());
headers.insert("x-sdk-version", "0.3.0".parse().unwrap());
let config = Config {
endpoint: "https://cloud.appwrite.io/v1".to_string(),
headers,
self_signed: false,
chunk_size: DEFAULT_CHUNK_SIZE,
timeout_secs: DEFAULT_TIMEOUT,
};
let http = Self::build_http_client(&config);
let http_no_redirect = Self::build_http_client_no_redirect(&config);
let state = ClientState { config, http, http_no_redirect };
Self {
state: Arc::new(ArcSwap::from_pointee(state)),
}
}
fn build_http_client(config: &Config) -> HttpClient {
let mut builder = HttpClient::builder().timeout(Duration::from_secs(config.timeout_secs));
if config.self_signed {
builder = builder.danger_accept_invalid_certs(true);
}
builder.build().expect("Failed to create HTTP client")
}
fn build_http_client_no_redirect(config: &Config) -> HttpClient {
let mut builder = HttpClient::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(config.timeout_secs));
if config.self_signed {
builder = builder.danger_accept_invalid_certs(true);
}
builder.build().expect("Failed to create no-redirect HTTP client")
}
pub fn set_endpoint<S: Into<String>>(&self, endpoint: S) -> Self {
let endpoint = endpoint.into();
if !endpoint.starts_with("http://") && !endpoint.starts_with("https://") {
panic!("Invalid endpoint URL: {}. Endpoint must start with http:// or https://", endpoint);
}
self.state.rcu(|state| {
let mut next = (**state).clone();
next.config.endpoint = endpoint.clone();
Arc::new(next)
});
self.clone()
}
pub fn set_project<S: Into<String>>(&self, project: S) -> Self {
let project = project.into();
self.state.rcu(|state| {
let mut next = (**state).clone();
next.config.headers.insert("x-appwrite-project", project.clone().parse().unwrap());
Arc::new(next)
});
self.clone()
}
pub fn set_key<S: Into<String>>(&self, key: S) -> Self {
let key = key.into();
self.state.rcu(|state| {
let mut next = (**state).clone();
next.config.headers.insert("x-appwrite-key", key.clone().parse().unwrap());
Arc::new(next)
});
self.clone()
}
pub fn set_jwt<S: Into<String>>(&self, jwt: S) -> Self {
let jwt = jwt.into();
self.state.rcu(|state| {
let mut next = (**state).clone();
next.config.headers.insert("x-appwrite-jwt", jwt.clone().parse().unwrap());
Arc::new(next)
});
self.clone()
}
pub fn set_locale<S: Into<String>>(&self, locale: S) -> Self {
let locale = locale.into();
self.state.rcu(|state| {
let mut next = (**state).clone();
next.config.headers.insert("x-appwrite-locale", locale.clone().parse().unwrap());
Arc::new(next)
});
self.clone()
}
pub fn set_session<S: Into<String>>(&self, session: S) -> Self {
let session = session.into();
self.state.rcu(|state| {
let mut next = (**state).clone();
next.config.headers.insert("x-appwrite-session", session.clone().parse().unwrap());
Arc::new(next)
});
self.clone()
}
pub fn set_self_signed(&self, self_signed: bool) -> Self {
self.state.rcu(|state| {
let mut next = (**state).clone();
if next.config.self_signed != self_signed {
next.config.self_signed = self_signed;
next.http = Self::build_http_client(&next.config);
next.http_no_redirect = Self::build_http_client_no_redirect(&next.config);
}
Arc::new(next)
});
self.clone()
}
pub fn set_chunk_size(&self, chunk_size: usize) -> Self {
self.state.rcu(|state| {
let mut next = (**state).clone();
next.config.chunk_size = chunk_size.max(1);
Arc::new(next)
});
self.clone()
}
pub fn set_timeout(&self, timeout_secs: u64) -> Self {
self.state.rcu(|state| {
let mut next = (**state).clone();
if next.config.timeout_secs != timeout_secs {
next.config.timeout_secs = timeout_secs;
next.http = Self::build_http_client(&next.config);
next.http_no_redirect = Self::build_http_client_no_redirect(&next.config);
}
Arc::new(next)
});
self.clone()
}
pub fn add_header<K: AsRef<str>, V: AsRef<str>>(&self, key: K, value: V) -> Self {
use reqwest::header::{HeaderName, HeaderValue};
let key = key.as_ref().to_string();
let value = value.as_ref().to_string();
self.state.rcu(|state| {
let mut next = (**state).clone();
if let (Ok(header_name), Ok(header_value)) = (
key.parse::<HeaderName>(),
value.parse::<HeaderValue>(),
) {
next.config.headers.insert(header_name, header_value);
}
Arc::new(next)
});
self.clone()
}
pub fn get_headers(&self) -> HashMap<String, String> {
let state = self.state.load();
state
.config
.headers
.iter()
.filter_map(|(key, value)| match value.to_str() {
Ok(value) => Some((key.as_str().to_string(), value.to_string())),
Err(_) => {
debug_assert!(
false,
"Non-UTF-8 header value found for key {}",
key.as_str()
);
None
}
})
.collect()
}
pub fn endpoint(&self) -> String {
let state = self.state.load();
state.config.endpoint.clone()
}
fn serialize_param_value(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => String::new(),
_ => value.to_string(),
}
}
fn flatten_query_params(params: &HashMap<String, Value>) -> Vec<(String, String)> {
let mut result = Vec::new();
for (key, value) in params {
match value {
Value::Array(arr) => {
for item in arr {
match item {
Value::String(s) => result.push((format!("{}[]", key), s.clone())),
Value::Number(n) => result.push((format!("{}[]", key), n.to_string())),
Value::Bool(b) => result.push((format!("{}[]", key), b.to_string())),
Value::Null => result.push((format!("{}[]", key), String::new())),
_ => {
result.push((format!("{}[]", key), serde_json::to_string(item).unwrap_or_default()));
}
}
}
}
Value::Object(_) => {
result.push((key.clone(), serde_json::to_string(value).unwrap_or_default()));
}
_ => {
result.push((key.clone(), Self::serialize_param_value(value)));
}
}
}
result
}
fn flatten_multipart_params(params: &HashMap<String, Value>, prefix: &str) -> Vec<(String, String)> {
let mut result = Vec::new();
for (key, value) in params {
let final_key = if prefix.is_empty() {
key.clone()
} else {
format!("{}[{}]", prefix, key)
};
match value {
Value::Array(arr) => {
for (i, item) in arr.iter().enumerate() {
let array_key = format!("{}[{}]", final_key, i);
if item.is_object() || item.is_array() {
let mut nested = HashMap::new();
nested.insert(String::new(), item.clone());
let flattened = Self::flatten_multipart_params(&nested, &array_key);
result.extend(flattened);
} else {
result.push((array_key, Self::serialize_param_value(item)));
}
}
}
Value::Object(obj) => {
let mut nested_map = HashMap::new();
for (nested_key, nested_value) in obj {
nested_map.insert(nested_key.clone(), nested_value.clone());
}
let flattened = Self::flatten_multipart_params(&nested_map, &final_key);
result.extend(flattened);
}
_ => {
result.push((final_key, Self::serialize_param_value(value)));
}
}
}
result
}
pub async fn call<T: DeserializeOwned>(
&self,
method: Method,
path: &str,
headers: Option<HashMap<String, String>>,
params: Option<HashMap<String, Value>>,
) -> Result<T> {
let state = self.state.load_full();
let url = format!("{}{}", state.config.endpoint, path);
let mut request_builder;
if let Some(params) = params {
if method == Method::GET {
let mut url_with_params = Url::parse(&url).map_err(|e| AppwriteError::new(0, format!("Invalid URL: {}", e), None, String::new()))?;
{
let mut query_pairs = url_with_params.query_pairs_mut();
for (key, value) in Self::flatten_query_params(¶ms) {
query_pairs.append_pair(&key, &value);
}
}
request_builder = state.http.request(method.clone(), url_with_params);
} else {
request_builder = state.http.request(method.clone(), &url);
request_builder = request_builder.json(¶ms);
}
} else {
request_builder = state.http.request(method.clone(), &url);
}
request_builder = request_builder.headers(state.config.headers.clone());
if let Some(custom_headers) = headers {
for (key, value) in custom_headers {
request_builder = request_builder.header(key, value);
}
}
let response = request_builder.send().await?;
self.handle_response(response).await
}
pub async fn call_location(
&self,
method: Method,
path: &str,
headers: Option<HashMap<String, String>>,
params: Option<HashMap<String, Value>>,
) -> Result<String> {
let state = self.state.load_full();
let url = format!("{}{}", state.config.endpoint, path);
let mut request_builder = state.http_no_redirect.request(method.clone(), &url);
request_builder = request_builder.headers(state.config.headers.clone());
if let Some(ref custom_headers) = headers {
for (key, value) in custom_headers {
request_builder = request_builder.header(key, value);
}
}
if let Some(params) = params {
if method == Method::GET {
let mut url_with_params = Url::parse(&url).map_err(|e| AppwriteError::new(0, format!("Invalid URL: {}", e), None, String::new()))?;
{
let mut query_pairs = url_with_params.query_pairs_mut();
for (key, value) in Self::flatten_query_params(¶ms) {
query_pairs.append_pair(&key, &value);
}
}
request_builder = state.http_no_redirect.request(method, url_with_params);
request_builder = request_builder.headers(state.config.headers.clone());
if let Some(ref custom_headers) = headers {
for (key, value) in custom_headers {
request_builder = request_builder.header(key, value);
}
}
} else {
request_builder = request_builder.json(¶ms);
}
}
let response = request_builder.send().await?;
let status = response.status();
if status.is_redirection() {
response
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| AppwriteError::new(
status.as_u16(),
"Location header not found in redirect response",
None,
String::new(),
))
} else {
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let error_text = response.text().await?;
if content_type.starts_with("application/json") {
if let Ok(error_json) = serde_json::from_str::<Value>(&error_text) {
let message = error_json
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
let error_type = error_json
.get("type")
.and_then(|v| v.as_str())
.map(|v| v.to_string());
return Err(AppwriteError::new(
status.as_u16(),
message,
error_type,
error_text,
));
}
}
Err(AppwriteError::new(
status.as_u16(),
error_text.clone(),
None,
error_text,
))
}
}
pub async fn call_bytes(
&self,
method: Method,
path: &str,
headers: Option<HashMap<String, String>>,
params: Option<HashMap<String, Value>>,
) -> Result<Vec<u8>> {
let state = self.state.load_full();
let url = format!("{}{}", state.config.endpoint, path);
let mut request_builder;
if let Some(params) = params {
if method == Method::GET {
let mut url_with_params = Url::parse(&url).map_err(|e| AppwriteError::new(0, format!("Invalid URL: {}", e), None, String::new()))?;
{
let mut query_pairs = url_with_params.query_pairs_mut();
for (key, value) in Self::flatten_query_params(¶ms) {
query_pairs.append_pair(&key, &value);
}
}
request_builder = state.http.request(method.clone(), url_with_params);
} else {
request_builder = state.http.request(method.clone(), &url);
request_builder = request_builder.json(¶ms);
}
} else {
request_builder = state.http.request(method.clone(), &url);
}
request_builder = request_builder.headers(state.config.headers.clone());
if let Some(custom_headers) = headers {
for (key, value) in custom_headers {
request_builder = request_builder.header(key, value);
}
}
let response = request_builder.send().await?;
let status = response.status();
if status.is_success() {
let bytes = response.bytes().await?;
Ok(bytes.to_vec())
} else {
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let error_text = response.text().await?;
if content_type.starts_with("application/json") {
if let Ok(error_json) = serde_json::from_str::<Value>(&error_text) {
let message = error_json
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
let error_type = error_json
.get("type")
.and_then(|v| v.as_str())
.map(|v| v.to_string());
return Err(AppwriteError::new(
status.as_u16(),
message,
error_type,
error_text,
));
}
}
Err(AppwriteError::new(
status.as_u16(),
error_text.clone(),
None,
error_text,
))
}
}
pub async fn file_upload<T: DeserializeOwned>(
&self,
path: &str,
headers: Option<HashMap<String, String>>,
params: HashMap<String, Value>,
param_name: &str,
input_file: InputFile,
upload_id: Option<String>,
) -> Result<T> {
self.file_upload_with_progress(
path,
headers,
params,
param_name,
input_file,
UploadOptions {
upload_id,
on_progress: None::<fn(UploadProgress)>,
},
)
.await
}
pub async fn file_upload_with_progress<T: DeserializeOwned, F>(
&self,
path: &str,
headers: Option<HashMap<String, String>>,
params: HashMap<String, Value>,
param_name: &str,
input_file: InputFile,
options: UploadOptions<F>,
) -> Result<T>
where
F: Fn(UploadProgress),
{
let file_size = input_file.size().await?;
let chunk_size = {
let state = self.state.load();
state.config.chunk_size.max(1)
};
if file_size <= chunk_size as u64 {
let state = self.state.load_full();
let url = format!("{}{}", state.config.endpoint, path);
let result = self.single_file_upload(&url, headers, params, param_name, &input_file).await?;
if let Some(callback) = &options.on_progress {
callback(UploadProgress {
bytes_uploaded: file_size,
total_bytes: file_size,
chunks_uploaded: 1,
total_chunks: 1,
});
}
return Ok(result);
}
self.chunked_file_upload_with_progress(path, headers, params, param_name, &input_file, options).await
}
async fn single_file_upload<T: DeserializeOwned>(
&self,
url: &str,
headers: Option<HashMap<String, String>>,
params: HashMap<String, Value>,
param_name: &str,
input_file: &InputFile,
) -> Result<T> {
let state = self.state.load_full();
let mut form = multipart::Form::new();
let file_data = input_file.read_all().await?;
let file_size = file_data.len() as u64;
let mut file_part = multipart::Part::stream_with_length(file_data, file_size)
.file_name(input_file.filename().to_string());
if let Some(mime_type) = input_file.mime_type() {
file_part = file_part.mime_str(mime_type)
.map_err(|e| AppwriteError::new(0, format!("Invalid MIME type: {}", e), None, String::new()))?;
}
form = form.part(param_name.to_string(), file_part);
let mut params_to_flatten = HashMap::new();
for (key, value) in params {
if key != param_name {
params_to_flatten.insert(key, value);
}
}
for (key, value_str) in Self::flatten_multipart_params(¶ms_to_flatten, "") {
form = form.text(key, value_str);
}
let mut request_builder = state.http.post(url).headers(state.config.headers.clone());
if let Some(custom_headers) = headers {
for (key, value) in custom_headers {
if key.to_lowercase() != "content-type" {
request_builder = request_builder.header(key, value);
}
}
}
let response = request_builder
.multipart(form)
.send()
.await
?;
self.handle_response(response).await
}
async fn chunked_file_upload_with_progress<T: DeserializeOwned, F>(
&self,
path: &str,
headers: Option<HashMap<String, String>>,
params: HashMap<String, Value>,
param_name: &str,
input_file: &InputFile,
options: UploadOptions<F>,
) -> Result<T>
where
F: Fn(UploadProgress),
{
let file_size = input_file.size().await?;
let chunk_size = {
let state = self.state.load();
state.config.chunk_size.max(1)
};
let total_chunks = file_size.div_ceil(chunk_size as u64);
let mut current_upload_id = options.upload_id;
let mut start_chunk = 0u64;
if let Some(ref id) = current_upload_id {
if let Ok(response) = self.call::<Value>(
Method::GET,
&format!("{}/{}", path, id),
None,
None,
).await {
if let Some(chunks_uploaded) = response.get("chunksUploaded").and_then(|v| v.as_u64()) {
start_chunk = chunks_uploaded;
}
}
}
let mut reader = input_file.chunked_reader().await?;
if start_chunk > 0 {
let resume_offset = start_chunk * chunk_size as u64;
reader.seek(resume_offset).await?;
}
let mut last_response = None;
for chunk_index in start_chunk..total_chunks {
let chunk_data = match reader.read_next(chunk_size).await? {
Some(data) => data,
None => break,
};
let actual_chunk_size = chunk_data.len();
let start = reader.position() - actual_chunk_size as u64;
if actual_chunk_size == 0 {
break;
}
let state = self.state.load_full();
let mut form = multipart::Form::new();
let mut file_part = multipart::Part::stream_with_length(chunk_data, actual_chunk_size as u64)
.file_name(input_file.filename().to_string());
if let Some(mime_type) = input_file.mime_type() {
file_part = file_part.mime_str(mime_type)
.map_err(|e| AppwriteError::new(0, format!("Invalid MIME type: {}", e), None, String::new()))?;
}
form = form.part(param_name.to_string(), file_part);
let mut params_to_flatten = HashMap::new();
for (key, value) in ¶ms {
if key != param_name {
params_to_flatten.insert(key.clone(), value.clone());
}
}
for (key, value_str) in Self::flatten_multipart_params(¶ms_to_flatten, "") {
form = form.text(key, value_str);
}
let url = format!("{}{}", state.config.endpoint, path);
let mut request_builder = state.http.post(url).headers(state.config.headers.clone());
if let Some(ref custom_headers) = headers {
for (key, value) in custom_headers {
if key.to_lowercase() != "content-type" {
request_builder = request_builder.header(key, value);
}
}
}
if let Some(ref id) = current_upload_id {
request_builder = request_builder.header("x-appwrite-id", id);
}
let chunk_end = start + actual_chunk_size as u64 - 1;
let content_range = format!("bytes {}-{}/{}", start, chunk_end, file_size);
request_builder = request_builder.header("content-range", content_range);
let response = request_builder
.multipart(form)
.send()
.await
?;
let result: Value = self.handle_response(response).await?;
if current_upload_id.is_none() {
if let Some(id) = result.get("$id").and_then(|v| v.as_str()) {
current_upload_id = Some(id.to_string());
}
}
last_response = Some(result);
if let Some(ref callback) = options.on_progress {
callback(UploadProgress {
bytes_uploaded: start + actual_chunk_size as u64,
total_bytes: file_size,
chunks_uploaded: chunk_index + 1,
total_chunks,
});
}
}
last_response
.ok_or_else(|| AppwriteError::new(0, "No chunks uploaded", None, String::new()))
.and_then(|v| serde_json::from_value(v).map_err(Into::into))
}
async fn handle_response<T: DeserializeOwned>(&self, response: Response) -> Result<T> {
let status = response.status();
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.unwrap_or_default();
let content_type = content_type.as_str();
if status.is_success() {
let bytes = response.bytes().await?;
if !content_type.is_empty() && !content_type.starts_with("application/json") && !bytes.is_empty() {
return Err(AppwriteError::new(
status.as_u16(),
format!("Expected JSON response but received content-type: {}", content_type),
None,
String::from_utf8_lossy(&bytes).to_string(),
));
}
Ok(if bytes.is_empty() {
serde_json::from_slice(b"null")?
} else {
serde_json::from_slice(&bytes)?
})
} else {
let error_text = response.text().await?;
if content_type.starts_with("application/json") {
if let Ok(error_json) = serde_json::from_str::<Value>(&error_text) {
let message = error_json
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
let error_type = error_json
.get("type")
.and_then(|v| v.as_str())
.map(|v| v.to_string());
return Err(AppwriteError::new(
status.as_u16(),
message,
error_type,
error_text,
));
}
}
Err(AppwriteError::new(
status.as_u16(),
error_text.clone(),
None,
error_text,
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = Client::new();
assert_eq!(client.endpoint(), "https://cloud.appwrite.io/v1");
}
#[test]
fn test_client_builder_pattern() {
let client = Client::new()
.set_endpoint("https://custom.example.com/v1")
.set_project("test-project")
.set_key("test-key");
assert_eq!(client.endpoint(), "https://custom.example.com/v1");
}
}