1use reqwest::Client;
27use serde::{Deserialize, Serialize};
28use time::OffsetDateTime;
29
30#[derive(Debug, Clone)]
32pub struct ServiceClient {
33 client: Client,
34 base_url: String,
35}
36
37#[derive(Debug, thiserror::Error)]
39pub enum ServiceClientError {
40 #[error("Service not reachable at {url}: {source}")]
42 NotReachable {
43 url: String,
44 #[source]
45 source: reqwest::Error,
46 },
47
48 #[error("HTTP request failed: {0}")]
50 Request(#[from] reqwest::Error),
51
52 #[error("Invalid URL: {0}")]
54 InvalidUrl(String),
55
56 #[error("API error: {message}")]
58 ApiError { status: u16, message: String },
59}
60
61pub type Result<T> = std::result::Result<T, ServiceClientError>;
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ServiceStatus {
71 pub version: String,
73 #[serde(with = "time::serde::rfc3339")]
75 pub timestamp: OffsetDateTime,
76 pub collector: CollectorStatus,
78 pub devices: Vec<DeviceCollectionStats>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct CollectorStatus {
85 pub running: bool,
87 #[serde(default, with = "time::serde::rfc3339::option")]
89 pub started_at: Option<OffsetDateTime>,
90 pub uptime_seconds: Option<u64>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct DeviceCollectionStats {
97 pub device_id: String,
99 pub alias: Option<String>,
101 pub poll_interval: u64,
103 #[serde(default, with = "time::serde::rfc3339::option")]
105 pub last_poll_at: Option<OffsetDateTime>,
106 #[serde(default, with = "time::serde::rfc3339::option")]
108 pub last_error_at: Option<OffsetDateTime>,
109 pub last_error: Option<String>,
111 pub success_count: u64,
113 pub failure_count: u64,
115 pub polling: bool,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct CollectorActionResponse {
122 pub success: bool,
123 pub message: String,
124 pub running: bool,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct ServiceConfig {
130 pub server: ServerConfig,
131 pub devices: Vec<DeviceConfig>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct ServerConfig {
137 pub bind: String,
138}
139
140#[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#[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
163impl ServiceClient {
168 pub fn new(base_url: &str) -> Result<Self> {
174 let base_url = base_url.trim_end_matches('/').to_string();
176
177 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 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 pub fn base_url(&self) -> &str {
209 &self.base_url
210 }
211
212 pub async fn is_reachable(&self) -> bool {
214 self.health().await.is_ok()
215 }
216
217 pub async fn health(&self) -> Result<HealthResponse> {
219 let url = format!("{}/api/health", self.base_url);
220 self.get(&url).await
221 }
222
223 pub async fn status(&self) -> Result<ServiceStatus> {
225 let url = format!("{}/api/status", self.base_url);
226 self.get(&url).await
227 }
228
229 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 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 pub async fn config(&self) -> Result<ServiceConfig> {
243 let url = format!("{}/api/config", self.base_url);
244 self.get(&url).await
245 }
246
247 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 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 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 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}