use async_trait::async_trait;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum TransportError {
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("HTTP {status}: {body}")]
HttpStatus { status: u16, body: String },
}
#[async_trait]
pub trait Transport: Send + Sync {
async fn soap_post(
&self,
url: &str,
action: &str,
body: String,
) -> Result<String, TransportError>;
}
pub struct HttpTransport {
client: reqwest::Client,
}
impl HttpTransport {
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
}
}
}
impl Default for HttpTransport {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Transport for HttpTransport {
async fn soap_post(
&self,
url: &str,
action: &str,
body: String,
) -> Result<String, TransportError> {
let content_type = format!("application/soap+xml; charset=utf-8; action=\"{action}\"");
let response = self
.client
.post(url)
.header("Content-Type", content_type)
.header("User-Agent", "oxvif/0.1")
.body(body)
.send()
.await?;
let status = response.status().as_u16();
let text = response.text().await?;
if status == 200 || status == 500 {
Ok(text)
} else {
Err(TransportError::HttpStatus { status, body: text })
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const ACTION: &str = "http://www.onvif.org/ver10/device/wsdl/GetCapabilities";
const SOAP_BODY: &str = r#"<s:Envelope><s:Body><tds:GetCapabilities/></s:Body></s:Envelope>"#;
fn sample_response() -> &'static str {
r#"<s:Envelope><s:Body><tds:GetCapabilitiesResponse/></s:Body></s:Envelope>"#
}
#[tokio::test]
async fn test_200_returns_body() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/onvif/device_service")
.with_status(200)
.with_header("content-type", "application/soap+xml; charset=utf-8")
.with_body(sample_response())
.create_async()
.await;
let t = HttpTransport::new();
let result = t
.soap_post(
&format!("{}/onvif/device_service", server.url()),
ACTION,
SOAP_BODY.to_string(),
)
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), sample_response());
mock.assert_async().await;
}
#[tokio::test]
async fn test_500_returns_ok_for_soap_fault() {
let fault_xml = r#"<s:Envelope><s:Body><s:Fault><s:Code><s:Value>s:Sender</s:Value></s:Code></s:Fault></s:Body></s:Envelope>"#;
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/onvif/device_service")
.with_status(500)
.with_body(fault_xml)
.create_async()
.await;
let t = HttpTransport::new();
let result = t
.soap_post(
&format!("{}/onvif/device_service", server.url()),
ACTION,
SOAP_BODY.to_string(),
)
.await;
assert!(
result.is_ok(),
"HTTP 500 should be Ok so SOAP layer can parse the Fault"
);
assert_eq!(result.unwrap(), fault_xml);
mock.assert_async().await;
}
#[tokio::test]
async fn test_non_soap_status_returns_err() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/onvif/device_service")
.with_status(401)
.with_body("Unauthorized")
.create_async()
.await;
let t = HttpTransport::new();
let result = t
.soap_post(
&format!("{}/onvif/device_service", server.url()),
ACTION,
SOAP_BODY.to_string(),
)
.await;
assert!(matches!(
result,
Err(TransportError::HttpStatus { status: 401, .. })
));
mock.assert_async().await;
}
#[tokio::test]
async fn test_content_type_contains_action() {
let expected_ct = format!("application/soap+xml; charset=utf-8; action=\"{ACTION}\"");
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/onvif/device_service")
.match_header("content-type", expected_ct.as_str())
.with_status(200)
.with_body(sample_response())
.create_async()
.await;
let t = HttpTransport::new();
let _ = t
.soap_post(
&format!("{}/onvif/device_service", server.url()),
ACTION,
SOAP_BODY.to_string(),
)
.await;
mock.assert_async().await;
}
#[tokio::test]
async fn test_body_is_sent_as_is() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/onvif/device_service")
.match_body(SOAP_BODY)
.with_status(200)
.with_body(sample_response())
.create_async()
.await;
let t = HttpTransport::new();
let _ = t
.soap_post(
&format!("{}/onvif/device_service", server.url()),
ACTION,
SOAP_BODY.to_string(),
)
.await;
mock.assert_async().await;
}
}