1#[cfg(feature = "mock")]
2pub mod mock;
3
4pub(crate) mod paths;
5
6pub mod access_catalog;
7pub mod access_grants;
8pub mod api_tokens;
9pub mod audit_logs;
10pub mod auth;
11pub mod autodiscovery;
12pub mod batch_progress_stream;
13pub mod discovery_allowlist;
14pub mod enrollment_tokens;
15pub mod error;
16pub mod events_stream;
17pub mod health;
18pub mod host_tags;
19pub mod hosts;
20pub mod notifications;
21pub mod oauth_clients;
22pub mod oauth_consents;
23pub mod oidc_auth;
24pub mod oidc_providers;
25pub mod pki;
26pub mod plugin_configs;
27pub mod plugin_type_settings;
28pub mod roles;
29pub mod scheduler;
30pub mod services;
31pub mod settings;
32pub mod settings_nats;
33pub mod settings_provider_github;
34pub mod software_items;
35pub mod sse;
36pub mod surfaces;
37pub mod system_alerts;
38pub mod system_enrollment_tokens;
39pub mod system_services;
40pub mod update_batches;
41pub mod update_history;
42pub mod update_output_stream;
43pub mod users;
44
45pub use error::{ClientError, Result};
46
47pub use uptrakit_shared_types::DeviceAuthStatus;
48pub use uptrakit_web_api_types as types;
49
50pub(crate) mod types_impl {
51 pub(crate) use uptrakit_web_api_types::*;
52}
53
54#[cfg(test)]
55pub(crate) mod shared_types_impl {
56 pub(crate) use uptrakit_shared_types::*;
57}
58
59pub use uuid::Uuid;
62
63pub use reqwest::Error as ReqwestError;
66
67pub use reqwest::StatusCode;
70
71use rootcause::prelude::*;
72use serde::Serialize;
73use serde::de::DeserializeOwned;
74use std::time::Duration;
75
76fn serialize_status_code<S: serde::Serializer>(
78 status: &reqwest::StatusCode,
79 serializer: S,
80) -> std::result::Result<S::Ok, S::Error> {
81 serializer.serialize_u16(status.as_u16())
82}
83
84#[derive(Debug, Serialize)]
86pub struct RawResponse {
87 #[serde(serialize_with = "serialize_status_code")]
88 pub status: reqwest::StatusCode,
89 pub body: serde_json::Value,
90}
91
92#[derive(Debug, Clone)]
106pub struct RetryConfig {
107 pub max_retries: u32,
110 pub initial_delay: Duration,
113 pub max_delay: Duration,
116}
117
118impl Default for RetryConfig {
119 fn default() -> Self {
120 Self {
121 max_retries: 3,
122 initial_delay: Duration::from_secs(1),
123 max_delay: Duration::from_secs(30),
124 }
125 }
126}
127
128pub struct UptrakitClient {
133 http: reqwest::Client,
134 base_url: String,
135 token: Option<String>,
136 retry: Option<RetryConfig>,
137}
138
139impl UptrakitClient {
140 const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
142
143 const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
145
146 pub fn new(
161 base_url: &str,
162 token: Option<&str>,
163 insecure: bool,
164 ca_pem: Option<&str>,
165 request_timeout: Option<Duration>,
166 ) -> Result<Self> {
167 let timeout = request_timeout.unwrap_or(Self::DEFAULT_REQUEST_TIMEOUT);
168 let mut builder = reqwest::Client::builder()
169 .connect_timeout(Self::DEFAULT_CONNECT_TIMEOUT)
170 .timeout(timeout);
171 if insecure {
172 builder = builder.tls_danger_accept_invalid_certs(true);
173 } else if let Some(pem) = ca_pem {
174 let cert = reqwest::Certificate::from_pem(pem.as_bytes()).context_to()?;
175 builder = builder.tls_certs_only(std::iter::once(cert));
176 }
177 let http = builder.build().context_to()?;
178
179 Ok(Self {
180 http,
181 base_url: base_url.trim_end_matches('/').to_string(),
182 token: token.map(|t| t.to_string()),
183 retry: None,
184 })
185 }
186
187 pub fn with_token(
192 base_url: &str,
193 token: &str,
194 insecure: bool,
195 ca_pem: Option<&str>,
196 ) -> Result<Self> {
197 Self::new(base_url, Some(token), insecure, ca_pem, None)
198 }
199
200 pub fn with_retry(mut self, config: RetryConfig) -> Self {
206 self.retry = Some(config);
207 self
208 }
209
210 pub async fn raw_request(
212 &self,
213 method: &str,
214 path: &str,
215 body: Option<serde_json::Value>,
216 ) -> Result<RawResponse> {
217 let url = format!("{}{}", self.base_url, path);
218 let method = method.to_uppercase();
219 let req_method = method
220 .parse::<reqwest::Method>()
221 .map_err(|e| report!(ClientError::InvalidMethod(e.to_string())))?;
222
223 let mut req = self.http.request(req_method, &url);
224 if let Some(token) = &self.token {
225 req = req.bearer_auth(token);
226 }
227 if let Some(body) = body {
228 req = req.json(&body);
229 }
230
231 let resp = req.send().await.context_to()?;
232 let status = resp.status();
233 let text = resp.text().await.context_to()?;
234
235 let body = if text.is_empty() {
236 serde_json::Value::Null
237 } else {
238 serde_json::from_str(&text).unwrap_or(serde_json::Value::String(text))
239 };
240
241 Ok(RawResponse { status, body })
242 }
243
244 fn token_or_err(&self) -> Result<&str> {
247 self.token
248 .as_deref()
249 .ok_or_else(|| report!(ClientError::NotAuthenticated))
250 }
251
252 async fn send_with_retry(&self, req: reqwest::RequestBuilder) -> Result<reqwest::Response> {
258 let Some(retry) = &self.retry else {
259 return req.send().await.context_to();
260 };
261
262 let retry_builders: Vec<reqwest::RequestBuilder> = (0..retry.max_retries)
266 .map_while(|_| req.try_clone())
267 .collect();
268
269 let mut resp = req.send().await.context_to()?;
270
271 for (attempt, retry_req) in retry_builders.into_iter().enumerate() {
272 let status = resp.status();
273 let delay = if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
274 parse_retry_after(&resp)
276 .map(Duration::from_secs)
277 .unwrap_or(retry.initial_delay)
278 .min(retry.max_delay)
279 } else if status.is_server_error() {
280 let factor = 1u32.checked_shl(attempt as u32).unwrap_or(u32::MAX);
282 retry
283 .initial_delay
284 .saturating_mul(factor)
285 .min(retry.max_delay)
286 } else {
287 return Ok(resp);
289 };
290
291 tokio::time::sleep(delay).await;
292 resp = retry_req.send().await.context_to()?;
293 }
294
295 Ok(resp)
296 }
297
298 pub(crate) async fn fetch_all_pages<T: DeserializeOwned + Send>(
306 &self,
307 path: &str,
308 base_query: &impl Serialize,
309 ) -> Result<Vec<T>> {
310 use crate::types_impl::pagination::{MAX_PER_PAGE, PaginatedResponse};
311
312 let base_value = serde_json::to_value(base_query).context_to()?;
313 let mut all: Vec<T> = Vec::new();
314 let mut page: u64 = 1;
315 loop {
316 let mut query = base_value.clone();
317 if let Some(obj) = query.as_object_mut() {
318 obj.insert("page".to_string(), serde_json::json!(page));
319 obj.insert("per_page".to_string(), serde_json::json!(MAX_PER_PAGE));
320 }
321 let resp: PaginatedResponse<T> = self.get_with_query(path, &query).await?;
322 let total_pages = resp.total_pages;
323 all.extend(resp.items);
324 if page >= total_pages || total_pages == 0 {
325 break;
326 }
327 page += 1;
328 }
329 Ok(all)
330 }
331
332 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
333 let url = format!("{}{}", self.base_url, path);
334 let req = self.http.get(&url).bearer_auth(self.token_or_err()?);
335 let resp = self.send_with_retry(req).await?;
336 self.handle_response(resp).await
337 }
338
339 async fn get_with_query<T: DeserializeOwned>(
340 &self,
341 path: &str,
342 query: &impl Serialize,
343 ) -> Result<T> {
344 let url = format!("{}{}", self.base_url, path);
345 let req = self
346 .http
347 .get(&url)
348 .bearer_auth(self.token_or_err()?)
349 .query(query);
350 let resp = self.send_with_retry(req).await?;
351 self.handle_response(resp).await
352 }
353
354 async fn post_json<T: DeserializeOwned>(&self, path: &str, body: &impl Serialize) -> Result<T> {
355 let url = format!("{}{}", self.base_url, path);
356 let req = self
357 .http
358 .post(&url)
359 .bearer_auth(self.token_or_err()?)
360 .json(body);
361 let resp = self.send_with_retry(req).await?;
362 self.handle_response(resp).await
363 }
364
365 async fn post_empty<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
366 let url = format!("{}{}", self.base_url, path);
367 let req = self.http.post(&url).bearer_auth(self.token_or_err()?);
368 let resp = self.send_with_retry(req).await?;
369 self.handle_response(resp).await
370 }
371
372 async fn post_json_unauth<T: DeserializeOwned>(
374 &self,
375 path: &str,
376 body: &impl Serialize,
377 ) -> Result<T> {
378 let url = format!("{}{}", self.base_url, path);
379 let req = self.http.post(&url).json(body);
380 let resp = self.send_with_retry(req).await?;
381 self.handle_response(resp).await
382 }
383
384 async fn post_form_unauth<T: DeserializeOwned, F: Serialize + ?Sized>(
394 &self,
395 path: &str,
396 form: &F,
397 ) -> Result<T> {
398 let url = format!("{}{}", self.base_url, path);
399 let req = self.http.post(&url).form(form);
400 let resp = self.send_with_retry(req).await?;
401
402 let status = resp.status();
403 let retry_after = parse_retry_after(&resp);
406 let bytes = resp.bytes().await.context_to()?;
407
408 if status == reqwest::StatusCode::OK {
409 return serde_json::from_slice::<T>(&bytes).context_to();
410 }
411 if status == reqwest::StatusCode::BAD_REQUEST
412 && let Ok(err_resp) =
413 serde_json::from_slice::<crate::types_impl::oauth::OAuthErrorResponse>(&bytes)
414 {
415 bail!(ClientError::OAuthError(err_resp));
416 }
417 self.handle_response_bytes(status, bytes.to_vec(), retry_after)
422 .await
423 }
424
425 async fn delete(&self, path: &str) -> Result<()> {
426 let url = format!("{}{}", self.base_url, path);
427 let req = self.http.delete(&url).bearer_auth(self.token_or_err()?);
428 let resp = self.send_with_retry(req).await?;
429 self.handle_empty_response(resp).await
430 }
431
432 async fn post_empty_no_content(&self, path: &str) -> Result<()> {
434 let url = format!("{}{}", self.base_url, path);
435 let req = self.http.post(&url).bearer_auth(self.token_or_err()?);
436 let resp = self.send_with_retry(req).await?;
437 self.handle_empty_response(resp).await
438 }
439
440 async fn delete_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
441 let url = format!("{}{}", self.base_url, path);
442 let req = self.http.delete(&url).bearer_auth(self.token_or_err()?);
443 let resp = self.send_with_retry(req).await?;
444 self.handle_response(resp).await
445 }
446
447 async fn delete_json_body<T: DeserializeOwned>(
451 &self,
452 path: &str,
453 body: &impl Serialize,
454 ) -> Result<T> {
455 let url = format!("{}{}", self.base_url, path);
456 let req = self
457 .http
458 .delete(&url)
459 .bearer_auth(self.token_or_err()?)
460 .json(body);
461 let resp = self.send_with_retry(req).await?;
462 self.handle_response(resp).await
463 }
464
465 async fn delete_with_query(&self, path: &str, query: &impl Serialize) -> Result<()> {
466 let url = format!("{}{}", self.base_url, path);
467 let req = self
468 .http
469 .delete(&url)
470 .bearer_auth(self.token_or_err()?)
471 .query(query);
472 let resp = self.send_with_retry(req).await?;
473 self.handle_empty_response(resp).await
474 }
475
476 #[expect(
477 dead_code,
478 reason = "HTTP helper — not yet called by any route but retained for API completeness"
479 )]
480 async fn delete_with_query_json<T: DeserializeOwned>(
481 &self,
482 path: &str,
483 query: &impl Serialize,
484 ) -> Result<T> {
485 let url = format!("{}{}", self.base_url, path);
486 let req = self
487 .http
488 .delete(&url)
489 .bearer_auth(self.token_or_err()?)
490 .query(query);
491 let resp = self.send_with_retry(req).await?;
492 self.handle_response(resp).await
493 }
494
495 async fn put_json<T: DeserializeOwned>(&self, path: &str, body: &impl Serialize) -> Result<T> {
496 let url = format!("{}{}", self.base_url, path);
497 let req = self
498 .http
499 .put(&url)
500 .bearer_auth(self.token_or_err()?)
501 .json(body);
502 let resp = self.send_with_retry(req).await?;
503 self.handle_response(resp).await
504 }
505
506 async fn get_with_etag<T: DeserializeOwned>(&self, path: &str) -> Result<(T, String)> {
507 let url = format!("{}{}", self.base_url, path);
508 let req = self.http.get(&url).bearer_auth(self.token_or_err()?);
509 let resp = self.send_with_retry(req).await?;
510 let etag = resp
511 .headers()
512 .get(reqwest::header::ETAG)
513 .and_then(|v| v.to_str().ok())
514 .unwrap_or_default()
515 .to_string();
516 let body: T = self.handle_response(resp).await?;
517 Ok((body, etag))
518 }
519
520 async fn put_json_with_etag<T: DeserializeOwned>(
521 &self,
522 path: &str,
523 body: &impl Serialize,
524 etag: &str,
525 ) -> Result<(T, String)> {
526 let url = format!("{}{}", self.base_url, path);
527 let req = self
528 .http
529 .put(&url)
530 .bearer_auth(self.token_or_err()?)
531 .header(reqwest::header::IF_MATCH, etag)
532 .json(body);
533 let resp = self.send_with_retry(req).await?;
534 let new_etag = resp
535 .headers()
536 .get(reqwest::header::ETAG)
537 .and_then(|v| v.to_str().ok())
538 .unwrap_or_default()
539 .to_string();
540 let body: T = self.handle_response(resp).await?;
541 Ok((body, new_etag))
542 }
543
544 async fn post_json_no_content(&self, path: &str, body: &impl Serialize) -> Result<()> {
546 let url = format!("{}{}", self.base_url, path);
547 let req = self
548 .http
549 .post(&url)
550 .bearer_auth(self.token_or_err()?)
551 .json(body);
552 let resp = self.send_with_retry(req).await?;
553 self.handle_empty_response(resp).await
554 }
555
556 async fn get_unauth<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
558 let url = format!("{}{}", self.base_url, path);
559 let req = self.http.get(&url);
560 let resp = self.send_with_retry(req).await?;
561 self.handle_response(resp).await
562 }
563
564 async fn get_text_unauth(&self, path: &str) -> Result<String> {
566 let url = format!("{}{}", self.base_url, path);
567 let req = self.http.get(&url);
568 let resp = self.send_with_retry(req).await?;
569 self.handle_text_response(resp).await
570 }
571
572 async fn handle_response<T: DeserializeOwned>(&self, resp: reqwest::Response) -> Result<T> {
573 let status = resp.status();
574 let retry_after = parse_retry_after(&resp);
575 let bytes = resp.bytes().await.context_to()?.to_vec();
576 self.handle_response_bytes(status, bytes, retry_after).await
577 }
578
579 async fn handle_response_bytes<T: DeserializeOwned>(
580 &self,
581 status: reqwest::StatusCode,
582 bytes: Vec<u8>,
583 retry_after: Option<u64>,
584 ) -> Result<T> {
585 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
586 bail!(ClientError::RateLimited {
587 retry_after_seconds: retry_after,
588 });
589 }
590 if status == reqwest::StatusCode::UNAUTHORIZED {
591 bail!(ClientError::NotAuthenticated);
592 }
593 let text = String::from_utf8_lossy(&bytes).into_owned();
594 if status == reqwest::StatusCode::NOT_FOUND {
595 let message = extract_error_message(&text);
596 bail!(ClientError::NotFound(message));
597 }
598 if status.is_client_error() || status.is_server_error() {
599 let message = extract_error_message(&text);
600 bail!(ClientError::Api { status, message });
601 }
602 serde_json::from_slice::<T>(&bytes).context_to()
603 }
604
605 async fn handle_empty_response(&self, resp: reqwest::Response) -> Result<()> {
606 let status = resp.status();
607 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
608 let retry_after = parse_retry_after(&resp);
609 bail!(ClientError::RateLimited {
610 retry_after_seconds: retry_after,
611 });
612 }
613 if status == reqwest::StatusCode::UNAUTHORIZED {
614 bail!(ClientError::NotAuthenticated);
615 }
616 if status == reqwest::StatusCode::NOT_FOUND {
617 let text = resp.text().await.context_to()?;
618 let message = extract_error_message(&text);
619 bail!(ClientError::NotFound(message));
620 }
621 if status.is_client_error() || status.is_server_error() {
622 let text = resp.text().await.context_to()?;
623 let message = extract_error_message(&text);
624 bail!(ClientError::Api { status, message });
625 }
626 Ok(())
627 }
628
629 async fn handle_text_response(&self, resp: reqwest::Response) -> Result<String> {
630 let status = resp.status();
631 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
632 let retry_after = parse_retry_after(&resp);
633 bail!(ClientError::RateLimited {
634 retry_after_seconds: retry_after,
635 });
636 }
637 if status == reqwest::StatusCode::UNAUTHORIZED {
638 bail!(ClientError::NotAuthenticated);
639 }
640 let text = resp.text().await.context_to()?;
641 if status == reqwest::StatusCode::NOT_FOUND {
642 let message = extract_error_message(&text);
643 bail!(ClientError::NotFound(message));
644 }
645 if status.is_client_error() || status.is_server_error() {
646 let message = extract_error_message(&text);
647 bail!(ClientError::Api { status, message });
648 }
649 Ok(text)
650 }
651}
652
653fn parse_retry_after(resp: &reqwest::Response) -> Option<u64> {
658 resp.headers()
659 .get(reqwest::header::RETRY_AFTER)?
660 .to_str()
661 .ok()?
662 .parse::<u64>()
663 .ok()
664}
665
666#[expect(
669 clippy::indexing_slicing,
670 reason = "serde_json::Value index operator returns Value::Null for missing keys rather than panicking; this is safe"
671)]
672pub(crate) fn extract_error_message(text: &str) -> String {
673 serde_json::from_str::<serde_json::Value>(text)
674 .ok()
675 .and_then(|v| v["error"].as_str().map(|s| s.to_string()))
676 .unwrap_or_else(|| {
677 if text.is_empty() {
678 "Request failed".to_string()
679 } else {
680 text.to_string()
681 }
682 })
683}
684
685#[cfg(test)]
686mod tests {
687 #![expect(
688 clippy::assertions_on_result_states,
689 reason = "test assertions — assert!(result.is_err()) is idiomatic in tests"
690 )]
691
692 use super::*;
693
694 #[test]
695 fn extract_error_message_from_json() {
696 let text = r#"{"error":"Not found"}"#;
697 assert_eq!(extract_error_message(text), "Not found");
698 }
699
700 #[test]
701 fn extract_error_message_from_json_without_error_field() {
702 let text = r#"{"message":"something"}"#;
703 assert_eq!(extract_error_message(text), text);
704 }
705
706 #[test]
707 fn extract_error_message_from_plain_text() {
708 let text = "Internal Server Error";
709 assert_eq!(extract_error_message(text), "Internal Server Error");
710 }
711
712 #[test]
713 fn extract_error_message_from_empty() {
714 assert_eq!(extract_error_message(""), "Request failed");
715 }
716
717 #[test]
718 fn base_url_trailing_slash_is_trimmed() {
719 let client = UptrakitClient::new("https://example.com/", None, false, None, None)
720 .expect("client creation");
721 assert_eq!(client.base_url, "https://example.com");
722 }
723
724 #[test]
725 fn base_url_without_trailing_slash_is_unchanged() {
726 let client = UptrakitClient::new("https://example.com", None, false, None, None)
727 .expect("client creation");
728 assert_eq!(client.base_url, "https://example.com");
729 }
730
731 #[test]
732 fn with_token_stores_token() {
733 let client = UptrakitClient::with_token("https://example.com", "tok-123", false, None)
734 .expect("client creation");
735 assert_eq!(client.token.as_deref(), Some("tok-123"));
736 }
737
738 #[test]
739 fn new_without_token_stores_none() {
740 let client = UptrakitClient::new("https://example.com", None, false, None, None)
741 .expect("client creation");
742 assert!(client.token.is_none());
743 }
744
745 #[test]
746 fn token_or_err_returns_token_when_present() {
747 let client = UptrakitClient::with_token("https://example.com", "tok", false, None)
748 .expect("client creation");
749 assert_eq!(client.token_or_err().expect("token"), "tok");
750 }
751
752 #[test]
753 fn token_or_err_returns_error_when_absent() {
754 let client = UptrakitClient::new("https://example.com", None, false, None, None)
755 .expect("client creation");
756 let err = client.token_or_err().unwrap_err();
757 assert!(
758 matches!(err.current_context(), ClientError::NotAuthenticated),
759 "expected NotAuthenticated, got: {err}"
760 );
761 }
762
763 #[test]
764 fn parse_retry_after_valid_seconds() {
765 let resp = http::Response::builder()
766 .status(http::StatusCode::TOO_MANY_REQUESTS)
767 .header("Retry-After", "60")
768 .body("")
769 .unwrap();
770 let reqwest_resp = reqwest::Response::from(resp);
771 assert_eq!(parse_retry_after(&reqwest_resp), Some(60));
772 }
773
774 #[test]
775 fn parse_retry_after_missing_header() {
776 let resp = http::Response::builder()
777 .status(http::StatusCode::TOO_MANY_REQUESTS)
778 .body("")
779 .unwrap();
780 let reqwest_resp = reqwest::Response::from(resp);
781 assert_eq!(parse_retry_after(&reqwest_resp), None);
782 }
783
784 #[test]
785 fn parse_retry_after_non_numeric() {
786 let resp = http::Response::builder()
787 .status(http::StatusCode::TOO_MANY_REQUESTS)
788 .header("Retry-After", "Wed, 21 Oct 2025 07:28:00 GMT")
789 .body("")
790 .unwrap();
791 let reqwest_resp = reqwest::Response::from(resp);
792 assert_eq!(parse_retry_after(&reqwest_resp), None);
793 }
794
795 #[test]
796 fn raw_response_serialization() {
797 let resp = RawResponse {
798 status: reqwest::StatusCode::OK,
799 body: serde_json::json!({"key": "value"}),
800 };
801 let json = serde_json::to_string(&resp).expect("serialize");
802 let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse");
803 assert_eq!(parsed["status"], 200);
804 assert_eq!(parsed["body"]["key"], "value");
805 }
806
807 #[test]
808 fn default_client_has_no_retry() {
809 let client =
810 UptrakitClient::new("https://example.com", None, false, None, None).expect("client");
811 assert!(client.retry.is_none());
812 }
813
814 #[test]
815 fn with_retry_sets_config() {
816 let client = UptrakitClient::new("https://example.com", None, false, None, None)
817 .expect("client")
818 .with_retry(RetryConfig::default());
819 assert!(client.retry.is_some());
820 }
821
822 #[test]
823 fn retry_config_default_values() {
824 let config = RetryConfig::default();
825 assert_eq!(config.max_retries, 3);
826 assert_eq!(config.initial_delay, Duration::from_secs(1));
827 assert_eq!(config.max_delay, Duration::from_secs(30));
828 }
829
830 #[cfg(test)]
832 fn retrying_client(base_url: &str) -> UptrakitClient {
833 UptrakitClient::with_token(base_url, "test-token", false, None)
834 .expect("client")
835 .with_retry(RetryConfig {
836 max_retries: 2,
837 initial_delay: Duration::from_millis(1),
838 max_delay: Duration::from_millis(10),
839 })
840 }
841
842 #[tokio::test]
845 async fn retry_exhausted_on_repeated_503() {
846 use crate::types_impl::pagination::PaginationParams;
847 use httpmock::prelude::*;
848
849 let server = MockServer::start_async().await;
850 let mock = server.mock(|when, then| {
851 when.method(GET).path("/api/v1/hosts");
852 then.status(503).body(r#"{"error":"down"}"#);
853 });
854
855 let params = PaginationParams {
856 page: None,
857 per_page: None,
858 };
859 let client = retrying_client(&server.base_url());
860 let result = client.list_hosts(¶ms).await;
861
862 assert!(result.is_err());
863 mock.assert_calls(3);
865 }
866
867 #[tokio::test]
868 async fn no_retry_on_400() {
869 use crate::types_impl::pagination::PaginationParams;
870 use httpmock::prelude::*;
871
872 let server = MockServer::start_async().await;
873 let mock = server.mock(|when, then| {
874 when.method(GET).path("/api/v1/hosts");
875 then.status(400).body(r#"{"error":"bad request"}"#);
876 });
877
878 let params = PaginationParams {
879 page: None,
880 per_page: None,
881 };
882 let client = retrying_client(&server.base_url());
883 let result = client.list_hosts(¶ms).await;
884
885 assert!(result.is_err());
886 mock.assert_calls(1); }
888
889 #[tokio::test]
890 async fn no_retry_on_401() {
891 use crate::types_impl::pagination::PaginationParams;
892 use httpmock::prelude::*;
893
894 let server = MockServer::start_async().await;
895 let mock = server.mock(|when, then| {
896 when.method(GET).path("/api/v1/hosts");
897 then.status(401).body(r#"{"error":"unauthorized"}"#);
898 });
899
900 let params = PaginationParams {
901 page: None,
902 per_page: None,
903 };
904 let client = retrying_client(&server.base_url());
905 let result = client.list_hosts(¶ms).await;
906
907 assert!(result.is_err());
908 mock.assert_calls(1); }
910
911 #[tokio::test]
912 async fn retry_exhausted_on_repeated_429() {
913 use crate::types_impl::pagination::PaginationParams;
914 use httpmock::prelude::*;
915
916 let server = MockServer::start_async().await;
917 let mock = server.mock(|when, then| {
918 when.method(GET).path("/api/v1/hosts");
919 then.status(429)
920 .header("Retry-After", "1")
921 .body(r#"{"error":"rate limited"}"#);
922 });
923
924 let params = PaginationParams {
925 page: None,
926 per_page: None,
927 };
928 let client = retrying_client(&server.base_url());
929 let result = client.list_hosts(¶ms).await;
930
931 assert!(result.is_err());
932 mock.assert_calls(3);
934 }
935
936 fn host_json(id: &str) -> serde_json::Value {
940 serde_json::json!({
941 "id": id,
942 "machine_id": format!("machine-{id}"),
943 "hostname": format!("host-{id}"),
944 "friendly_name": format!("Host {id}"),
945 "os_type": null,
946 "os_version": null,
947 "architecture": null,
948 "ip_address": null,
949 "last_seen_at": null,
950 "created_at": "2024-01-01T00:00:00Z",
951 "updated_at": "2024-01-01T00:00:00Z",
952 "agents": [],
953 "tags": []
954 })
955 }
956
957 fn paginated_hosts_json(
958 items: Vec<serde_json::Value>,
959 total: u64,
960 page: u64,
961 total_pages: u64,
962 ) -> serde_json::Value {
963 serde_json::json!({
964 "items": items,
965 "total": total,
966 "page": page,
967 "per_page": 1000,
968 "total_pages": total_pages
969 })
970 }
971
972 #[tokio::test]
973 async fn list_all_hosts_multi_page() {
974 use httpmock::prelude::*;
975
976 let server = MockServer::start_async().await;
977
978 let h1 = host_json("550e8400-e29b-41d4-a716-446655440001");
979 let h2 = host_json("550e8400-e29b-41d4-a716-446655440002");
980 let h3 = host_json("550e8400-e29b-41d4-a716-446655440003");
981
982 server.mock(|when, then| {
983 when.method(GET)
984 .path("/api/v1/hosts")
985 .query_param("page", "1")
986 .query_param("per_page", "1000");
987 then.status(200)
988 .header("Content-Type", "application/json")
989 .json_body(paginated_hosts_json(vec![h1.clone()], 3, 1, 3));
990 });
991 server.mock(|when, then| {
992 when.method(GET)
993 .path("/api/v1/hosts")
994 .query_param("page", "2")
995 .query_param("per_page", "1000");
996 then.status(200)
997 .header("Content-Type", "application/json")
998 .json_body(paginated_hosts_json(vec![h2.clone()], 3, 2, 3));
999 });
1000 server.mock(|when, then| {
1001 when.method(GET)
1002 .path("/api/v1/hosts")
1003 .query_param("page", "3")
1004 .query_param("per_page", "1000");
1005 then.status(200)
1006 .header("Content-Type", "application/json")
1007 .json_body(paginated_hosts_json(vec![h3.clone()], 3, 3, 3));
1008 });
1009
1010 let client =
1011 UptrakitClient::with_token(&server.base_url(), "tok", false, None).expect("client");
1012 let all = client.list_all_hosts().await.expect("list_all_hosts");
1013 assert_eq!(all.len(), 3);
1014 assert_eq!(
1015 all[0].machine_id,
1016 "machine-550e8400-e29b-41d4-a716-446655440001"
1017 );
1018 assert_eq!(
1019 all[2].machine_id,
1020 "machine-550e8400-e29b-41d4-a716-446655440003"
1021 );
1022 }
1023
1024 #[tokio::test]
1025 async fn list_all_hosts_single_page() {
1026 use httpmock::prelude::*;
1027
1028 let server = MockServer::start_async().await;
1029 let h1 = host_json("550e8400-e29b-41d4-a716-000000000001");
1030 let h2 = host_json("550e8400-e29b-41d4-a716-000000000002");
1031
1032 server.mock(|when, then| {
1033 when.method(GET).path("/api/v1/hosts");
1034 then.status(200)
1035 .header("Content-Type", "application/json")
1036 .json_body(paginated_hosts_json(vec![h1, h2], 2, 1, 1));
1037 });
1038
1039 let client =
1040 UptrakitClient::with_token(&server.base_url(), "tok", false, None).expect("client");
1041 let all = client.list_all_hosts().await.expect("list_all_hosts");
1042 assert_eq!(all.len(), 2);
1043 }
1044
1045 #[tokio::test]
1046 async fn list_all_hosts_empty() {
1047 use httpmock::prelude::*;
1048
1049 let server = MockServer::start_async().await;
1050
1051 server.mock(|when, then| {
1052 when.method(GET).path("/api/v1/hosts");
1053 then.status(200)
1054 .header("Content-Type", "application/json")
1055 .json_body(paginated_hosts_json(vec![], 0, 1, 0));
1056 });
1057
1058 let client =
1059 UptrakitClient::with_token(&server.base_url(), "tok", false, None).expect("client");
1060 let all = client.list_all_hosts().await.expect("list_all_hosts");
1061 assert!(all.is_empty());
1062 }
1063
1064 #[tokio::test]
1065 async fn list_all_hosts_forwards_page_params() {
1066 use crate::types_impl::pagination::MAX_PER_PAGE;
1067 use httpmock::prelude::*;
1068
1069 let server = MockServer::start_async().await;
1070
1071 let page_param_mock = server.mock(|when, then| {
1073 when.method(GET)
1074 .path("/api/v1/hosts")
1075 .query_param("page", "1")
1076 .query_param("per_page", MAX_PER_PAGE.to_string());
1077 then.status(200)
1078 .header("Content-Type", "application/json")
1079 .json_body(paginated_hosts_json(vec![], 0, 1, 0));
1080 });
1081
1082 let client =
1083 UptrakitClient::with_token(&server.base_url(), "tok", false, None).expect("client");
1084 client.list_all_hosts().await.expect("list_all_hosts");
1085
1086 page_param_mock.assert_calls(1);
1087 }
1088
1089 #[test]
1090 fn new_with_ca_pem_none_succeeds() {
1091 let client =
1092 UptrakitClient::new("https://example.com", None, false, None, None).expect("client");
1093 assert!(client.token.is_none());
1094 }
1095
1096 #[test]
1097 fn new_with_insecure_ignores_invalid_ca_pem() {
1098 let client =
1100 UptrakitClient::new("https://example.com", None, true, Some("not-a-pem"), None);
1101 assert!(client.is_ok());
1102 }
1103}