use crate::domain::{HardwareReport, PublishConfig, PublishError};
use crate::ports::DataPublisher;
use async_trait::async_trait;
use reqwest::Client;
use serde_json::json;
use std::time::Duration;
pub struct HttpDataPublisher {
client: Client,
#[allow(dead_code)]
timeout: Duration,
}
impl HttpDataPublisher {
pub fn new(timeout: Duration, skip_tls_verify: bool) -> Result<Self, PublishError> {
let client = Client::builder()
.timeout(timeout)
.danger_accept_invalid_certs(skip_tls_verify)
.build()
.map_err(|e| {
PublishError::NetworkFailed(format!("Failed to create HTTP client: {e}"))
})?;
Ok(Self { client, timeout })
}
pub fn with_defaults() -> Result<Self, PublishError> {
Self::new(Duration::from_secs(30), false)
}
fn create_payload(&self, report: &HardwareReport, config: &PublishConfig) -> serde_json::Value {
let mut payload = serde_json::to_value(report).unwrap_or_else(|_| json!({}));
let system_id = config
.system_identifier
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.unwrap_or_else(|| report.summary.system_info.uuid.clone());
if let Some(obj) = payload.as_object_mut() {
if !config.labels.is_empty() {
obj.insert(
"labels".to_string(),
serde_json::to_value(&config.labels).unwrap_or(json!({})),
);
}
obj.insert("system_id".to_string(), json!(system_id));
}
payload
}
}
#[async_trait]
impl DataPublisher for HttpDataPublisher {
async fn publish(
&self,
report: &HardwareReport,
config: &PublishConfig,
) -> Result<(), PublishError> {
if config.endpoint.is_empty() {
return Err(PublishError::NetworkFailed(
"No endpoint URL provided".to_string(),
));
}
let payload = self.create_payload(report, config);
let mut request = self.client.post(&config.endpoint).json(&payload);
if let Some(ref token) = config.auth_token {
request = request.header("Authorization", format!("Bearer {token}"));
}
request = request.header("Content-Type", "application/json");
let response = request
.send()
.await
.map_err(|e| PublishError::NetworkFailed(format!("Failed to send request: {e}")))?;
if response.status().is_success() {
Ok(())
} else {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
if status.as_u16() == 401 || status.as_u16() == 403 {
Err(PublishError::AuthenticationFailed(format!(
"HTTP {status}: {error_text}"
)))
} else {
Err(PublishError::NetworkFailed(format!(
"HTTP {status}: {error_text}"
)))
}
}
}
async fn test_connectivity(&self, config: &PublishConfig) -> Result<bool, PublishError> {
if config.endpoint.is_empty() {
return Ok(false);
}
let mut request = self.client.head(&config.endpoint);
if let Some(ref token) = config.auth_token {
request = request.header("Authorization", format!("Bearer {token}"));
}
match request.send().await {
Ok(response) => Ok(response.status().is_success() || response.status().as_u16() == 405), Err(_) => Ok(false),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{HardwareInfo, HardwareReport, NetworkInfo, SystemInfo, SystemSummary};
use std::collections::HashMap;
fn create_test_report() -> HardwareReport {
HardwareReport {
summary: SystemSummary {
system_info: SystemInfo {
uuid: "test-uuid".to_string(),
serial: "test-serial".to_string(),
product_name: "Test System".to_string(),
product_manufacturer: "Test Corp".to_string(),
},
total_memory: "16GB".to_string(),
memory_config: "DDR4 @ 3200MHz".to_string(),
total_storage: "1TB".to_string(),
total_storage_tb: 1.0,
filesystems: vec![],
bios: crate::domain::BiosInfo {
vendor: "Test BIOS".to_string(),
version: "1.0".to_string(),
release_date: "2024-01-01".to_string(),
firmware_version: "1.0".to_string(),
},
chassis: crate::domain::ChassisInfo {
manufacturer: "Test Corp".to_string(),
type_: "Desktop".to_string(),
serial: "test-chassis".to_string(),
},
motherboard: crate::domain::MotherboardInfo {
manufacturer: "Test Corp".to_string(),
product_name: "Test Board".to_string(),
version: "1.0".to_string(),
serial: "test-mb".to_string(),
features: "None".to_string(),
location: "System".to_string(),
type_: "Motherboard".to_string(),
},
total_gpus: 1,
total_nics: 1,
numa_topology: HashMap::new(),
cpu_topology: crate::domain::CpuTopology {
total_cores: 8,
total_threads: 16,
sockets: 1,
cores_per_socket: 8,
threads_per_core: 2,
numa_nodes: 1,
cpu_model: "Test CPU".to_string(),
},
cpu_summary: "Test CPU (1 Socket, 8 Cores/Socket, 2 Threads/Core, 1 NUMA Node)"
.to_string(),
},
hostname: "test-host".to_string(),
fqdn: "test-host.example.com".to_string(),
os_ip: vec![],
bmc_ip: None,
bmc_mac: None,
hardware: HardwareInfo {
cpu: crate::domain::CpuInfo {
model: "Test CPU".to_string(),
cores: 8,
threads: 2,
sockets: 1,
speed: "3.0 GHz".to_string(),
..Default::default()
},
memory: crate::domain::MemoryInfo {
total: "16GB".to_string(),
type_: "DDR4".to_string(),
speed: "3200 MHz".to_string(),
modules: vec![],
},
storage: crate::domain::StorageInfo { devices: vec![] },
gpus: crate::domain::GpuInfo { devices: vec![] },
},
network: NetworkInfo {
interfaces: vec![],
infiniband: None,
},
}
}
#[tokio::test]
async fn test_http_publisher_creation() {
let publisher = HttpDataPublisher::with_defaults();
assert!(publisher.is_ok());
}
#[tokio::test]
async fn test_create_payload_with_labels() {
let publisher = HttpDataPublisher::with_defaults().unwrap();
let report = create_test_report();
let mut labels = HashMap::new();
labels.insert("environment".to_string(), "test".to_string());
labels.insert("datacenter".to_string(), "dc1".to_string());
let config = PublishConfig {
endpoint: "http://example.com".to_string(),
auth_token: None,
skip_tls_verify: false,
labels,
system_identifier: None,
};
let payload = publisher.create_payload(&report, &config);
assert_eq!(payload["system_id"], "test-uuid");
assert!(payload.get("labels").is_some());
assert_eq!(payload["labels"]["environment"], "test");
assert_eq!(payload["labels"]["datacenter"], "dc1");
}
#[tokio::test]
async fn test_create_payload_system_identifier_override() {
let publisher = HttpDataPublisher::with_defaults().unwrap();
let report = create_test_report();
let config = PublishConfig {
endpoint: "http://example.com".to_string(),
auth_token: None,
skip_tls_verify: false,
labels: HashMap::new(),
system_identifier: Some("custom-machine-42".to_string()),
};
let payload = publisher.create_payload(&report, &config);
assert_eq!(payload["system_id"], "custom-machine-42");
}
#[tokio::test]
async fn test_empty_endpoint_error() {
let publisher = HttpDataPublisher::with_defaults().unwrap();
let report = create_test_report();
let config = PublishConfig {
endpoint: String::new(),
auth_token: None,
skip_tls_verify: false,
labels: HashMap::new(),
system_identifier: None,
};
let result = publisher.publish(&report, &config).await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
PublishError::NetworkFailed(_)
));
}
}