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, Method, RequestBuilder};
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    api_key: Option<String>,
36}
37
38/// Error type for service client operations.
39#[derive(Debug, thiserror::Error)]
40pub enum ServiceClientError {
41    /// The service is not reachable.
42    #[error("Service not reachable at {url}: {source}")]
43    NotReachable {
44        url: String,
45        #[source]
46        source: reqwest::Error,
47    },
48
49    /// HTTP request failed.
50    #[error("HTTP request failed: {0}")]
51    Request(#[from] reqwest::Error),
52
53    /// Invalid URL.
54    #[error("Invalid URL: {0}")]
55    InvalidUrl(String),
56
57    /// API returned an error response.
58    #[error("API error: {message}")]
59    ApiError { status: u16, message: String },
60}
61
62/// Result type for service client operations.
63pub type Result<T> = std::result::Result<T, ServiceClientError>;
64
65const REJECTED_ACTION_STATUS: u16 = 409;
66
67// ==========================================================================
68// Response Types
69// ==========================================================================
70
71/// Service status response.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ServiceStatus {
74    /// Service version.
75    pub version: String,
76    /// Current timestamp.
77    #[serde(with = "time::serde::rfc3339")]
78    pub timestamp: OffsetDateTime,
79    /// Collector status.
80    pub collector: CollectorStatus,
81    /// Per-device collection statistics.
82    pub devices: Vec<DeviceCollectionStats>,
83}
84
85/// Collector status.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct CollectorStatus {
88    /// Whether the collector is running.
89    pub running: bool,
90    /// When the collector was started (if running).
91    #[serde(default, with = "time::serde::rfc3339::option")]
92    pub started_at: Option<OffsetDateTime>,
93    /// How long the collector has been running (in seconds).
94    pub uptime_seconds: Option<u64>,
95}
96
97/// Collection statistics for a single device.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct DeviceCollectionStats {
100    /// Device ID/address.
101    pub device_id: String,
102    /// Device alias.
103    pub alias: Option<String>,
104    /// Poll interval in seconds.
105    pub poll_interval: u64,
106    /// Time of last successful poll.
107    #[serde(default, with = "time::serde::rfc3339::option")]
108    pub last_poll_at: Option<OffsetDateTime>,
109    /// Time of last failed poll.
110    #[serde(default, with = "time::serde::rfc3339::option")]
111    pub last_error_at: Option<OffsetDateTime>,
112    /// Last error message.
113    pub last_error: Option<String>,
114    /// Total successful polls.
115    pub success_count: u64,
116    /// Total failed polls.
117    pub failure_count: u64,
118    /// Whether the device is currently being polled.
119    pub polling: bool,
120}
121
122/// Response from collector control actions.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct CollectorActionResponse {
125    pub success: bool,
126    pub message: String,
127    pub running: bool,
128}
129
130/// Service configuration.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct ServiceConfig {
133    pub server: ServerConfig,
134    pub devices: Vec<DeviceConfig>,
135}
136
137/// Server configuration.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ServerConfig {
140    pub bind: String,
141}
142
143/// Device configuration for monitoring.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct DeviceConfig {
146    pub address: String,
147    #[serde(default)]
148    pub alias: Option<String>,
149    #[serde(default = "default_poll_interval")]
150    pub poll_interval: u64,
151}
152
153fn default_poll_interval() -> u64 {
154    60
155}
156
157/// Health check response.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct HealthResponse {
160    pub status: String,
161    pub version: String,
162    #[serde(with = "time::serde::rfc3339")]
163    pub timestamp: OffsetDateTime,
164}
165
166// ==========================================================================
167// ServiceClient Implementation
168// ==========================================================================
169
170impl ServiceClient {
171    /// Create a new service client.
172    ///
173    /// # Arguments
174    ///
175    /// * `base_url` - The base URL of the aranet-service (e.g., "http://localhost:8080")
176    pub fn new(base_url: &str) -> Result<Self> {
177        let client = Client::builder()
178            .timeout(std::time::Duration::from_secs(10))
179            .build()
180            .map_err(ServiceClientError::Request)?;
181
182        Self::with_client_and_api_key(base_url, client, None)
183    }
184
185    /// Create a new service client with an optional API key.
186    pub fn new_with_api_key(base_url: &str, api_key: Option<String>) -> Result<Self> {
187        let client = Client::builder()
188            .timeout(std::time::Duration::from_secs(10))
189            .build()
190            .map_err(ServiceClientError::Request)?;
191
192        Self::with_client_and_api_key(base_url, client, api_key)
193    }
194
195    /// Create a client with a custom reqwest Client.
196    pub fn with_client(base_url: &str, client: Client) -> Result<Self> {
197        Self::with_client_and_api_key(base_url, client, None)
198    }
199
200    /// Create a client with a custom reqwest Client and optional API key.
201    pub fn with_client_and_api_key(
202        base_url: &str,
203        client: Client,
204        api_key: Option<String>,
205    ) -> Result<Self> {
206        let base_url = normalize_base_url(base_url)?;
207        Ok(Self {
208            client,
209            base_url,
210            api_key: sanitize_api_key(api_key),
211        })
212    }
213
214    /// Get the base URL.
215    pub fn base_url(&self) -> &str {
216        &self.base_url
217    }
218
219    /// Check if the service is reachable.
220    pub async fn is_reachable(&self) -> bool {
221        self.health().await.is_ok()
222    }
223
224    /// Get service health.
225    pub async fn health(&self) -> Result<HealthResponse> {
226        let url = format!("{}/api/health", self.base_url);
227        self.get(&url).await
228    }
229
230    /// Get service status including collector state and device stats.
231    pub async fn status(&self) -> Result<ServiceStatus> {
232        let url = format!("{}/api/status", self.base_url);
233        self.get(&url).await
234    }
235
236    /// Start the collector.
237    pub async fn start_collector(&self) -> Result<CollectorActionResponse> {
238        let url = format!("{}/api/collector/start", self.base_url);
239        let response = self.post_empty(&url).await?;
240        ensure_successful_action(response)
241    }
242
243    /// Stop the collector.
244    pub async fn stop_collector(&self) -> Result<CollectorActionResponse> {
245        let url = format!("{}/api/collector/stop", self.base_url);
246        let response = self.post_empty(&url).await?;
247        ensure_successful_action(response)
248    }
249
250    /// Get current configuration.
251    pub async fn config(&self) -> Result<ServiceConfig> {
252        let url = format!("{}/api/config", self.base_url);
253        self.get(&url).await
254    }
255
256    /// Add a device to monitor.
257    pub async fn add_device(&self, device: DeviceConfig) -> Result<DeviceConfig> {
258        let url = format!("{}/api/config/devices", self.base_url);
259        self.post_json(&url, &device).await
260    }
261
262    /// Update a device configuration.
263    pub async fn update_device(
264        &self,
265        device_id: &str,
266        alias: Option<String>,
267        poll_interval: Option<u64>,
268    ) -> Result<DeviceConfig> {
269        self.update_device_with_alias_change(device_id, alias.map(Some), poll_interval)
270            .await
271    }
272
273    /// Update a device configuration, distinguishing unchanged and cleared aliases.
274    pub async fn update_device_with_alias_change(
275        &self,
276        device_id: &str,
277        alias: Option<Option<String>>,
278        poll_interval: Option<u64>,
279    ) -> Result<DeviceConfig> {
280        let url = format!("{}/api/config/devices/{}", self.base_url, device_id);
281        let body = build_update_device_body(alias, poll_interval);
282        self.put_json(&url, &body).await
283    }
284
285    /// Remove a device from monitoring.
286    pub async fn remove_device(&self, device_id: &str) -> Result<()> {
287        let url = format!("{}/api/config/devices/{}", self.base_url, device_id);
288        self.delete(&url).await
289    }
290
291    // ======================================================================
292    // Internal HTTP helpers
293    // ======================================================================
294
295    fn request(&self, method: Method, url: &str) -> RequestBuilder {
296        let mut request = self.client.request(method, url);
297        if let Some(api_key) = &self.api_key {
298            request = request.header("X-API-Key", api_key);
299        }
300        request
301    }
302
303    async fn get<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
304        let response = self.request(Method::GET, url).send().await.map_err(|e| {
305            ServiceClientError::NotReachable {
306                url: url.to_string(),
307                source: e,
308            }
309        })?;
310
311        self.handle_response(response).await
312    }
313
314    async fn post_empty<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
315        let response = self.request(Method::POST, url).send().await.map_err(|e| {
316            ServiceClientError::NotReachable {
317                url: url.to_string(),
318                source: e,
319            }
320        })?;
321
322        self.handle_response(response).await
323    }
324
325    async fn post_json<T: serde::de::DeserializeOwned, B: Serialize>(
326        &self,
327        url: &str,
328        body: &B,
329    ) -> Result<T> {
330        let response = self
331            .request(Method::POST, url)
332            .json(body)
333            .send()
334            .await
335            .map_err(|e| ServiceClientError::NotReachable {
336                url: url.to_string(),
337                source: e,
338            })?;
339
340        self.handle_response(response).await
341    }
342
343    async fn put_json<T: serde::de::DeserializeOwned, B: Serialize>(
344        &self,
345        url: &str,
346        body: &B,
347    ) -> Result<T> {
348        let response = self
349            .request(Method::PUT, url)
350            .json(body)
351            .send()
352            .await
353            .map_err(|e| ServiceClientError::NotReachable {
354                url: url.to_string(),
355                source: e,
356            })?;
357
358        self.handle_response(response).await
359    }
360
361    async fn delete(&self, url: &str) -> Result<()> {
362        let response = self
363            .request(Method::DELETE, url)
364            .send()
365            .await
366            .map_err(|e| ServiceClientError::NotReachable {
367                url: url.to_string(),
368                source: e,
369            })?;
370
371        let status = response.status();
372        if status.is_success() {
373            Ok(())
374        } else {
375            let message = response
376                .json::<serde_json::Value>()
377                .await
378                .ok()
379                .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
380                .unwrap_or_else(|| status.to_string());
381
382            Err(ServiceClientError::ApiError {
383                status: status.as_u16(),
384                message,
385            })
386        }
387    }
388
389    async fn handle_response<T: serde::de::DeserializeOwned>(
390        &self,
391        response: reqwest::Response,
392    ) -> Result<T> {
393        let status = response.status();
394        if status.is_success() {
395            response.json().await.map_err(ServiceClientError::Request)
396        } else {
397            let message = response
398                .json::<serde_json::Value>()
399                .await
400                .ok()
401                .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
402                .unwrap_or_else(|| status.to_string());
403
404            Err(ServiceClientError::ApiError {
405                status: status.as_u16(),
406                message,
407            })
408        }
409    }
410}
411
412fn normalize_base_url(base_url: &str) -> Result<String> {
413    let base_url = base_url.trim_end_matches('/').to_string();
414
415    if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
416        return Err(ServiceClientError::InvalidUrl(format!(
417            "URL must start with http:// or https://, got: {}",
418            base_url
419        )));
420    }
421
422    Ok(base_url)
423}
424
425fn sanitize_api_key(api_key: Option<String>) -> Option<String> {
426    api_key
427        .map(|key| key.trim().to_string())
428        .filter(|key| !key.is_empty())
429}
430
431fn build_update_device_body(
432    alias: Option<Option<String>>,
433    poll_interval: Option<u64>,
434) -> serde_json::Value {
435    let mut body = serde_json::Map::new();
436
437    if let Some(alias) = alias {
438        body.insert("alias".to_string(), serde_json::Value::from(alias));
439    }
440
441    if let Some(poll_interval) = poll_interval {
442        body.insert(
443            "poll_interval".to_string(),
444            serde_json::Value::from(poll_interval),
445        );
446    }
447
448    serde_json::Value::Object(body)
449}
450
451fn ensure_successful_action(response: CollectorActionResponse) -> Result<CollectorActionResponse> {
452    if response.success {
453        Ok(response)
454    } else {
455        Err(ServiceClientError::ApiError {
456            status: REJECTED_ACTION_STATUS,
457            message: response.message,
458        })
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    #[test]
467    fn test_client_creation() {
468        let client = ServiceClient::new("http://localhost:8080");
469        assert!(client.is_ok());
470
471        let client = client.unwrap();
472        assert_eq!(client.base_url(), "http://localhost:8080");
473        assert!(client.api_key.is_none());
474    }
475
476    #[test]
477    fn test_client_normalizes_url() {
478        let client = ServiceClient::new("http://localhost:8080/").unwrap();
479        assert_eq!(client.base_url(), "http://localhost:8080");
480    }
481
482    #[test]
483    fn test_client_invalid_url() {
484        let result = ServiceClient::new("localhost:8080");
485        assert!(result.is_err());
486        assert!(matches!(result, Err(ServiceClientError::InvalidUrl(_))));
487    }
488
489    #[test]
490    fn test_client_sanitizes_api_key() {
491        let client = ServiceClient::new_with_api_key(
492            "http://localhost:8080",
493            Some("  test-api-key  ".to_string()),
494        )
495        .unwrap();
496        assert_eq!(client.api_key.as_deref(), Some("test-api-key"));
497
498        let client =
499            ServiceClient::new_with_api_key("http://localhost:8080", Some("   ".to_string()))
500                .unwrap();
501        assert!(client.api_key.is_none());
502    }
503
504    #[test]
505    fn test_update_device_body_omits_unchanged_alias() {
506        let body = build_update_device_body(None, Some(300));
507        assert_eq!(body, serde_json::json!({ "poll_interval": 300 }));
508    }
509
510    #[test]
511    fn test_update_device_body_can_clear_alias() {
512        let body = build_update_device_body(Some(None), None);
513        assert_eq!(body, serde_json::json!({ "alias": null }));
514    }
515
516    #[test]
517    fn test_device_config_default_poll_interval() {
518        let json = r#"{"address": "test"}"#;
519        let config: DeviceConfig = serde_json::from_str(json).unwrap();
520        assert_eq!(config.poll_interval, 60);
521    }
522
523    #[test]
524    fn test_successful_collector_action_passes_through() {
525        let response = CollectorActionResponse {
526            success: true,
527            message: "Collector started".to_string(),
528            running: true,
529        };
530
531        let result = ensure_successful_action(response).unwrap();
532        assert!(result.running);
533        assert_eq!(result.message, "Collector started");
534    }
535
536    #[test]
537    fn test_rejected_collector_action_returns_conflict_error() {
538        let response = CollectorActionResponse {
539            success: false,
540            message: "No devices configured".to_string(),
541            running: false,
542        };
543
544        let result = ensure_successful_action(response);
545
546        assert!(matches!(
547            result,
548            Err(ServiceClientError::ApiError { status, message })
549                if status == REJECTED_ACTION_STATUS && message == "No devices configured"
550        ));
551    }
552}