1#[cfg(feature = "mock")]
2pub mod mock;
3
4pub(crate) mod paths;
5
6pub mod access_presets;
7pub mod api_tokens;
8pub mod audit_logs;
9pub mod auth;
10pub mod autodiscovery;
11pub mod batch_progress_stream;
12pub mod device_auth_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 oidc_auth;
22pub mod oidc_providers;
23pub mod permissions;
24pub mod pki;
25pub mod plugin_configs;
26pub mod plugin_type_settings;
27pub mod roles;
28pub mod scheduler;
29pub mod services;
30pub mod settings;
31pub mod settings_nats;
32pub mod settings_provider_github;
33pub mod software_items;
34pub mod sse;
35pub mod surfaces;
36pub mod system_alerts;
37pub mod system_enrollment_tokens;
38pub mod system_services;
39pub mod update_batches;
40pub mod update_history;
41pub mod update_output_stream;
42pub mod users;
43
44pub use error::{ClientError, Result};
45
46pub use uptrakit_shared_types::DeviceAuthStatus;
47pub use uptrakit_web_api_types as types;
48
49pub(crate) mod types_impl {
50 pub(crate) use uptrakit_web_api_types::*;
51}
52
53pub(crate) mod shared_types_impl {
54 #[allow(unused_imports)]
55 pub(crate) use uptrakit_shared_types::*;
56}
57
58pub use uuid::Uuid;
61
62pub use reqwest::Error as ReqwestError;
65
66pub use reqwest::StatusCode;
69
70use rootcause::prelude::*;
71use serde::Serialize;
72use serde::de::DeserializeOwned;
73use std::time::Duration;
74
75fn serialize_status_code<S: serde::Serializer>(
77 status: &reqwest::StatusCode,
78 serializer: S,
79) -> std::result::Result<S::Ok, S::Error> {
80 serializer.serialize_u16(status.as_u16())
81}
82
83#[derive(Debug, Serialize)]
85pub struct RawResponse {
86 #[serde(serialize_with = "serialize_status_code")]
87 pub status: reqwest::StatusCode,
88 pub body: serde_json::Value,
89}
90
91#[derive(Debug, Clone)]
105pub struct RetryConfig {
106 pub max_retries: u32,
109 pub initial_delay: Duration,
112 pub max_delay: Duration,
115}
116
117impl Default for RetryConfig {
118 fn default() -> Self {
119 Self {
120 max_retries: 3,
121 initial_delay: Duration::from_secs(1),
122 max_delay: Duration::from_secs(30),
123 }
124 }
125}
126
127pub struct UptrakitClient {
132 http: reqwest::Client,
133 base_url: String,
134 token: Option<String>,
135 retry: Option<RetryConfig>,
136}
137
138impl UptrakitClient {
139 const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
141
142 const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
144
145 pub fn new(
152 base_url: &str,
153 token: Option<&str>,
154 insecure: bool,
155 request_timeout: Option<Duration>,
156 ) -> Result<Self> {
157 let timeout = request_timeout.unwrap_or(Self::DEFAULT_REQUEST_TIMEOUT);
158 let mut builder = reqwest::Client::builder()
159 .connect_timeout(Self::DEFAULT_CONNECT_TIMEOUT)
160 .timeout(timeout);
161 if insecure {
162 builder = builder.tls_danger_accept_invalid_certs(true);
163 }
164 let http = builder.build().context_to()?;
165
166 Ok(Self {
167 http,
168 base_url: base_url.trim_end_matches('/').to_string(),
169 token: token.map(|t| t.to_string()),
170 retry: None,
171 })
172 }
173
174 pub fn with_token(base_url: &str, token: &str, insecure: bool) -> Result<Self> {
176 Self::new(base_url, Some(token), insecure, None)
177 }
178
179 pub fn with_retry(mut self, config: RetryConfig) -> Self {
185 self.retry = Some(config);
186 self
187 }
188
189 pub async fn raw_request(
191 &self,
192 method: &str,
193 path: &str,
194 body: Option<serde_json::Value>,
195 ) -> Result<RawResponse> {
196 let url = format!("{}{}", self.base_url, path);
197 let method = method.to_uppercase();
198 let req_method = method
199 .parse::<reqwest::Method>()
200 .map_err(|e| report!(ClientError::InvalidMethod(e.to_string())))?;
201
202 let mut req = self.http.request(req_method, &url);
203 if let Some(token) = &self.token {
204 req = req.bearer_auth(token);
205 }
206 if let Some(body) = body {
207 req = req.json(&body);
208 }
209
210 let resp = req.send().await.context_to()?;
211 let status = resp.status();
212 let text = resp.text().await.context_to()?;
213
214 let body = if text.is_empty() {
215 serde_json::Value::Null
216 } else {
217 serde_json::from_str(&text).unwrap_or(serde_json::Value::String(text))
218 };
219
220 Ok(RawResponse { status, body })
221 }
222
223 fn token_or_err(&self) -> Result<&str> {
226 self.token
227 .as_deref()
228 .ok_or_else(|| report!(ClientError::NotAuthenticated))
229 }
230
231 async fn send_with_retry(&self, req: reqwest::RequestBuilder) -> Result<reqwest::Response> {
237 let Some(retry) = &self.retry else {
238 return req.send().await.context_to();
239 };
240
241 let retry_builders: Vec<reqwest::RequestBuilder> = (0..retry.max_retries)
245 .map_while(|_| req.try_clone())
246 .collect();
247
248 let mut resp = req.send().await.context_to()?;
249
250 for (attempt, retry_req) in retry_builders.into_iter().enumerate() {
251 let status = resp.status();
252 let delay = if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
253 parse_retry_after(&resp)
255 .map(Duration::from_secs)
256 .unwrap_or(retry.initial_delay)
257 .min(retry.max_delay)
258 } else if status.is_server_error() {
259 let factor = 1u32.checked_shl(attempt as u32).unwrap_or(u32::MAX);
261 retry
262 .initial_delay
263 .saturating_mul(factor)
264 .min(retry.max_delay)
265 } else {
266 return Ok(resp);
268 };
269
270 tokio::time::sleep(delay).await;
271 resp = retry_req.send().await.context_to()?;
272 }
273
274 Ok(resp)
275 }
276
277 pub(crate) async fn fetch_all_pages<T: DeserializeOwned + Send>(
285 &self,
286 path: &str,
287 base_query: &impl Serialize,
288 ) -> Result<Vec<T>> {
289 use crate::types_impl::pagination::{MAX_PER_PAGE, PaginatedResponse};
290
291 let base_value = serde_json::to_value(base_query).context_to()?;
292 let mut all: Vec<T> = Vec::new();
293 let mut page: u64 = 1;
294 loop {
295 let mut query = base_value.clone();
296 if let Some(obj) = query.as_object_mut() {
297 obj.insert("page".to_string(), serde_json::json!(page));
298 obj.insert("per_page".to_string(), serde_json::json!(MAX_PER_PAGE));
299 }
300 let resp: PaginatedResponse<T> = self.get_with_query(path, &query).await?;
301 let total_pages = resp.total_pages;
302 all.extend(resp.items);
303 if page >= total_pages || total_pages == 0 {
304 break;
305 }
306 page += 1;
307 }
308 Ok(all)
309 }
310
311 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
312 let url = format!("{}{}", self.base_url, path);
313 let req = self.http.get(&url).bearer_auth(self.token_or_err()?);
314 let resp = self.send_with_retry(req).await?;
315 self.handle_response(resp).await
316 }
317
318 async fn get_with_query<T: DeserializeOwned>(
319 &self,
320 path: &str,
321 query: &impl Serialize,
322 ) -> Result<T> {
323 let url = format!("{}{}", self.base_url, path);
324 let req = self
325 .http
326 .get(&url)
327 .bearer_auth(self.token_or_err()?)
328 .query(query);
329 let resp = self.send_with_retry(req).await?;
330 self.handle_response(resp).await
331 }
332
333 async fn post_json<T: DeserializeOwned>(&self, path: &str, body: &impl Serialize) -> Result<T> {
334 let url = format!("{}{}", self.base_url, path);
335 let req = self
336 .http
337 .post(&url)
338 .bearer_auth(self.token_or_err()?)
339 .json(body);
340 let resp = self.send_with_retry(req).await?;
341 self.handle_response(resp).await
342 }
343
344 async fn post_empty<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
345 let url = format!("{}{}", self.base_url, path);
346 let req = self.http.post(&url).bearer_auth(self.token_or_err()?);
347 let resp = self.send_with_retry(req).await?;
348 self.handle_response(resp).await
349 }
350
351 async fn post_json_unauth<T: DeserializeOwned>(
353 &self,
354 path: &str,
355 body: &impl Serialize,
356 ) -> Result<T> {
357 let url = format!("{}{}", self.base_url, path);
358 let req = self.http.post(&url).json(body);
359 let resp = self.send_with_retry(req).await?;
360 self.handle_response(resp).await
361 }
362
363 async fn delete(&self, path: &str) -> Result<()> {
364 let url = format!("{}{}", self.base_url, path);
365 let req = self.http.delete(&url).bearer_auth(self.token_or_err()?);
366 let resp = self.send_with_retry(req).await?;
367 self.handle_empty_response(resp).await
368 }
369
370 #[allow(dead_code)]
371 async fn delete_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
372 let url = format!("{}{}", self.base_url, path);
373 let req = self.http.delete(&url).bearer_auth(self.token_or_err()?);
374 let resp = self.send_with_retry(req).await?;
375 self.handle_response(resp).await
376 }
377
378 async fn delete_with_query(&self, path: &str, query: &impl Serialize) -> Result<()> {
379 let url = format!("{}{}", self.base_url, path);
380 let req = self
381 .http
382 .delete(&url)
383 .bearer_auth(self.token_or_err()?)
384 .query(query);
385 let resp = self.send_with_retry(req).await?;
386 self.handle_empty_response(resp).await
387 }
388
389 #[allow(dead_code)]
390 async fn delete_with_query_json<T: DeserializeOwned>(
391 &self,
392 path: &str,
393 query: &impl Serialize,
394 ) -> Result<T> {
395 let url = format!("{}{}", self.base_url, path);
396 let req = self
397 .http
398 .delete(&url)
399 .bearer_auth(self.token_or_err()?)
400 .query(query);
401 let resp = self.send_with_retry(req).await?;
402 self.handle_response(resp).await
403 }
404
405 async fn put_json<T: DeserializeOwned>(&self, path: &str, body: &impl Serialize) -> Result<T> {
406 let url = format!("{}{}", self.base_url, path);
407 let req = self
408 .http
409 .put(&url)
410 .bearer_auth(self.token_or_err()?)
411 .json(body);
412 let resp = self.send_with_retry(req).await?;
413 self.handle_response(resp).await
414 }
415
416 async fn post_json_no_content(&self, path: &str, body: &impl Serialize) -> Result<()> {
418 let url = format!("{}{}", self.base_url, path);
419 let req = self
420 .http
421 .post(&url)
422 .bearer_auth(self.token_or_err()?)
423 .json(body);
424 let resp = self.send_with_retry(req).await?;
425 self.handle_empty_response(resp).await
426 }
427
428 async fn get_unauth<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
430 let url = format!("{}{}", self.base_url, path);
431 let req = self.http.get(&url);
432 let resp = self.send_with_retry(req).await?;
433 self.handle_response(resp).await
434 }
435
436 async fn get_text_unauth(&self, path: &str) -> Result<String> {
438 let url = format!("{}{}", self.base_url, path);
439 let req = self.http.get(&url);
440 let resp = self.send_with_retry(req).await?;
441 self.handle_text_response(resp).await
442 }
443
444 async fn handle_response<T: DeserializeOwned>(&self, resp: reqwest::Response) -> Result<T> {
445 let status = resp.status();
446 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
447 let retry_after = parse_retry_after(&resp);
448 bail!(ClientError::RateLimited {
449 retry_after_seconds: retry_after,
450 });
451 }
452 if status == reqwest::StatusCode::UNAUTHORIZED {
453 bail!(ClientError::NotAuthenticated);
454 }
455 let text = resp.text().await.context_to()?;
456 if status == reqwest::StatusCode::NOT_FOUND {
457 let message = extract_error_message(&text);
458 bail!(ClientError::NotFound(message));
459 }
460 if status.is_client_error() || status.is_server_error() {
461 let message = extract_error_message(&text);
462 bail!(ClientError::Api { status, message });
463 }
464 serde_json::from_str(&text).context_to()
465 }
466
467 async fn handle_empty_response(&self, resp: reqwest::Response) -> Result<()> {
468 let status = resp.status();
469 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
470 let retry_after = parse_retry_after(&resp);
471 bail!(ClientError::RateLimited {
472 retry_after_seconds: retry_after,
473 });
474 }
475 if status == reqwest::StatusCode::UNAUTHORIZED {
476 bail!(ClientError::NotAuthenticated);
477 }
478 if status == reqwest::StatusCode::NOT_FOUND {
479 let text = resp.text().await.context_to()?;
480 let message = extract_error_message(&text);
481 bail!(ClientError::NotFound(message));
482 }
483 if status.is_client_error() || status.is_server_error() {
484 let text = resp.text().await.context_to()?;
485 let message = extract_error_message(&text);
486 bail!(ClientError::Api { status, message });
487 }
488 Ok(())
489 }
490
491 async fn handle_text_response(&self, resp: reqwest::Response) -> Result<String> {
492 let status = resp.status();
493 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
494 let retry_after = parse_retry_after(&resp);
495 bail!(ClientError::RateLimited {
496 retry_after_seconds: retry_after,
497 });
498 }
499 if status == reqwest::StatusCode::UNAUTHORIZED {
500 bail!(ClientError::NotAuthenticated);
501 }
502 let text = resp.text().await.context_to()?;
503 if status == reqwest::StatusCode::NOT_FOUND {
504 let message = extract_error_message(&text);
505 bail!(ClientError::NotFound(message));
506 }
507 if status.is_client_error() || status.is_server_error() {
508 let message = extract_error_message(&text);
509 bail!(ClientError::Api { status, message });
510 }
511 Ok(text)
512 }
513}
514
515fn parse_retry_after(resp: &reqwest::Response) -> Option<u64> {
520 resp.headers()
521 .get(reqwest::header::RETRY_AFTER)?
522 .to_str()
523 .ok()?
524 .parse::<u64>()
525 .ok()
526}
527
528pub(crate) fn extract_error_message(text: &str) -> String {
531 serde_json::from_str::<serde_json::Value>(text)
532 .ok()
533 .and_then(|v| v["error"].as_str().map(|s| s.to_string()))
534 .unwrap_or_else(|| {
535 if text.is_empty() {
536 "Request failed".to_string()
537 } else {
538 text.to_string()
539 }
540 })
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 #[test]
548 fn extract_error_message_from_json() {
549 let text = r#"{"error":"Not found"}"#;
550 assert_eq!(extract_error_message(text), "Not found");
551 }
552
553 #[test]
554 fn extract_error_message_from_json_without_error_field() {
555 let text = r#"{"message":"something"}"#;
556 assert_eq!(extract_error_message(text), text);
557 }
558
559 #[test]
560 fn extract_error_message_from_plain_text() {
561 let text = "Internal Server Error";
562 assert_eq!(extract_error_message(text), "Internal Server Error");
563 }
564
565 #[test]
566 fn extract_error_message_from_empty() {
567 assert_eq!(extract_error_message(""), "Request failed");
568 }
569
570 #[test]
571 fn base_url_trailing_slash_is_trimmed() {
572 let client = UptrakitClient::new("https://example.com/", None, false, None)
573 .expect("client creation");
574 assert_eq!(client.base_url, "https://example.com");
575 }
576
577 #[test]
578 fn base_url_without_trailing_slash_is_unchanged() {
579 let client =
580 UptrakitClient::new("https://example.com", None, false, None).expect("client creation");
581 assert_eq!(client.base_url, "https://example.com");
582 }
583
584 #[test]
585 fn with_token_stores_token() {
586 let client = UptrakitClient::with_token("https://example.com", "tok-123", false)
587 .expect("client creation");
588 assert_eq!(client.token.as_deref(), Some("tok-123"));
589 }
590
591 #[test]
592 fn new_without_token_stores_none() {
593 let client =
594 UptrakitClient::new("https://example.com", None, false, None).expect("client creation");
595 assert!(client.token.is_none());
596 }
597
598 #[test]
599 fn token_or_err_returns_token_when_present() {
600 let client = UptrakitClient::with_token("https://example.com", "tok", false)
601 .expect("client creation");
602 assert_eq!(client.token_or_err().expect("token"), "tok");
603 }
604
605 #[test]
606 fn token_or_err_returns_error_when_absent() {
607 let client =
608 UptrakitClient::new("https://example.com", None, false, None).expect("client creation");
609 let err = client.token_or_err().unwrap_err();
610 assert!(
611 matches!(err.current_context(), ClientError::NotAuthenticated),
612 "expected NotAuthenticated, got: {err}"
613 );
614 }
615
616 #[test]
617 fn parse_retry_after_valid_seconds() {
618 let resp = http::Response::builder()
619 .status(http::StatusCode::TOO_MANY_REQUESTS)
620 .header("Retry-After", "60")
621 .body("")
622 .unwrap();
623 let reqwest_resp = reqwest::Response::from(resp);
624 assert_eq!(parse_retry_after(&reqwest_resp), Some(60));
625 }
626
627 #[test]
628 fn parse_retry_after_missing_header() {
629 let resp = http::Response::builder()
630 .status(http::StatusCode::TOO_MANY_REQUESTS)
631 .body("")
632 .unwrap();
633 let reqwest_resp = reqwest::Response::from(resp);
634 assert_eq!(parse_retry_after(&reqwest_resp), None);
635 }
636
637 #[test]
638 fn parse_retry_after_non_numeric() {
639 let resp = http::Response::builder()
640 .status(http::StatusCode::TOO_MANY_REQUESTS)
641 .header("Retry-After", "Wed, 21 Oct 2025 07:28:00 GMT")
642 .body("")
643 .unwrap();
644 let reqwest_resp = reqwest::Response::from(resp);
645 assert_eq!(parse_retry_after(&reqwest_resp), None);
646 }
647
648 #[test]
649 fn raw_response_serialization() {
650 let resp = RawResponse {
651 status: reqwest::StatusCode::OK,
652 body: serde_json::json!({"key": "value"}),
653 };
654 let json = serde_json::to_string(&resp).expect("serialize");
655 let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse");
656 assert_eq!(parsed["status"], 200);
657 assert_eq!(parsed["body"]["key"], "value");
658 }
659
660 #[test]
661 fn default_client_has_no_retry() {
662 let client = UptrakitClient::new("https://example.com", None, false, None).expect("client");
663 assert!(client.retry.is_none());
664 }
665
666 #[test]
667 fn with_retry_sets_config() {
668 let client = UptrakitClient::new("https://example.com", None, false, None)
669 .expect("client")
670 .with_retry(RetryConfig::default());
671 assert!(client.retry.is_some());
672 }
673
674 #[test]
675 fn retry_config_default_values() {
676 let config = RetryConfig::default();
677 assert_eq!(config.max_retries, 3);
678 assert_eq!(config.initial_delay, Duration::from_secs(1));
679 assert_eq!(config.max_delay, Duration::from_secs(30));
680 }
681
682 #[cfg(test)]
684 fn retrying_client(base_url: &str) -> UptrakitClient {
685 UptrakitClient::with_token(base_url, "test-token", false)
686 .expect("client")
687 .with_retry(RetryConfig {
688 max_retries: 2,
689 initial_delay: Duration::from_millis(1),
690 max_delay: Duration::from_millis(10),
691 })
692 }
693
694 #[tokio::test]
697 async fn retry_exhausted_on_repeated_503() {
698 use crate::types_impl::pagination::PaginationParams;
699 use httpmock::prelude::*;
700
701 let server = MockServer::start_async().await;
702 let mock = server.mock(|when, then| {
703 when.method(GET).path("/api/v1/hosts");
704 then.status(503).body(r#"{"error":"down"}"#);
705 });
706
707 let params = PaginationParams {
708 page: None,
709 per_page: None,
710 };
711 let client = retrying_client(&server.base_url());
712 let result = client.list_hosts(¶ms).await;
713
714 assert!(result.is_err());
715 mock.assert_calls(3);
717 }
718
719 #[tokio::test]
720 async fn no_retry_on_400() {
721 use crate::types_impl::pagination::PaginationParams;
722 use httpmock::prelude::*;
723
724 let server = MockServer::start_async().await;
725 let mock = server.mock(|when, then| {
726 when.method(GET).path("/api/v1/hosts");
727 then.status(400).body(r#"{"error":"bad request"}"#);
728 });
729
730 let params = PaginationParams {
731 page: None,
732 per_page: None,
733 };
734 let client = retrying_client(&server.base_url());
735 let result = client.list_hosts(¶ms).await;
736
737 assert!(result.is_err());
738 mock.assert_calls(1); }
740
741 #[tokio::test]
742 async fn no_retry_on_401() {
743 use crate::types_impl::pagination::PaginationParams;
744 use httpmock::prelude::*;
745
746 let server = MockServer::start_async().await;
747 let mock = server.mock(|when, then| {
748 when.method(GET).path("/api/v1/hosts");
749 then.status(401).body(r#"{"error":"unauthorized"}"#);
750 });
751
752 let params = PaginationParams {
753 page: None,
754 per_page: None,
755 };
756 let client = retrying_client(&server.base_url());
757 let result = client.list_hosts(¶ms).await;
758
759 assert!(result.is_err());
760 mock.assert_calls(1); }
762
763 #[tokio::test]
764 async fn retry_exhausted_on_repeated_429() {
765 use crate::types_impl::pagination::PaginationParams;
766 use httpmock::prelude::*;
767
768 let server = MockServer::start_async().await;
769 let mock = server.mock(|when, then| {
770 when.method(GET).path("/api/v1/hosts");
771 then.status(429)
772 .header("Retry-After", "1")
773 .body(r#"{"error":"rate limited"}"#);
774 });
775
776 let params = PaginationParams {
777 page: None,
778 per_page: None,
779 };
780 let client = retrying_client(&server.base_url());
781 let result = client.list_hosts(¶ms).await;
782
783 assert!(result.is_err());
784 mock.assert_calls(3);
786 }
787
788 fn host_json(id: &str) -> serde_json::Value {
792 serde_json::json!({
793 "id": id,
794 "machine_id": format!("machine-{id}"),
795 "hostname": format!("host-{id}"),
796 "friendly_name": format!("Host {id}"),
797 "os_type": null,
798 "os_version": null,
799 "architecture": null,
800 "ip_address": null,
801 "last_seen_at": null,
802 "created_at": "2024-01-01T00:00:00Z",
803 "updated_at": "2024-01-01T00:00:00Z",
804 "agents": [],
805 "tags": []
806 })
807 }
808
809 fn paginated_hosts_json(
810 items: Vec<serde_json::Value>,
811 total: u64,
812 page: u64,
813 total_pages: u64,
814 ) -> serde_json::Value {
815 serde_json::json!({
816 "items": items,
817 "total": total,
818 "page": page,
819 "per_page": 1000,
820 "total_pages": total_pages
821 })
822 }
823
824 #[tokio::test]
825 async fn list_all_hosts_multi_page() {
826 use httpmock::prelude::*;
827
828 let server = MockServer::start_async().await;
829
830 let h1 = host_json("550e8400-e29b-41d4-a716-446655440001");
831 let h2 = host_json("550e8400-e29b-41d4-a716-446655440002");
832 let h3 = host_json("550e8400-e29b-41d4-a716-446655440003");
833
834 server.mock(|when, then| {
835 when.method(GET)
836 .path("/api/v1/hosts")
837 .query_param("page", "1")
838 .query_param("per_page", "1000");
839 then.status(200)
840 .header("Content-Type", "application/json")
841 .json_body(paginated_hosts_json(vec![h1.clone()], 3, 1, 3));
842 });
843 server.mock(|when, then| {
844 when.method(GET)
845 .path("/api/v1/hosts")
846 .query_param("page", "2")
847 .query_param("per_page", "1000");
848 then.status(200)
849 .header("Content-Type", "application/json")
850 .json_body(paginated_hosts_json(vec![h2.clone()], 3, 2, 3));
851 });
852 server.mock(|when, then| {
853 when.method(GET)
854 .path("/api/v1/hosts")
855 .query_param("page", "3")
856 .query_param("per_page", "1000");
857 then.status(200)
858 .header("Content-Type", "application/json")
859 .json_body(paginated_hosts_json(vec![h3.clone()], 3, 3, 3));
860 });
861
862 let client = UptrakitClient::with_token(&server.base_url(), "tok", false).expect("client");
863 let all = client.list_all_hosts().await.expect("list_all_hosts");
864 assert_eq!(all.len(), 3);
865 assert_eq!(
866 all[0].machine_id,
867 "machine-550e8400-e29b-41d4-a716-446655440001"
868 );
869 assert_eq!(
870 all[2].machine_id,
871 "machine-550e8400-e29b-41d4-a716-446655440003"
872 );
873 }
874
875 #[tokio::test]
876 async fn list_all_hosts_single_page() {
877 use httpmock::prelude::*;
878
879 let server = MockServer::start_async().await;
880 let h1 = host_json("550e8400-e29b-41d4-a716-000000000001");
881 let h2 = host_json("550e8400-e29b-41d4-a716-000000000002");
882
883 server.mock(|when, then| {
884 when.method(GET).path("/api/v1/hosts");
885 then.status(200)
886 .header("Content-Type", "application/json")
887 .json_body(paginated_hosts_json(vec![h1, h2], 2, 1, 1));
888 });
889
890 let client = UptrakitClient::with_token(&server.base_url(), "tok", false).expect("client");
891 let all = client.list_all_hosts().await.expect("list_all_hosts");
892 assert_eq!(all.len(), 2);
893 }
894
895 #[tokio::test]
896 async fn list_all_hosts_empty() {
897 use httpmock::prelude::*;
898
899 let server = MockServer::start_async().await;
900
901 server.mock(|when, then| {
902 when.method(GET).path("/api/v1/hosts");
903 then.status(200)
904 .header("Content-Type", "application/json")
905 .json_body(paginated_hosts_json(vec![], 0, 1, 0));
906 });
907
908 let client = UptrakitClient::with_token(&server.base_url(), "tok", false).expect("client");
909 let all = client.list_all_hosts().await.expect("list_all_hosts");
910 assert!(all.is_empty());
911 }
912
913 #[tokio::test]
914 async fn list_all_hosts_forwards_page_params() {
915 use crate::types_impl::pagination::MAX_PER_PAGE;
916 use httpmock::prelude::*;
917
918 let server = MockServer::start_async().await;
919
920 let page_param_mock = server.mock(|when, then| {
922 when.method(GET)
923 .path("/api/v1/hosts")
924 .query_param("page", "1")
925 .query_param("per_page", MAX_PER_PAGE.to_string());
926 then.status(200)
927 .header("Content-Type", "application/json")
928 .json_body(paginated_hosts_json(vec![], 0, 1, 0));
929 });
930
931 let client = UptrakitClient::with_token(&server.base_url(), "tok", false).expect("client");
932 client.list_all_hosts().await.expect("list_all_hosts");
933
934 page_param_mock.assert_calls(1);
935 }
936}