Adds basic config api and constants to the library.
This commit is contained in:
216
nextcloud_client/src/client.rs
Normal file
216
nextcloud_client/src/client.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
135
nextcloud_client/src/config.rs
Normal file
135
nextcloud_client/src/config.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
38
nextcloud_client/src/error.rs
Normal file
38
nextcloud_client/src/error.rs
Normal 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>;
|
||||
@@ -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};
|
||||
|
||||
64
nextcloud_client/src/models.rs
Normal file
64
nextcloud_client/src/models.rs
Normal 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>,
|
||||
}
|
||||
Reference in New Issue
Block a user