aranet_core/
service_client.rs

1//! HTTP client for the aranet-service REST API.
2//!
3//! This module provides a client for interacting with the aranet-service
4//! background service. It allows checking service status, controlling the
5//! collector, and managing monitored devices.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use aranet_core::service_client::ServiceClient;
11//!
12//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
13//! let client = ServiceClient::new("http://localhost:8080")?;
14//!
15//! // Check if service is running
16//! let status = client.status().await?;
17//! println!("Collector running: {}", status.collector.running);
18//!
19//! // Start the collector
20//! client.start_collector().await?;
21//!
22//! Ok(())
23//! # }
24//! ```
25
26use reqwest::Client;
27use serde::{Deserialize, Serialize};
28use time::OffsetDateTime;
29
30/// HTTP client for the aranet-service API.
31#[derive(Debug, Clone)]
32pub struct ServiceClient {
33    client: Client,
34    base_url: String,
35}
36
37/// Error type for service client operations.
38#[derive(Debug, thiserror::Error)]
39pub enum ServiceClientError {
40    /// The service is not reachable.
41    #[error("Service not reachable at {url}: {source}")]
42    NotReachable {
43        url: String,
44        #[source]
45        source: reqwest::Error,
46    },
47
48    /// HTTP request failed.
49    #[error("HTTP request failed: {0}")]
50    Request(#[from] reqwest::Error),
51
52    /// Invalid URL.
53    #[error("Invalid URL: {0}")]
54    InvalidUrl(String),
55
56    /// API returned an error response.
57    #[error("API error: {message}")]
58    ApiError { status: u16, message: String },
59}
60
61/// Result type for service client operations.
62pub type Result<T> = std::result::Result<T, ServiceClientError>;
63
64// ==========================================================================
65// Response Types
66// ==========================================================================
67
68/// Service status response.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ServiceStatus {
71    /// Service version.
72    pub version: String,
73    /// Current timestamp.
74    #[serde(with = "time::serde::rfc3339")]
75    pub timestamp: OffsetDateTime,
76    /// Collector status.
77    pub collector: CollectorStatus,
78    /// Per-device collection statistics.
79    pub devices: Vec<DeviceCollectionStats>,
80}
81
82/// Collector status.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct CollectorStatus {
85    /// Whether the collector is running.
86    pub running: bool,
87    /// When the collector was started (if running).
88    #[serde(default, with = "time::serde::rfc3339::option")]
89    pub started_at: Option<OffsetDateTime>,
90    /// How long the collector has been running (in seconds).
91    pub uptime_seconds: Option<u64>,
92}
93
94/// Collection statistics for a single device.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct DeviceCollectionStats {
97    /// Device ID/address.
98    pub device_id: String,
99    /// Device alias.
100    pub alias: Option<String>,
101    /// Poll interval in seconds.
102    pub poll_interval: u64,
103    /// Time of last successful poll.
104    #[serde(default, with = "time::serde::rfc3339::option")]
105    pub last_poll_at: Option<OffsetDateTime>,
106    /// Time of last failed poll.
107    #[serde(default, with = "time::serde::rfc3339::option")]
108    pub last_error_at: Option<OffsetDateTime>,
109    /// Last error message.
110    pub last_error: Option<String>,
111    /// Total successful polls.
112    pub success_count: u64,
113    /// Total failed polls.
114    pub failure_count: u64,
115    /// Whether the device is currently being polled.
116    pub polling: bool,
117}
118
119/// Response from collector control actions.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct CollectorActionResponse {
122    pub success: bool,
123    pub message: String,
124    pub running: bool,
125}
126
127/// Service configuration.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct ServiceConfig {
130    pub server: ServerConfig,
131    pub devices: Vec<DeviceConfig>,
132}
133
134/// Server configuration.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct ServerConfig {
137    pub bind: String,
138}
139
140/// Device configuration for monitoring.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct DeviceConfig {
143    pub address: String,
144    #[serde(default)]
145    pub alias: Option<String>,
146    #[serde(default = "default_poll_interval")]
147    pub poll_interval: u64,
148}
149
150fn default_poll_interval() -> u64 {
151    60
152}
153
154/// Health check response.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct HealthResponse {
157    pub status: String,
158    pub version: String,
159    #[serde(with = "time::serde::rfc3339")]
160    pub timestamp: OffsetDateTime,
161}
162
163// ==========================================================================
164// ServiceClient Implementation
165// ==========================================================================
166
167impl ServiceClient {
168    /// Create a new service client.
169    ///
170    /// # Arguments
171    ///
172    /// * `base_url` - The base URL of the aranet-service (e.g., "http://localhost:8080")
173    pub fn new(base_url: &str) -> Result<Self> {
174        // Normalize URL (remove trailing slash)
175        let base_url = base_url.trim_end_matches('/').to_string();
176
177        // Validate URL format
178        if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
179            return Err(ServiceClientError::InvalidUrl(format!(
180                "URL must start with http:// or https://, got: {}",
181                base_url
182            )));
183        }
184
185        let client = Client::builder()
186            .timeout(std::time::Duration::from_secs(10))
187            .build()
188            .map_err(ServiceClientError::Request)?;
189
190        Ok(Self { client, base_url })
191    }
192
193    /// Create a client with a custom reqwest Client.
194    pub fn with_client(base_url: &str, client: Client) -> Result<Self> {
195        let base_url = base_url.trim_end_matches('/').to_string();
196
197        if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
198            return Err(ServiceClientError::InvalidUrl(format!(
199                "URL must start with http:// or https://, got: {}",
200                base_url
201            )));
202        }
203
204        Ok(Self { client, base_url })
205    }
206
207    /// Get the base URL.
208    pub fn base_url(&self) -> &str {
209        &self.base_url
210    }
211
212    /// Check if the service is reachable.
213    pub async fn is_reachable(&self) -> bool {
214        self.health().await.is_ok()
215    }
216
217    /// Get service health.
218    pub async fn health(&self) -> Result<HealthResponse> {
219        let url = format!("{}/api/health", self.base_url);
220        self.get(&url).await
221    }
222
223    /// Get service status including collector state and device stats.
224    pub async fn status(&self) -> Result<ServiceStatus> {
225        let url = format!("{}/api/status", self.base_url);
226        self.get(&url).await
227    }
228
229    /// Start the collector.
230    pub async fn start_collector(&self) -> Result<CollectorActionResponse> {
231        let url = format!("{}/api/collector/start", self.base_url);
232        self.post_empty(&url).await
233    }
234
235    /// Stop the collector.
236    pub async fn stop_collector(&self) -> Result<CollectorActionResponse> {
237        let url = format!("{}/api/collector/stop", self.base_url);
238        self.post_empty(&url).await
239    }
240
241    /// Get current configuration.
242    pub async fn config(&self) -> Result<ServiceConfig> {
243        let url = format!("{}/api/config", self.base_url);
244        self.get(&url).await
245    }
246
247    /// Add a device to monitor.
248    pub async fn add_device(&self, device: DeviceConfig) -> Result<DeviceConfig> {
249        let url = format!("{}/api/config/devices", self.base_url);
250        self.post_json(&url, &device).await
251    }
252
253    /// Update a device configuration.
254    pub async fn update_device(
255        &self,
256        device_id: &str,
257        alias: Option<String>,
258        poll_interval: Option<u64>,
259    ) -> Result<DeviceConfig> {
260        let url = format!("{}/api/config/devices/{}", self.base_url, device_id);
261        let body = serde_json::json!({
262            "alias": alias,
263            "poll_interval": poll_interval,
264        });
265        self.put_json(&url, &body).await
266    }
267
268    /// Remove a device from monitoring.
269    pub async fn remove_device(&self, device_id: &str) -> Result<()> {
270        let url = format!("{}/api/config/devices/{}", self.base_url, device_id);
271        self.delete(&url).await
272    }
273
274    // ======================================================================
275    // Internal HTTP helpers
276    // ======================================================================
277
278    async fn get<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
279        let response =
280            self.client
281                .get(url)
282                .send()
283                .await
284                .map_err(|e| ServiceClientError::NotReachable {
285                    url: url.to_string(),
286                    source: e,
287                })?;
288
289        self.handle_response(response).await
290    }
291
292    async fn post_empty<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
293        let response =
294            self.client
295                .post(url)
296                .send()
297                .await
298                .map_err(|e| ServiceClientError::NotReachable {
299                    url: url.to_string(),
300                    source: e,
301                })?;
302
303        self.handle_response(response).await
304    }
305
306    async fn post_json<T: serde::de::DeserializeOwned, B: Serialize>(
307        &self,
308        url: &str,
309        body: &B,
310    ) -> Result<T> {
311        let response = self.client.post(url).json(body).send().await.map_err(|e| {
312            ServiceClientError::NotReachable {
313                url: url.to_string(),
314                source: e,
315            }
316        })?;
317
318        self.handle_response(response).await
319    }
320
321    async fn put_json<T: serde::de::DeserializeOwned, B: Serialize>(
322        &self,
323        url: &str,
324        body: &B,
325    ) -> Result<T> {
326        let response = self.client.put(url).json(body).send().await.map_err(|e| {
327            ServiceClientError::NotReachable {
328                url: url.to_string(),
329                source: e,
330            }
331        })?;
332
333        self.handle_response(response).await
334    }
335
336    async fn delete(&self, url: &str) -> Result<()> {
337        let response =
338            self.client
339                .delete(url)
340                .send()
341                .await
342                .map_err(|e| ServiceClientError::NotReachable {
343                    url: url.to_string(),
344                    source: e,
345                })?;
346
347        let status = response.status();
348        if status.is_success() {
349            Ok(())
350        } else {
351            let message = response
352                .json::<serde_json::Value>()
353                .await
354                .ok()
355                .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
356                .unwrap_or_else(|| status.to_string());
357
358            Err(ServiceClientError::ApiError {
359                status: status.as_u16(),
360                message,
361            })
362        }
363    }
364
365    async fn handle_response<T: serde::de::DeserializeOwned>(
366        &self,
367        response: reqwest::Response,
368    ) -> Result<T> {
369        let status = response.status();
370        if status.is_success() {
371            response.json().await.map_err(ServiceClientError::Request)
372        } else {
373            let message = response
374                .json::<serde_json::Value>()
375                .await
376                .ok()
377                .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
378                .unwrap_or_else(|| status.to_string());
379
380            Err(ServiceClientError::ApiError {
381                status: status.as_u16(),
382                message,
383            })
384        }
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn test_client_creation() {
394        let client = ServiceClient::new("http://localhost:8080");
395        assert!(client.is_ok());
396
397        let client = client.unwrap();
398        assert_eq!(client.base_url(), "http://localhost:8080");
399    }
400
401    #[test]
402    fn test_client_normalizes_url() {
403        let client = ServiceClient::new("http://localhost:8080/").unwrap();
404        assert_eq!(client.base_url(), "http://localhost:8080");
405    }
406
407    #[test]
408    fn test_client_invalid_url() {
409        let result = ServiceClient::new("localhost:8080");
410        assert!(result.is_err());
411        assert!(matches!(result, Err(ServiceClientError::InvalidUrl(_))));
412    }
413
414    #[test]
415    fn test_device_config_default_poll_interval() {
416        let json = r#"{"address": "test"}"#;
417        let config: DeviceConfig = serde_json::from_str(json).unwrap();
418        assert_eq!(config.poll_interval, 60);
419    }
420}