Skip to main content

uptrakit_openapi_client/
lib.rs

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
58/// Re-export `Uuid` so that downstream crates can use the exact same type
59/// without adding a direct `uuid` dependency.
60pub use uuid::Uuid;
61
62/// Re-export `reqwest::Error` so that downstream crates (e.g. the CLI)
63/// do not need a direct dependency on `reqwest`.
64pub use reqwest::Error as ReqwestError;
65
66/// Re-export `reqwest::StatusCode` so that downstream crates (e.g. the CLI)
67/// do not need a direct dependency on `reqwest` for HTTP status handling.
68pub use reqwest::StatusCode;
69
70use rootcause::prelude::*;
71use serde::Serialize;
72use serde::de::DeserializeOwned;
73use std::time::Duration;
74
75/// Serialize a `StatusCode` as its numeric `u16` value for JSON wire compatibility.
76fn 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/// Response from a raw (untyped) API request.
84#[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/// Configuration for automatic retry on transient failures.
92///
93/// Apply with [`UptrakitClient::with_retry`]. By default the client fails fast
94/// with no retries; call `with_retry(RetryConfig::default())` to enable.
95///
96/// Retries are applied to:
97/// - **HTTP 429 Too Many Requests**: respects the `Retry-After` header if
98///   present (numeric seconds only); falls back to `initial_delay`.
99/// - **HTTP 5xx Server Error**: exponential backoff starting at `initial_delay`,
100///   doubling on each attempt, capped at `max_delay`.
101///
102/// No retry is attempted for 4xx client errors, network errors, or authentication
103/// failures — these are not transient.
104#[derive(Debug, Clone)]
105pub struct RetryConfig {
106    /// Number of additional attempts after the initial request fails.
107    /// Default: 3.
108    pub max_retries: u32,
109    /// Delay before the first retry (and base for exponential backoff).
110    /// Default: 1 second.
111    pub initial_delay: Duration,
112    /// Upper bound on any single inter-retry delay.
113    /// Default: 30 seconds.
114    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
127/// Typed HTTP client for the Uptrakit web API.
128///
129/// Provides compile-time type safety for all API endpoints by using shared
130/// request/response types from `uptrakit-web-api-types`.
131pub struct UptrakitClient {
132    http: reqwest::Client,
133    base_url: String,
134    token: Option<String>,
135    retry: Option<RetryConfig>,
136}
137
138impl UptrakitClient {
139    /// Default connect timeout for the HTTP client (10 seconds).
140    const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
141
142    /// Default request timeout for the HTTP client (30 seconds).
143    const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
144
145    /// Create a new client. Pass `token: None` for unauthenticated endpoints
146    /// (e.g. device auth start/poll).
147    ///
148    /// `request_timeout` overrides [`DEFAULT_REQUEST_TIMEOUT`] when `Some`.
149    ///
150    /// [`DEFAULT_REQUEST_TIMEOUT`]: Self::DEFAULT_REQUEST_TIMEOUT
151    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    /// Create a client with a required bearer token.
175    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    /// Enable automatic retry on transient failures (429 and 5xx).
180    ///
181    /// Returns a new client with the given retry configuration. By default,
182    /// the client fails fast with no retries. Retries use exponential backoff
183    /// for 5xx errors and respect `Retry-After` headers for 429 errors.
184    pub fn with_retry(mut self, config: RetryConfig) -> Self {
185        self.retry = Some(config);
186        self
187    }
188
189    /// Execute a raw (untyped) API request. Used by the CLI `api` escape-hatch command.
190    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    // ── Internal helpers ──────────────────────────────────────────────
224
225    fn token_or_err(&self) -> Result<&str> {
226        self.token
227            .as_deref()
228            .ok_or_else(|| report!(ClientError::NotAuthenticated))
229    }
230
231    /// Send a request, retrying automatically on 429 and 5xx responses.
232    ///
233    /// Without a [`RetryConfig`] (the default), this is a direct single-shot
234    /// `send()`. Retries use exponential backoff (5xx) or the `Retry-After`
235    /// header (429). 4xx and network errors are never retried.
236    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        // Pre-clone the builder for every potential retry before the first send
242        // consumes it. `try_clone` returns `None` for streaming bodies; the
243        // collected vec will just be shorter, reducing effective retry count.
244        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                // Respect Retry-After header; fall back to initial_delay.
254                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                // Exponential backoff: initial, 2×initial, 4×initial, …, capped.
260                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                // Not retriable — return as-is.
267                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    /// Fetch all pages from a paginated list endpoint, accumulating every item.
278    ///
279    /// Serialises `base_query` to JSON, then overrides `page` and `per_page`
280    /// (set to [`MAX_PER_PAGE`]) on each iteration. Stops when
281    /// `page >= total_pages` or the first page reports zero total pages.
282    ///
283    /// [`MAX_PER_PAGE`]: uptrakit_web_api_types::pagination::MAX_PER_PAGE
284    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    /// POST without authentication (for device auth endpoints).
352    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    /// POST with JSON body, expecting a 204 No Content response.
417    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    /// GET without authentication (for public endpoints).
429    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    /// GET without authentication, returning the raw response body as text.
437    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
515/// Parse the `Retry-After` header from a response as seconds.
516///
517/// Only the seconds-delay format (e.g. `Retry-After: 60`) is supported.
518/// HTTP-date format and non-numeric values return `None`.
519fn 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
528/// Extract an error message from a JSON response body, falling back to
529/// the raw text when the body is not JSON or has no `error` field.
530pub(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    /// Helper: build a client pointing at the given URL with a short retry config.
683    #[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    // ── Retry behaviour tests ──────────────────────────────────────────
695
696    #[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(&params).await;
713
714        assert!(result.is_err());
715        // 1 initial attempt + 2 retries = 3 total calls
716        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(&params).await;
736
737        assert!(result.is_err());
738        mock.assert_calls(1); // no retries for client errors
739    }
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(&params).await;
758
759        assert!(result.is_err());
760        mock.assert_calls(1); // no retries for 401
761    }
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(&params).await;
782
783        assert!(result.is_err());
784        // 1 initial + 2 retries = 3 total calls
785        mock.assert_calls(3);
786    }
787
788    // ── Pagination tests ──────────────────────────────────────────────
789
790    /// Build a minimal valid `HostResponse`-compatible JSON object.
791    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        // Verify that page=1 and per_page=MAX_PER_PAGE are sent
921        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}