1use reqwest::{Client, Method, RequestBuilder};
27use serde::{Deserialize, Serialize};
28use time::OffsetDateTime;
29
30#[derive(Debug, Clone)]
32pub struct ServiceClient {
33 client: Client,
34 base_url: String,
35 api_key: Option<String>,
36}
37
38#[derive(Debug, thiserror::Error)]
40pub enum ServiceClientError {
41 #[error("Service not reachable at {url}: {source}")]
43 NotReachable {
44 url: String,
45 #[source]
46 source: reqwest::Error,
47 },
48
49 #[error("HTTP request failed: {0}")]
51 Request(#[from] reqwest::Error),
52
53 #[error("Invalid URL: {0}")]
55 InvalidUrl(String),
56
57 #[error("API error: {message}")]
59 ApiError { status: u16, message: String },
60}
61
62pub type Result<T> = std::result::Result<T, ServiceClientError>;
64
65const REJECTED_ACTION_STATUS: u16 = 409;
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ServiceStatus {
74 pub version: String,
76 #[serde(with = "time::serde::rfc3339")]
78 pub timestamp: OffsetDateTime,
79 pub collector: CollectorStatus,
81 pub devices: Vec<DeviceCollectionStats>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct CollectorStatus {
88 pub running: bool,
90 #[serde(default, with = "time::serde::rfc3339::option")]
92 pub started_at: Option<OffsetDateTime>,
93 pub uptime_seconds: Option<u64>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct DeviceCollectionStats {
100 pub device_id: String,
102 pub alias: Option<String>,
104 pub poll_interval: u64,
106 #[serde(default, with = "time::serde::rfc3339::option")]
108 pub last_poll_at: Option<OffsetDateTime>,
109 #[serde(default, with = "time::serde::rfc3339::option")]
111 pub last_error_at: Option<OffsetDateTime>,
112 pub last_error: Option<String>,
114 pub success_count: u64,
116 pub failure_count: u64,
118 pub polling: bool,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct CollectorActionResponse {
125 pub success: bool,
126 pub message: String,
127 pub running: bool,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct ServiceConfig {
133 pub server: ServerConfig,
134 pub devices: Vec<DeviceConfig>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ServerConfig {
140 pub bind: String,
141}
142
143#[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#[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
166impl ServiceClient {
171 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 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 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 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 pub fn base_url(&self) -> &str {
216 &self.base_url
217 }
218
219 pub async fn is_reachable(&self) -> bool {
221 self.health().await.is_ok()
222 }
223
224 pub async fn health(&self) -> Result<HealthResponse> {
226 let url = format!("{}/api/health", self.base_url);
227 self.get(&url).await
228 }
229
230 pub async fn status(&self) -> Result<ServiceStatus> {
232 let url = format!("{}/api/status", self.base_url);
233 self.get(&url).await
234 }
235
236 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 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 pub async fn config(&self) -> Result<ServiceConfig> {
252 let url = format!("{}/api/config", self.base_url);
253 self.get(&url).await
254 }
255
256 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 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 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 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 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}