Adds basic config api and constants to the library.

This commit is contained in:
2026-08-23 05:45:52 -07:00
parent 737b92469f
commit 3703d4ddf2
8 changed files with 737 additions and 30 deletions

View File

@@ -4,3 +4,9 @@ version = "0.1.0"
edition = "2024"
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "default-tls"] }
tokio = { version = "1", features = ["sync", "fs", "io-util", "macros", "time"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0"
url = { version = "2.5", features = ["serde"] }

View File

@@ -0,0 +1,216 @@
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::config::ClientConfig;
use crate::error::{NextcloudError, Result};
/// Response payload from Nextcloud's `/status.php` endpoint.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerStatus {
pub installed: bool,
pub maintenance: bool,
pub version: String,
#[serde(rename = "versionstring")]
pub version_string: String,
pub edition: String,
#[serde(rename = "productname", default)]
pub product_name: Option<String>,
}
/// The core Nextcloud client instance.
#[derive(Debug, Clone)]
pub struct NextcloudClient {
config: ClientConfig,
http: reqwest::Client,
}
impl NextcloudClient {
/// Create a new `NextcloudClient` from a [`ClientConfig`].
pub fn new(config: ClientConfig) -> Result<Self> {
let mut headers = HeaderMap::new();
// Set User-Agent
headers.insert(
USER_AGENT,
HeaderValue::from_str(&config.user_agent)
.map_err(|e| NextcloudError::Other(e.to_string()))?,
);
// Set Basic Auth header if credentials are provided
if let Some(ref creds) = config.credentials {
use reqwest::header::HeaderValue;
let raw_auth = format!("{}:{}", creds.username, creds.app_password);
let encoded = base64_encode(raw_auth.as_bytes());
let mut auth_val = HeaderValue::from_str(&format!("Basic {encoded}"))
.map_err(|e| NextcloudError::Other(e.to_string()))?;
auth_val.set_sensitive(true);
headers.insert(AUTHORIZATION, auth_val);
}
// Standard Nextcloud OCS headers for JSON responses
headers.insert("OCS-APIRequest", HeaderValue::from_static("true"));
let http = reqwest::Client::builder()
.default_headers(headers)
.timeout(config.timeout)
.build()?;
Ok(Self { config, http })
}
/// Reference to the underlying client configuration.
pub fn config(&self) -> &ClientConfig {
&self.config
}
/// Access the underlying `reqwest::Client` for raw HTTP operations.
pub fn http(&self) -> &reqwest::Client {
&self.http
}
/// Nextcloud username configured for this client (if any).
pub fn username(&self) -> Option<&str> {
self.config.credentials.as_ref().map(|c| c.username.as_str())
}
/// Build the full WebDAV URL for a given remote file path.
///
/// Nextcloud WebDAV endpoint format:
/// `https://<host>/remote.php/dav/files/<username>/<path>`
pub fn webdav_url(&self, remote_path: &str) -> Result<Url> {
let username = self.username().ok_or_else(|| {
NextcloudError::Other("Cannot generate WebDAV URL without username credentials".into())
})?;
let clean_path = remote_path.trim_start_matches('/');
let path_segment = format!("remote.php/dav/files/{username}/{clean_path}");
self.config
.server_url
.join(&path_segment)
.map_err(NextcloudError::from)
}
/// Build the OCS sharing API URL.
///
/// Nextcloud OCS sharing endpoint:
/// `https://<host>/ocs/v2.php/apps/files_sharing/api/v1/shares`
pub fn ocs_shares_url(&self) -> Result<Url> {
self.config
.server_url
.join("ocs/v2.php/apps/files_sharing/api/v1/shares?format=json")
.map_err(NextcloudError::from)
}
/// Build the public download URL from a share token.
///
/// Format: `https://<host>/index.php/s/<token>/download`
pub fn direct_download_url(&self, share_token: &str) -> Result<Url> {
let path_segment = format!("index.php/s/{share_token}/download");
self.config
.server_url
.join(&path_segment)
.map_err(NextcloudError::from)
}
/// Check server reachability and retrieve server version info from `/status.php`.
///
/// This endpoint does not require authentication.
pub async fn check_status(&self) -> Result<ServerStatus> {
let status_url = self.config.server_url.join("status.php")?;
let response = self
.http
.get(status_url)
.send()
.await?
.error_for_status()?;
let status = response.json::<ServerStatus>().await?;
Ok(status)
}
}
/// Simple Base64 encoder helper avoiding extra external crate dependencies.
fn base64_encode(data: &[u8]) -> String {
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
for chunk in data.chunks(3) {
let b0 = chunk[0];
let b1 = chunk.get(1).copied().unwrap_or(0);
let b2 = chunk.get(2).copied().unwrap_or(0);
out.push(CHARSET[(b0 >> 2) as usize] as char);
out.push(CHARSET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
if chunk.len() > 1 {
out.push(CHARSET[(((b1 & 0x0F) << 2) | (b2 >> 6)) as usize] as char);
} else {
out.push('=');
}
if chunk.len() > 2 {
out.push(CHARSET[(b2 & 0x3F) as usize] as char);
} else {
out.push('=');
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AccountCredentials;
#[test]
fn test_webdav_url_builder() {
let config = ClientConfig::new(
"https://nextcloud.example.com",
Some(AccountCredentials::new("bob", "app-pass")),
)
.unwrap();
let client = NextcloudClient::new(config).unwrap();
let url = client.webdav_url("Documents/test.pdf").unwrap();
assert_eq!(
url.as_str(),
"https://nextcloud.example.com/remote.php/dav/files/bob/Documents/test.pdf"
);
}
#[test]
fn test_direct_download_url() {
let config = ClientConfig::new("https://nextcloud.example.com", None).unwrap();
let client = NextcloudClient::new(config).unwrap();
let download_url = client.direct_download_url("AbCdEf12345").unwrap();
assert_eq!(
download_url.as_str(),
"https://nextcloud.example.com/index.php/s/AbCdEf12345/download"
);
}
#[test]
fn test_status_deserialization() {
let sample_json = r#"{
"installed": true,
"maintenance": false,
"version": "28.0.2.5",
"versionstring": "28.0.2",
"edition": "Community",
"productname": "Nextcloud"
}"#;
let status: ServerStatus = serde_json::from_str(sample_json).unwrap();
assert_eq!(status.version_string, "28.0.2");
assert_eq!(status.product_name.as_deref(), Some("Nextcloud"));
}
}

View File

@@ -0,0 +1,135 @@
use serde::{Deserialize, Serialize};
use std::time::Duration;
use url::Url;
use crate::error::{NextcloudError, Result};
/// Default request timeout for API calls (excluding long streaming uploads).
pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
/// Default User-Agent header string.
pub const DEFAULT_USER_AGENT: &str = "NextcloudUploadTool/0.1.0 (Rust)";
/// Credentials used to authenticate against a Nextcloud instance.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountCredentials {
pub username: String,
pub app_password: String,
}
impl AccountCredentials {
pub fn new(username: impl Into<String>, app_password: impl Into<String>) -> Self {
Self {
username: username.into(),
app_password: app_password.into(),
}
}
}
/// Configuration settings for connecting to a Nextcloud server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientConfig {
/// Base URL of the Nextcloud instance (e.g. `https://cloud.example.com`).
pub server_url: Url,
/// User authentication credentials.
pub credentials: Option<AccountCredentials>,
/// Request timeout for standard API calls.
#[serde(with = "humantime_serde", default = "default_timeout")]
pub timeout: Duration,
/// Custom User-Agent header.
pub user_agent: String,
}
fn default_timeout() -> Duration {
Duration::from_secs(DEFAULT_TIMEOUT_SECS)
}
// Simple serde helper for duration if humantime_serde is not pulled in
mod humantime_serde {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S>(duration: &Duration, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_u64(duration.as_secs())
}
pub fn deserialize<'de, D>(deserializer: D) -> std::result::Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let secs = u64::deserialize(deserializer)?;
Ok(Duration::from_secs(secs))
}
}
impl ClientConfig {
/// Create a new `ClientConfig` by parsing a URL string and optional credentials.
pub fn new(
server_url_str: &str,
credentials: Option<AccountCredentials>,
) -> Result<Self> {
let mut server_url = Url::parse(server_url_str)?;
// Ensure scheme is http or https
if server_url.scheme() != "http" && server_url.scheme() != "https" {
return Err(NextcloudError::Other(format!(
"Unsupported URL scheme '{}'. Must be http or https.",
server_url.scheme()
)));
}
// Ensure the path has a trailing slash for reliable path joining
if !server_url.path().ends_with('/') {
let new_path = format!("{}/", server_url.path());
server_url.set_path(&new_path);
}
Ok(Self {
server_url,
credentials,
timeout: default_timeout(),
user_agent: DEFAULT_USER_AGENT.to_string(),
})
}
/// Set a custom request timeout.
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
/// Set a custom User-Agent.
pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = user_agent.into();
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_config_creation() {
let config = ClientConfig::new(
"https://cloud.example.com",
Some(AccountCredentials::new("alice", "secret123")),
)
.expect("Valid URL should parse");
assert_eq!(config.server_url.as_str(), "https://cloud.example.com/");
assert_eq!(config.credentials.as_ref().unwrap().username, "alice");
}
#[test]
fn test_invalid_scheme() {
let result = ClientConfig::new("ftp://cloud.example.com", None);
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,38 @@
use thiserror::Error;
/// Core error type for Nextcloud client operations.
#[derive(Error, Debug)]
pub enum NextcloudError {
#[error("Invalid URL: {0}")]
InvalidUrl(#[from] url::ParseError),
#[error("HTTP request error: {0}")]
Http(#[from] reqwest::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Authentication failed for user '{username}': {message}")]
AuthenticationFailed { username: String, message: String },
#[error("Resource not found at '{path}'")]
NotFound { path: String },
#[error("Server error ({status}): {message}")]
ServerError { status: u16, message: String },
#[error("Nextcloud OCS API error (code {status_code}): {message}")]
OcsApiError {
status_code: i32,
message: String,
},
#[error("Invalid path '{path}': {reason}")]
InvalidPath { path: String, reason: String },
#[error("Unexpected error: {0}")]
Other(String),
}
/// Convenience alias for `Result<T, NextcloudError>`.
pub type Result<T> = std::result::Result<T, NextcloudError>;

View File

@@ -1,14 +1,15 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
//! # Nextcloud Client Library
//!
//! A high-performance, asynchronous Rust library for interacting with Nextcloud's
//! WebDAV file transfer APIs, OCS Sharing API, and credential storage.
#[cfg(test)]
mod tests {
use super::*;
pub mod client;
pub mod config;
pub mod error;
pub mod models;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
// Convenient top-level re-exports
pub use client::{NextcloudClient, ServerStatus};
pub use config::{AccountCredentials, ClientConfig};
pub use error::{NextcloudError, Result};
pub use models::{UploadOptions, UploadResult};

View File

@@ -0,0 +1,64 @@
use serde::{Deserialize, Serialize};
/// High-level options for uploading a file to Nextcloud.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UploadOptions {
/// Remote directory or full path on Nextcloud (e.g. `"Uploads/"` or `"Photos/sample.png"`).
pub remote_path: String,
/// Whether to automatically create a public share link after upload.
pub create_share: bool,
/// Optional password for the generated public share link.
pub share_password: Option<String>,
/// Whether to overwrite existing files at the target path.
pub overwrite: bool,
}
impl Default for UploadOptions {
fn default() -> Self {
Self {
remote_path: String::new(),
create_share: false,
share_password: None,
overwrite: true,
}
}
}
impl UploadOptions {
pub fn new(remote_path: impl Into<String>) -> Self {
Self {
remote_path: remote_path.into(),
..Default::default()
}
}
pub fn with_share(mut self, create_share: bool) -> Self {
self.create_share = create_share;
self
}
pub fn with_share_password(mut self, password: impl Into<String>) -> Self {
self.share_password = Some(password.into());
self.create_share = true;
self
}
}
/// Result returned from a successful file upload operation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UploadResult {
/// The final remote path of the uploaded file on the Nextcloud instance.
pub remote_path: String,
/// Total number of bytes transferred.
pub bytes_uploaded: u64,
/// Public share URL if a share link was requested (e.g. `https://cloud.example.com/s/XyZ123`).
pub share_url: Option<String>,
/// Direct download link if a share link was requested (e.g. `https://cloud.example.com/index.php/s/XyZ123/download`).
pub direct_download_url: Option<String>,
}