Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 146 additions & 43 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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 reqwest::{header::{HeaderMap, HeaderValue}, multipart, Client as HttpClient, Method, Response};
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::collections::HashMap;
Expand Down Expand Up @@ -88,12 +88,18 @@ impl Client {
/// Create a new Appwrite client
pub fn new() -> Self {
let mut headers = HeaderMap::new();
headers.insert("X-Appwrite-Response-Format", "1.9.0".parse().unwrap());
headers.insert("user-agent", format!("AppwriteRustSDK/0.1.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.1.0".parse().unwrap());
headers.insert("X-Appwrite-Response-Format", HeaderValue::from_static("1.9.0"));
// user-agent contains runtime OS/arch info, so it must be built dynamically
headers.insert(
"user-agent",
format!("AppwriteRustSDK/0.1.0 ({}; {})", std::env::consts::OS, std::env::consts::ARCH)
.parse()
.expect("OS and ARCH constants produce valid ASCII"),
);
headers.insert("x-sdk-name", HeaderValue::from_static("Rust"));
headers.insert("x-sdk-platform", HeaderValue::from_static("server"));
headers.insert("x-sdk-language", HeaderValue::from_static("rust"));
headers.insert("x-sdk-version", HeaderValue::from_static("0.1.0"));

let config = Config {
endpoint: "https://cloud.appwrite.io/v1".to_string(),
Expand All @@ -103,8 +109,9 @@ impl Client {
timeout_secs: DEFAULT_TIMEOUT,
};

let http = Self::build_http_client(&config);
let http_no_redirect = Self::build_http_client_no_redirect(&config);
// SAFETY: Default config uses known-valid values, so build cannot fail
let http = Self::build_http_client(&config).unwrap();
let http_no_redirect = Self::build_http_client_no_redirect(&config).unwrap();
Comment on lines +112 to +114
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Client::new() still calls .unwrap() on build_http_client*() results. reqwest::ClientBuilder::build() can fail (e.g., TLS backend/init, system proxy/env issues), so this can still panic at runtime, and the // SAFETY: ... build cannot fail comment is not guaranteed. Consider returning Result<Client> (or adding a try_new() that returns Result) and propagating the error instead of unwrapping.

Copilot uses AI. Check for mistakes.

let state = ClientState { config, http, http_no_redirect };

Expand All @@ -113,17 +120,19 @@ impl Client {
}
}

fn build_http_client(config: &Config) -> HttpClient {
fn build_http_client(config: &Config) -> Result<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")
builder.build().map_err(|e| {
AppwriteError::new(0, format!("Failed to create HTTP client: {}", e), None, String::new())
})
}

fn build_http_client_no_redirect(config: &Config) -> HttpClient {
fn build_http_client_no_redirect(config: &Config) -> Result<HttpClient> {
let mut builder = HttpClient::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(config.timeout_secs));
Expand All @@ -132,90 +141,117 @@ impl Client {
builder = builder.danger_accept_invalid_certs(true);
}

builder.build().expect("Failed to create no-redirect HTTP client")
builder.build().map_err(|e| {
AppwriteError::new(0, format!("Failed to create no-redirect HTTP client: {}", e), None, String::new())
})
}

/// Set the API endpoint
pub fn set_endpoint<S: Into<String>>(&self, endpoint: S) -> Self {
pub fn set_endpoint<S: Into<String>>(&self, endpoint: S) -> Result<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);
return Err(AppwriteError::new(
0,
format!("Invalid endpoint URL: {}. Endpoint must start with http:// or https://", endpoint),
None,
String::new(),
));
}
self.state.rcu(|state| {
let mut next = (**state).clone();
next.config.endpoint = endpoint.clone();
Arc::new(next)
});
self.clone()
Ok(self.clone())
}

/// Set the project ID
pub fn set_project<S: Into<String>>(&self, project: S) -> Self {
pub fn set_project<S: Into<String>>(&self, project: S) -> Result<Self> {
let project = project.into();
let header_value: HeaderValue = project.parse().map_err(|_| {
AppwriteError::new(0, format!("Invalid header value for project: {}", project), None, String::new())
})?;
self.state.rcu(|state| {
let mut next = (**state).clone();
next.config.headers.insert("x-appwrite-project", project.clone().parse().unwrap());
next.config.headers.insert("x-appwrite-project", header_value.clone());
Arc::new(next)
});
self.clone()
Ok(self.clone())
}
Comment on lines 163 to 180
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Validation errors use status code 0, making is_client_error() return false

All validation AppwriteErrors (invalid header value, invalid endpoint scheme) are constructed with code: 0. Because is_client_error() is defined as (400..500).contains(&self.code), these user-input errors will return false from is_client_error(), which is counterintuitive — invalid user input is a client error.

Consider using 400 instead of 0 for validation errors:

return Err(AppwriteError::new(
    400,
    format!("Invalid endpoint URL: {}. Endpoint must start with http:// or https://", endpoint),
    None,
    String::new(),
));

The same applies to all other validation errors in set_project, set_key, set_jwt, set_locale, set_session, and the HTTP client build errors.


/// Set the API key
pub fn set_key<S: Into<String>>(&self, key: S) -> Self {
pub fn set_key<S: Into<String>>(&self, key: S) -> Result<Self> {
let key = key.into();
let header_value: HeaderValue = key.parse().map_err(|_| {
AppwriteError::new(0, format!("Invalid header value for key: {}", key), None, String::new())
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message for an invalid API key includes the full key value. Since API keys are secrets, this risks leaking credentials into logs, crash reports, or user-facing error messages. Prefer a redacted message (e.g., omit the value or include only its length) while still indicating which field was invalid.

Suggested change
AppwriteError::new(0, format!("Invalid header value for key: {}", key), None, String::new())
AppwriteError::new(0, format!("Invalid header value for key (length {} characters)", key.len()), None, String::new())

Copilot uses AI. Check for mistakes.
})?;
self.state.rcu(|state| {
let mut next = (**state).clone();
next.config.headers.insert("x-appwrite-key", key.clone().parse().unwrap());
next.config.headers.insert("x-appwrite-key", header_value.clone());
Arc::new(next)
});
self.clone()
Ok(self.clone())
}

/// Set the JWT token
pub fn set_jwt<S: Into<String>>(&self, jwt: S) -> Self {
pub fn set_jwt<S: Into<String>>(&self, jwt: S) -> Result<Self> {
let jwt = jwt.into();
let header_value: HeaderValue = jwt.parse().map_err(|_| {
AppwriteError::new(0, format!("Invalid header value for JWT: {}", jwt), None, String::new())
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message for an invalid JWT includes the full jwt value. JWTs are credentials and should not be echoed back in errors because they can end up in logs/telemetry. Use a redacted message that does not include the token contents.

Suggested change
AppwriteError::new(0, format!("Invalid header value for JWT: {}", jwt), None, String::new())
AppwriteError::new(0, "Invalid header value for JWT".to_string(), None, String::new())

Copilot uses AI. Check for mistakes.
})?;
self.state.rcu(|state| {
let mut next = (**state).clone();
next.config.headers.insert("x-appwrite-jwt", jwt.clone().parse().unwrap());
next.config.headers.insert("x-appwrite-jwt", header_value.clone());
Arc::new(next)
});
self.clone()
Ok(self.clone())
}

/// Set the locale
pub fn set_locale<S: Into<String>>(&self, locale: S) -> Self {
pub fn set_locale<S: Into<String>>(&self, locale: S) -> Result<Self> {
let locale = locale.into();
let header_value: HeaderValue = locale.parse().map_err(|_| {
AppwriteError::new(0, format!("Invalid header value for locale: {}", locale), None, String::new())
})?;
self.state.rcu(|state| {
let mut next = (**state).clone();
next.config.headers.insert("x-appwrite-locale", locale.clone().parse().unwrap());
next.config.headers.insert("x-appwrite-locale", header_value.clone());
Arc::new(next)
});
self.clone()
Ok(self.clone())
}

/// Set the session
pub fn set_session<S: Into<String>>(&self, session: S) -> Self {
pub fn set_session<S: Into<String>>(&self, session: S) -> Result<Self> {
let session = session.into();
let header_value: HeaderValue = session.parse().map_err(|_| {
AppwriteError::new(0, format!("Invalid header value for session: {}", session), None, String::new())
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message for an invalid session includes the full session value, which is typically sensitive. Avoid including session tokens in error messages; return a redacted message instead.

Suggested change
AppwriteError::new(0, format!("Invalid header value for session: {}", session), None, String::new())
AppwriteError::new(0, "Invalid header value for session".to_string(), None, String::new())

Copilot uses AI. Check for mistakes.
})?;
self.state.rcu(|state| {
let mut next = (**state).clone();
next.config.headers.insert("x-appwrite-session", session.clone().parse().unwrap());
next.config.headers.insert("x-appwrite-session", header_value.clone());
Arc::new(next)
});
self.clone()
Ok(self.clone())
}

/// Enable or disable self-signed certificates
pub fn set_self_signed(&self, self_signed: bool) -> Self {
pub fn set_self_signed(&self, self_signed: bool) -> Result<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);
// If build fails, keep existing clients
if let Ok(http) = Self::build_http_client(&next.config) {
next.http = http;
}
if let Ok(http) = Self::build_http_client_no_redirect(&next.config) {
next.http_no_redirect = http;
}
Comment on lines +239 to +250
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

set_self_signed now returns Result<Self>, but it never returns Err: it updates next.config.self_signed even if rebuilding the HTTP clients fails, silently keeping the old clients. This can leave config and the actual http/http_no_redirect clients out of sync. Consider attempting the rebuild first and returning Err without mutating state if the rebuild fails (or keep the Self return type if failures are intentionally ignored).

Copilot uses AI. Check for mistakes.
}
Arc::new(next)
});
self.clone()
Ok(self.clone())
Comment on lines 240 to +254
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Silent failure leaves config and HTTP client in inconsistent state

In set_self_signed (and the same pattern in set_timeout), when build_http_client fails the code updates next.config.self_signed but keeps the old HTTP client. This means the client's config says self_signed: true while its internal reqwest::Client still rejects self-signed certificates — a silently broken state.

Worse, the method always returns Ok(self.clone()) regardless of whether the build succeeded. The Result<Self> return type is therefore misleading: callers have no way to detect the failure.

A safer approach is to build both clients outside the rcu closure first, propagate any error before touching state, and only commit the new state when both builds succeed:

pub fn set_self_signed(&self, self_signed: bool) -> Result<Self> {
    let current_config = {
        let state = self.state.load();
        state.config.clone()
    };
    if current_config.self_signed == self_signed {
        return Ok(self.clone());
    }
    let mut new_config = current_config;
    new_config.self_signed = self_signed;
    let new_http = Self::build_http_client(&new_config)?;
    let new_http_no_redirect = Self::build_http_client_no_redirect(&new_config)?;
    self.state.rcu(|state| {
        let mut next = (**state).clone();
        next.config.self_signed = self_signed;
        next.http = new_http.clone();
        next.http_no_redirect = new_http_no_redirect.clone();
        Arc::new(next)
    });
    Ok(self.clone())
}

Apply the same pattern to set_timeout.

}

/// Set chunk size for file uploads (minimum 1 byte)
Expand All @@ -229,17 +265,21 @@ impl Client {
}

/// Set request timeout in seconds
pub fn set_timeout(&self, timeout_secs: u64) -> Self {
pub fn set_timeout(&self, timeout_secs: u64) -> Result<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);
if let Ok(http) = Self::build_http_client(&next.config) {
next.http = http;
}
if let Ok(http) = Self::build_http_client_no_redirect(&next.config) {
next.http_no_redirect = http;
}
Comment on lines +268 to +278
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

set_timeout returns Result<Self> but never returns Err, and it updates next.config.timeout_secs even if rebuilding the HTTP clients fails, keeping the old clients. That can desynchronize the stored config from the actual client behavior (timeouts may not change). Consider rebuilding first and returning Err (and not updating state) when the rebuild fails, or otherwise avoid a Result return type if failure is intentionally ignored.

Copilot uses AI. Check for mistakes.
}
Arc::new(next)
});
self.clone()
Ok(self.clone())
}

/// Add a custom header
Expand Down Expand Up @@ -894,11 +934,74 @@ mod tests {

#[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");
let client = Client::new();
let client = client.set_endpoint("https://custom.example.com/v1").unwrap();
let client = client.set_project("test-project").unwrap();
let client = client.set_key("test-key").unwrap();

assert_eq!(client.endpoint(), "https://custom.example.com/v1");
}

#[test]
fn test_set_endpoint_invalid_scheme() {
let client = Client::new();
let result = client.set_endpoint("ftp://example.com");
assert!(result.is_err());
assert!(result.unwrap_err().message.contains("Invalid endpoint URL"));
}

#[test]
fn test_set_project_invalid_header_value() {
let client = Client::new();
let result = client.set_project("invalid\nvalue");
assert!(result.is_err());
assert!(result.unwrap_err().message.contains("Invalid header value"));
}

#[test]
fn test_set_key_invalid_header_value() {
let client = Client::new();
let result = client.set_key("invalid\nkey");
assert!(result.is_err());
}

#[test]
fn test_set_jwt_invalid_header_value() {
let client = Client::new();
let result = client.set_jwt("invalid\njwt");
assert!(result.is_err());
}

#[test]
fn test_set_locale_invalid_header_value() {
let client = Client::new();
let result = client.set_locale("invalid\nlocale");
assert!(result.is_err());
}

#[test]
fn test_set_session_invalid_header_value() {
let client = Client::new();
let result = client.set_session("invalid\nsession");
assert!(result.is_err());
}

#[test]
fn test_set_project_valid_value() {
let client = Client::new();
let result = client.set_project("my-project-123");
assert!(result.is_ok());
}

#[test]
fn test_set_endpoint_valid_http() {
let client = Client::new();
assert!(client.set_endpoint("http://localhost:8080/v1").is_ok());
}

#[test]
fn test_set_endpoint_valid_https() {
let client = Client::new();
assert!(client.set_endpoint("https://cloud.appwrite.io/v1").is_ok());
}
}
6 changes: 3 additions & 3 deletions tests/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let string_in_array = vec!["string in array".to_string()];

let client = Client::new()
.set_endpoint("http://mockapi/v1")
.set_project("appwrite")
.set_key("apikey")
.set_endpoint("http://mockapi/v1")?
.set_project("appwrite")?
.set_key("apikey")?
.add_header("Origin", "http://localhost");

println!("\n\nTest Started");
Expand Down
Loading