350 lines
8.6 KiB
Rust
350 lines
8.6 KiB
Rust
use crate::scgi::{send_request, ScgiError, ScgiRequest};
|
|
use quick_xml::de::from_str;
|
|
use quick_xml::se::to_string;
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
|
|
#[derive(Error, Debug)]
|
|
pub enum XmlRpcError {
|
|
#[error("SCGI Error: {0}")]
|
|
Scgi(#[from] ScgiError),
|
|
#[error("Serialization Error: {0}")]
|
|
Serialization(String),
|
|
#[error("Deserialization Error: {0}")]
|
|
Deserialization(#[from] quick_xml::de::DeError),
|
|
#[error("XML Parse Error: {0}")]
|
|
Parse(String),
|
|
}
|
|
|
|
// --- Request Parameters Enum ---
|
|
#[derive(Debug, Clone)]
|
|
pub enum RpcParam {
|
|
String(String),
|
|
Int(i64),
|
|
}
|
|
|
|
impl From<&str> for RpcParam {
|
|
fn from(s: &str) -> Self {
|
|
RpcParam::String(s.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<String> for RpcParam {
|
|
fn from(s: String) -> Self {
|
|
RpcParam::String(s)
|
|
}
|
|
}
|
|
|
|
impl From<i64> for RpcParam {
|
|
fn from(i: i64) -> Self {
|
|
RpcParam::Int(i)
|
|
}
|
|
}
|
|
|
|
impl From<i32> for RpcParam {
|
|
fn from(i: i32) -> Self {
|
|
RpcParam::Int(i as i64)
|
|
}
|
|
}
|
|
|
|
// --- Request Models ---
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename = "methodCall")]
|
|
struct MethodCall<'a> {
|
|
#[serde(rename = "methodName")]
|
|
method_name: &'a str,
|
|
params: RequestParams<'a>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct RequestParams<'a> {
|
|
param: Vec<RequestParam<'a>>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct RequestParam<'a> {
|
|
value: RequestValueInner<'a>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct RequestValueInner<'a> {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
string: Option<&'a str>,
|
|
// rTorrent standard is often i4 (32-bit signed int). i8 might not be supported by all XML-RPC libs.
|
|
// Casting i64 to i32 is safe for typical speed limits.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
i4: Option<i32>,
|
|
}
|
|
|
|
// --- Response Models for d.multicall2 ---
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename = "methodResponse")]
|
|
struct MulticallResponse {
|
|
params: MulticallResponseParams,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct MulticallResponseParams {
|
|
param: MulticallResponseParam,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct MulticallResponseParam {
|
|
value: MulticallResponseValueArray,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct MulticallResponseValueArray {
|
|
array: MulticallResponseDataOuter,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct MulticallResponseDataOuter {
|
|
data: MulticallResponseDataOuterValue,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct MulticallResponseDataOuterValue {
|
|
#[serde(rename = "value", default)]
|
|
values: Vec<MulticallRowValue>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct MulticallRowValue {
|
|
array: MulticallResponseDataInner,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct MulticallResponseDataInner {
|
|
data: MulticallResponseDataInnerValue,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct MulticallResponseDataInnerValue {
|
|
#[serde(rename = "value", default)]
|
|
values: Vec<MulticallItemValue>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct MulticallItemValue {
|
|
#[serde(rename = "string", default)]
|
|
string: Option<String>,
|
|
#[serde(rename = "i4", default)]
|
|
i4: Option<i64>,
|
|
#[serde(rename = "i8", default)]
|
|
i8: Option<i64>,
|
|
}
|
|
|
|
impl MulticallItemValue {
|
|
fn to_string_lossy(&self) -> String {
|
|
if let Some(s) = &self.string {
|
|
s.clone()
|
|
} else if let Some(i) = self.i4 {
|
|
i.to_string()
|
|
} else if let Some(i) = self.i8 {
|
|
i.to_string()
|
|
} else {
|
|
String::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Response Model for simple string ---
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename = "methodResponse")]
|
|
struct StringResponse {
|
|
params: StringResponseParams,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct StringResponseParams {
|
|
param: StringResponseParam,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct StringResponseParam {
|
|
value: StringResponseValue,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct StringResponseValue {
|
|
string: String,
|
|
}
|
|
|
|
// --- Response Model for simple integer (i8/i4) ---
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename = "methodResponse")]
|
|
struct IntegerResponse {
|
|
params: IntegerResponseParams,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct IntegerResponseParams {
|
|
param: IntegerResponseParam,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct IntegerResponseParam {
|
|
value: IntegerResponseValue,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct IntegerResponseValue {
|
|
#[serde(rename = "i8", default)]
|
|
i8: Option<i64>,
|
|
#[serde(rename = "i4", default)]
|
|
i4: Option<i64>,
|
|
#[serde(rename = "string", default)]
|
|
string: Option<String>,
|
|
}
|
|
|
|
// --- Client Implementation ---
|
|
|
|
pub struct RtorrentClient {
|
|
socket_path: String,
|
|
}
|
|
|
|
impl RtorrentClient {
|
|
pub fn new(socket_path: &str) -> Self {
|
|
Self {
|
|
socket_path: socket_path.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Helper to build and serialize XML-RPC method call
|
|
fn build_method_call(&self, method: &str, params: &[RpcParam]) -> Result<String, XmlRpcError> {
|
|
let req_params = RequestParams {
|
|
param: params
|
|
.iter()
|
|
.map(|p| RequestParam {
|
|
value: match p {
|
|
RpcParam::String(s) => RequestValueInner {
|
|
string: Some(s),
|
|
i4: None,
|
|
},
|
|
RpcParam::Int(i) => RequestValueInner {
|
|
string: None,
|
|
i4: Some(*i as i32),
|
|
},
|
|
},
|
|
})
|
|
.collect(),
|
|
};
|
|
|
|
let call = MethodCall {
|
|
method_name: method,
|
|
params: req_params,
|
|
};
|
|
|
|
let xml_body = to_string(&call).map_err(|e| XmlRpcError::Serialization(e.to_string()))?;
|
|
Ok(format!("<?xml version=\"1.0\"?>\n{}", xml_body))
|
|
}
|
|
|
|
pub async fn call(&self, method: &str, params: &[RpcParam]) -> Result<String, XmlRpcError> {
|
|
let xml = self.build_method_call(method, params)?;
|
|
let req = ScgiRequest::new().body(xml.into_bytes());
|
|
|
|
let bytes = send_request(&self.socket_path, req).await?;
|
|
let s = String::from_utf8_lossy(&bytes).to_string();
|
|
Ok(s)
|
|
}
|
|
}
|
|
|
|
pub fn parse_multicall_response(xml: &str) -> Result<Vec<Vec<String>>, XmlRpcError> {
|
|
let response: MulticallResponse = from_str(xml)?;
|
|
|
|
let mut result = Vec::new();
|
|
|
|
for row in response.params.param.value.array.data.values {
|
|
let mut row_vec = Vec::new();
|
|
for item in row.array.data.values {
|
|
row_vec.push(item.to_string_lossy());
|
|
}
|
|
result.push(row_vec);
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
pub fn parse_string_response(xml: &str) -> Result<String, XmlRpcError> {
|
|
let response: StringResponse = from_str(xml)?;
|
|
Ok(response.params.param.value.string)
|
|
}
|
|
|
|
pub fn parse_i64_response(xml: &str) -> Result<i64, XmlRpcError> {
|
|
let response: IntegerResponse = from_str(xml)?;
|
|
if let Some(val) = response.params.param.value.i8 {
|
|
Ok(val)
|
|
} else if let Some(val) = response.params.param.value.i4 {
|
|
Ok(val)
|
|
} else if let Some(ref s) = response.params.param.value.string {
|
|
s.parse()
|
|
.map_err(|_| XmlRpcError::Parse("Not an integer string".to_string()))
|
|
} else {
|
|
Err(XmlRpcError::Parse("No integer value found".to_string()))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_build_method_call() {
|
|
let client = RtorrentClient::new("dummy");
|
|
let params = vec![
|
|
RpcParam::String("".to_string()),
|
|
RpcParam::String("main".to_string()),
|
|
RpcParam::String("d.name=".to_string()),
|
|
];
|
|
let xml = client.build_method_call("d.multicall2", ¶ms).unwrap();
|
|
|
|
assert!(xml.contains("<methodName>d.multicall2</methodName>"));
|
|
assert!(xml.contains("<value><string>main</string></value>"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_method_call_int() {
|
|
let client = RtorrentClient::new("dummy");
|
|
let params = vec![RpcParam::Int(1024)];
|
|
let xml = client.build_method_call("test.int", ¶ms).unwrap();
|
|
// Should produce <value><i4>1024</i4></value>
|
|
assert!(xml.contains("<i4>1024</i4>"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_multicall_response() {
|
|
let xml = r#"<methodResponse>
|
|
<params>
|
|
<param>
|
|
<value>
|
|
<array>
|
|
<data>
|
|
<value>
|
|
<array>
|
|
<data>
|
|
<value><string>HASH123</string></value>
|
|
<value><string>Ubuntu ISO</string></value>
|
|
<value><i4>1024</i4></value>
|
|
</data>
|
|
</array>
|
|
</value>
|
|
</data>
|
|
</array>
|
|
</value>
|
|
</param>
|
|
</params>
|
|
</methodResponse>
|
|
"#;
|
|
let result = parse_multicall_response(xml).expect("Failed to parse");
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0][0], "HASH123");
|
|
assert_eq!(result[0][1], "Ubuntu ISO");
|
|
assert_eq!(result[0][2], "1024");
|
|
}
|
|
}
|