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 discovery_allowlist;
13pub mod enrollment_tokens;
14pub mod error;
15pub mod events_stream;
16pub mod health;
17pub mod host_tags;
18pub mod hosts;
19pub mod notifications;
20pub mod oidc_auth;
21pub mod oidc_providers;
22pub mod permissions;
23pub mod pki;
24pub mod plugin_configs;
25pub mod plugin_type_settings;
26pub mod roles;
27pub mod scheduler;
28pub mod services;
29pub mod settings;
30pub mod settings_nats;
31pub mod settings_provider_github;
32pub mod software_items;
33pub mod sse;
34pub mod surfaces;
35pub mod system_alerts;
36pub mod system_enrollment_tokens;
37pub mod system_services;
38pub mod update_batches;
39pub mod update_history;
40pub mod update_output_stream;
41pub mod users;
42
43pub use error::{ClientError, Result};
44
45pub use uptrakit_shared_types::DeviceAuthStatus;
46pub use uptrakit_web_api_types as types;
47
48pub(crate) mod types_impl {
49    pub(crate) use uptrakit_web_api_types::*;
50}
51
52#[cfg(test)]
53pub(crate) mod shared_types_impl {
54    pub(crate) use uptrakit_shared_types::*;
55}
56
57/// Re-export `Uuid` so that downstream crates can use the exact same type
58/// without adding a direct `uuid` dependency.
59pub use uuid::Uuid;
60
61/// Re-export `reqwest::Error` so that downstream crates (e.g. the CLI)
62/// do not need a direct dependency on `reqwest`.
63pub use reqwest::Error as ReqwestError;
64
65/// Re-export `reqwest::StatusCode` so that downstream crates (e.g. the CLI)
66/// do not need a direct dependency on `reqwest` for HTTP status handling.
67pub use reqwest::StatusCode;
68
69use rootcause::prelude::*;
70use serde::Serialize;
71use serde::de::DeserializeOwned;
72use std::time::Duration;
73
74/// Serialize a `StatusCode` as its numeric `u16` value for JSON wire compatibility.
75fn serialize_status_code<S: serde::Serializer>(
76    status: &reqwest::StatusCode,
77    serializer: S,
78) -> std::result::Result<S::Ok, S::Error> {
79    serializer.serialize_u16(status.as_u16())
80}
81
82/// Response from a raw (untyped) API request.
83#[derive(Debug, Serialize)]
84pub struct RawResponse {
85    #[serde(serialize_with = "serialize_status_code")]
86    pub status: reqwest::StatusCode,
87    pub body: serde_json::Value,
88}
89
90/// Configuration for automatic retry on transient failures.
91///
92/// Apply with [`UptrakitClient::with_retry`]. By default the client fails fast
93/// with no retries; call `with_retry(RetryConfig::default())` to enable.
94///
95/// Retries are applied to:
96/// - **HTTP 429 Too Many Requests**: respects the `Retry-After` header if
97///   present (numeric seconds only); falls back to `initial_delay`.
98/// - **HTTP 5xx Server Error**: exponential backoff starting at `initial_delay`,
99///   doubling on each attempt, capped at `max_delay`.
100///
101/// No retry is attempted for 4xx client errors, network errors, or authentication
102/// failures — these are not transient.
103#[derive(Debug, Clone)]
104pub struct RetryConfig {
105    /// Number of additional attempts after the initial request fails.
106    /// Default: 3.
107    pub max_retries: u32,
108    /// Delay before the first retry (and base for exponential backoff).
109    /// Default: 1 second.
110    pub initial_delay: Duration,
111    /// Upper bound on any single inter-retry delay.
112    /// Default: 30 seconds.
113    pub max_delay: Duration,
114}
115
116impl Default for RetryConfig {
117    fn default() -> Self {
118        Self {
119            max_retries: 3,
120            initial_delay: Duration::from_secs(1),
121            max_delay: Duration::from_secs(30),
122        }
123    }
124}
125
126/// Typed HTTP client for the Uptrakit web API.
127///
128/// Provides compile-time type safety for all API endpoints by using shared
129/// request/response types from `uptrakit-web-api-types`.
130pub struct UptrakitClient {
131    http: reqwest::Client,
132    base_url: String,
133    token: Option<String>,
134    retry: Option<RetryConfig>,
135}
136
137impl UptrakitClient {
138    /// Default connect timeout for the HTTP client (10 seconds).
139    const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
140
141    /// Default request timeout for the HTTP client (30 seconds).
142    const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
143
144    /// Create a new client. Pass `token: None` for unauthenticated endpoints
145    /// (e.g. the device authorization flow).
146    ///
147    /// When `ca_pem` is `Some`, the provided PEM replaces system root
148    /// certificates entirely (via `tls_certs_only`). This is the correct
149    /// approach for private CA trust — `add_root_certificate` is deprecated
150    /// because it appends to rather than replacing system roots.
151    ///
152    /// `insecure = true` takes precedence over `ca_pem` and disables all TLS
153    /// verification. `ca_pem` is ignored when `insecure` is `true`.
154    ///
155    /// `request_timeout` overrides [`DEFAULT_REQUEST_TIMEOUT`] when `Some`.
156    ///
157    /// [`DEFAULT_REQUEST_TIMEOUT`]: Self::DEFAULT_REQUEST_TIMEOUT
158    pub fn new(
159        base_url: &str,
160        token: Option<&str>,
161        insecure: bool,
162        ca_pem: Option<&str>,
163        request_timeout: Option<Duration>,
164    ) -> Result<Self> {
165        let timeout = request_timeout.unwrap_or(Self::DEFAULT_REQUEST_TIMEOUT);
166        let mut builder = reqwest::Client::builder()
167            .connect_timeout(Self::DEFAULT_CONNECT_TIMEOUT)
168            .timeout(timeout);
169        if insecure {
170            builder = builder.tls_danger_accept_invalid_certs(true);
171        } else if let Some(pem) = ca_pem {
172            let cert = reqwest::Certificate::from_pem(pem.as_bytes()).context_to()?;
173            builder = builder.tls_certs_only(std::iter::once(cert));
174        }
175        let http = builder.build().context_to()?;
176
177        Ok(Self {
178            http,
179            base_url: base_url.trim_end_matches('/').to_string(),
180            token: token.map(|t| t.to_string()),
181            retry: None,
182        })
183    }
184
185    /// Create a client with a required bearer token.
186    ///
187    /// When `ca_pem` is `Some`, the provided PEM replaces system root
188    /// certificates (see [`Self::new`] for details).
189    pub fn with_token(
190        base_url: &str,
191        token: &str,
192        insecure: bool,
193        ca_pem: Option<&str>,
194    ) -> Result<Self> {
195        Self::new(base_url, Some(token), insecure, ca_pem, None)
196    }
197
198    /// Enable automatic retry on transient failures (429 and 5xx).
199    ///
200    /// Returns a new client with the given retry configuration. By default,
201    /// the client fails fast with no retries. Retries use exponential backoff
202    /// for 5xx errors and respect `Retry-After` headers for 429 errors.
203    pub fn with_retry(mut self, config: RetryConfig) -> Self {
204        self.retry = Some(config);
205        self
206    }
207
208    /// Execute a raw (untyped) API request. Used by the CLI `api` escape-hatch command.
209    pub async fn raw_request(
210        &self,
211        method: &str,
212        path: &str,
213        body: Option<serde_json::Value>,
214    ) -> Result<RawResponse> {
215        let url = format!("{}{}", self.base_url, path);
216        let method = method.to_uppercase();
217        let req_method = method
218            .parse::<reqwest::Method>()
219            .map_err(|e| report!(ClientError::InvalidMethod(e.to_string())))?;
220
221        let mut req = self.http.request(req_method, &url);
222        if let Some(token) = &self.token {
223            req = req.bearer_auth(token);
224        }
225        if let Some(body) = body {
226            req = req.json(&body);
227        }
228
229        let resp = req.send().await.context_to()?;
230        let status = resp.status();
231        let text = resp.text().await.context_to()?;
232
233        let body = if text.is_empty() {
234            serde_json::Value::Null
235        } else {
236            serde_json::from_str(&text).unwrap_or(serde_json::Value::String(text))
237        };
238
239        Ok(RawResponse { status, body })
240    }
241
242    // ── Internal helpers ──────────────────────────────────────────────
243
244    fn token_or_err(&self) -> Result<&str> {
245        self.token
246            .as_deref()
247            .ok_or_else(|| report!(ClientError::NotAuthenticated))
248    }
249
250    /// Send a request, retrying automatically on 429 and 5xx responses.
251    ///
252    /// Without a [`RetryConfig`] (the default), this is a direct single-shot
253    /// `send()`. Retries use exponential backoff (5xx) or the `Retry-After`
254    /// header (429). 4xx and network errors are never retried.
255    async fn send_with_retry(&self, req: reqwest::RequestBuilder) -> Result<reqwest::Response> {
256        let Some(retry) = &self.retry else {
257            return req.send().await.context_to();
258        };
259
260        // Pre-clone the builder for every potential retry before the first send
261        // consumes it. `try_clone` returns `None` for streaming bodies; the
262        // collected vec will just be shorter, reducing effective retry count.
263        let retry_builders: Vec<reqwest::RequestBuilder> = (0..retry.max_retries)
264            .map_while(|_| req.try_clone())
265            .collect();
266
267        let mut resp = req.send().await.context_to()?;
268
269        for (attempt, retry_req) in retry_builders.into_iter().enumerate() {
270            let status = resp.status();
271            let delay = if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
272                // Respect Retry-After header; fall back to initial_delay.
273                parse_retry_after(&resp)
274                    .map(Duration::from_secs)
275                    .unwrap_or(retry.initial_delay)
276                    .min(retry.max_delay)
277            } else if status.is_server_error() {
278                // Exponential backoff: initial, 2×initial, 4×initial, …, capped.
279                let factor = 1u32.checked_shl(attempt as u32).unwrap_or(u32::MAX);
280                retry
281                    .initial_delay
282                    .saturating_mul(factor)
283                    .min(retry.max_delay)
284            } else {
285                // Not retriable — return as-is.
286                return Ok(resp);
287            };
288
289            tokio::time::sleep(delay).await;
290            resp = retry_req.send().await.context_to()?;
291        }
292
293        Ok(resp)
294    }
295
296    /// Fetch all pages from a paginated list endpoint, accumulating every item.
297    ///
298    /// Serialises `base_query` to JSON, then overrides `page` and `per_page`
299    /// (set to [`MAX_PER_PAGE`]) on each iteration. Stops when
300    /// `page >= total_pages` or the first page reports zero total pages.
301    ///
302    /// [`MAX_PER_PAGE`]: uptrakit_web_api_types::pagination::MAX_PER_PAGE
303    pub(crate) async fn fetch_all_pages<T: DeserializeOwned + Send>(
304        &self,
305        path: &str,
306        base_query: &impl Serialize,
307    ) -> Result<Vec<T>> {
308        use crate::types_impl::pagination::{MAX_PER_PAGE, PaginatedResponse};
309
310        let base_value = serde_json::to_value(base_query).context_to()?;
311        let mut all: Vec<T> = Vec::new();
312        let mut page: u64 = 1;
313        loop {
314            let mut query = base_value.clone();
315            if let Some(obj) = query.as_object_mut() {
316                obj.insert("page".to_string(), serde_json::json!(page));
317                obj.insert("per_page".to_string(), serde_json::json!(MAX_PER_PAGE));
318            }
319            let resp: PaginatedResponse<T> = self.get_with_query(path, &query).await?;
320            let total_pages = resp.total_pages;
321            all.extend(resp.items);
322            if page >= total_pages || total_pages == 0 {
323                break;
324            }
325            page += 1;
326        }
327        Ok(all)
328    }
329
330    async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
331        let url = format!("{}{}", self.base_url, path);
332        let req = self.http.get(&url).bearer_auth(self.token_or_err()?);
333        let resp = self.send_with_retry(req).await?;
334        self.handle_response(resp).await
335    }
336
337    async fn get_with_query<T: DeserializeOwned>(
338        &self,
339        path: &str,
340        query: &impl Serialize,
341    ) -> Result<T> {
342        let url = format!("{}{}", self.base_url, path);
343        let req = self
344            .http
345            .get(&url)
346            .bearer_auth(self.token_or_err()?)
347            .query(query);
348        let resp = self.send_with_retry(req).await?;
349        self.handle_response(resp).await
350    }
351
352    async fn post_json<T: DeserializeOwned>(&self, path: &str, body: &impl Serialize) -> Result<T> {
353        let url = format!("{}{}", self.base_url, path);
354        let req = self
355            .http
356            .post(&url)
357            .bearer_auth(self.token_or_err()?)
358            .json(body);
359        let resp = self.send_with_retry(req).await?;
360        self.handle_response(resp).await
361    }
362
363    async fn post_empty<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
364        let url = format!("{}{}", self.base_url, path);
365        let req = self.http.post(&url).bearer_auth(self.token_or_err()?);
366        let resp = self.send_with_retry(req).await?;
367        self.handle_response(resp).await
368    }
369
370    /// POST without authentication (for device auth endpoints).
371    async fn post_json_unauth<T: DeserializeOwned>(
372        &self,
373        path: &str,
374        body: &impl Serialize,
375    ) -> Result<T> {
376        let url = format!("{}{}", self.base_url, path);
377        let req = self.http.post(&url).json(body);
378        let resp = self.send_with_retry(req).await?;
379        self.handle_response(resp).await
380    }
381
382    /// POST a form-urlencoded body without authentication (OAuth endpoints).
383    ///
384    /// On HTTP 200, deserialise the JSON response body into `T`.
385    /// On HTTP 400, try to parse the body as an RFC 6749 §5.2 `OAuthErrorResponse`;
386    /// on success, return `Err(ClientError::OAuthError(...))`. If the 400 body is
387    /// not a parseable OAuth error envelope, or for any other non-success status,
388    /// fall through to `handle_response_bytes` (the shared status-dispatcher) so
389    /// `RateLimited` (429), `NotAuthenticated` (401), `NotFound` (404), and
390    /// generic `Api { status, message }` still flow through one place.
391    async fn post_form_unauth<T: DeserializeOwned, F: Serialize + ?Sized>(
392        &self,
393        path: &str,
394        form: &F,
395    ) -> Result<T> {
396        let url = format!("{}{}", self.base_url, path);
397        let req = self.http.post(&url).form(form);
398        let resp = self.send_with_retry(req).await?;
399
400        let status = resp.status();
401        // Extract Retry-After while the response is still alive — the bytes
402        // helper cannot reconstruct headers from the body.
403        let retry_after = parse_retry_after(&resp);
404        let bytes = resp.bytes().await.context_to()?;
405
406        if status == reqwest::StatusCode::OK {
407            return serde_json::from_slice::<T>(&bytes).context_to();
408        }
409        if status == reqwest::StatusCode::BAD_REQUEST
410            && let Ok(err_resp) =
411                serde_json::from_slice::<crate::types_impl::oauth::OAuthErrorResponse>(&bytes)
412        {
413            bail!(ClientError::OAuthError(err_resp));
414        }
415        // Body did not match the OAuth error envelope — fall through to the
416        // shared status-dispatcher so 400 surfaces consistently with every
417        // other endpoint.
418
419        self.handle_response_bytes(status, bytes.to_vec(), retry_after)
420            .await
421    }
422
423    async fn delete(&self, path: &str) -> Result<()> {
424        let url = format!("{}{}", self.base_url, path);
425        let req = self.http.delete(&url).bearer_auth(self.token_or_err()?);
426        let resp = self.send_with_retry(req).await?;
427        self.handle_empty_response(resp).await
428    }
429
430    async fn delete_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
431        let url = format!("{}{}", self.base_url, path);
432        let req = self.http.delete(&url).bearer_auth(self.token_or_err()?);
433        let resp = self.send_with_retry(req).await?;
434        self.handle_response(resp).await
435    }
436
437    async fn delete_with_query(&self, path: &str, query: &impl Serialize) -> Result<()> {
438        let url = format!("{}{}", self.base_url, path);
439        let req = self
440            .http
441            .delete(&url)
442            .bearer_auth(self.token_or_err()?)
443            .query(query);
444        let resp = self.send_with_retry(req).await?;
445        self.handle_empty_response(resp).await
446    }
447
448    #[expect(
449        dead_code,
450        reason = "HTTP helper — not yet called by any route but retained for API completeness"
451    )]
452    async fn delete_with_query_json<T: DeserializeOwned>(
453        &self,
454        path: &str,
455        query: &impl Serialize,
456    ) -> Result<T> {
457        let url = format!("{}{}", self.base_url, path);
458        let req = self
459            .http
460            .delete(&url)
461            .bearer_auth(self.token_or_err()?)
462            .query(query);
463        let resp = self.send_with_retry(req).await?;
464        self.handle_response(resp).await
465    }
466
467    async fn put_json<T: DeserializeOwned>(&self, path: &str, body: &impl Serialize) -> Result<T> {
468        let url = format!("{}{}", self.base_url, path);
469        let req = self
470            .http
471            .put(&url)
472            .bearer_auth(self.token_or_err()?)
473            .json(body);
474        let resp = self.send_with_retry(req).await?;
475        self.handle_response(resp).await
476    }
477
478    async fn get_with_etag<T: DeserializeOwned>(&self, path: &str) -> Result<(T, String)> {
479        let url = format!("{}{}", self.base_url, path);
480        let req = self.http.get(&url).bearer_auth(self.token_or_err()?);
481        let resp = self.send_with_retry(req).await?;
482        let etag = resp
483            .headers()
484            .get(reqwest::header::ETAG)
485            .and_then(|v| v.to_str().ok())
486            .unwrap_or_default()
487            .to_string();
488        let body: T = self.handle_response(resp).await?;
489        Ok((body, etag))
490    }
491
492    async fn put_json_with_etag<T: DeserializeOwned>(
493        &self,
494        path: &str,
495        body: &impl Serialize,
496        etag: &str,
497    ) -> Result<(T, String)> {
498        let url = format!("{}{}", self.base_url, path);
499        let req = self
500            .http
501            .put(&url)
502            .bearer_auth(self.token_or_err()?)
503            .header(reqwest::header::IF_MATCH, etag)
504            .json(body);
505        let resp = self.send_with_retry(req).await?;
506        let new_etag = resp
507            .headers()
508            .get(reqwest::header::ETAG)
509            .and_then(|v| v.to_str().ok())
510            .unwrap_or_default()
511            .to_string();
512        let body: T = self.handle_response(resp).await?;
513        Ok((body, new_etag))
514    }
515
516    /// POST with JSON body, expecting a 204 No Content response.
517    async fn post_json_no_content(&self, path: &str, body: &impl Serialize) -> Result<()> {
518        let url = format!("{}{}", self.base_url, path);
519        let req = self
520            .http
521            .post(&url)
522            .bearer_auth(self.token_or_err()?)
523            .json(body);
524        let resp = self.send_with_retry(req).await?;
525        self.handle_empty_response(resp).await
526    }
527
528    /// GET without authentication (for public endpoints).
529    async fn get_unauth<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
530        let url = format!("{}{}", self.base_url, path);
531        let req = self.http.get(&url);
532        let resp = self.send_with_retry(req).await?;
533        self.handle_response(resp).await
534    }
535
536    /// GET without authentication, returning the raw response body as text.
537    async fn get_text_unauth(&self, path: &str) -> Result<String> {
538        let url = format!("{}{}", self.base_url, path);
539        let req = self.http.get(&url);
540        let resp = self.send_with_retry(req).await?;
541        self.handle_text_response(resp).await
542    }
543
544    async fn handle_response<T: DeserializeOwned>(&self, resp: reqwest::Response) -> Result<T> {
545        let status = resp.status();
546        let retry_after = parse_retry_after(&resp);
547        let bytes = resp.bytes().await.context_to()?.to_vec();
548        self.handle_response_bytes(status, bytes, retry_after).await
549    }
550
551    async fn handle_response_bytes<T: DeserializeOwned>(
552        &self,
553        status: reqwest::StatusCode,
554        bytes: Vec<u8>,
555        retry_after: Option<u64>,
556    ) -> Result<T> {
557        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
558            bail!(ClientError::RateLimited {
559                retry_after_seconds: retry_after,
560            });
561        }
562        if status == reqwest::StatusCode::UNAUTHORIZED {
563            bail!(ClientError::NotAuthenticated);
564        }
565        let text = String::from_utf8_lossy(&bytes).into_owned();
566        if status == reqwest::StatusCode::NOT_FOUND {
567            let message = extract_error_message(&text);
568            bail!(ClientError::NotFound(message));
569        }
570        if status.is_client_error() || status.is_server_error() {
571            let message = extract_error_message(&text);
572            bail!(ClientError::Api { status, message });
573        }
574        serde_json::from_slice::<T>(&bytes).context_to()
575    }
576
577    async fn handle_empty_response(&self, resp: reqwest::Response) -> Result<()> {
578        let status = resp.status();
579        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
580            let retry_after = parse_retry_after(&resp);
581            bail!(ClientError::RateLimited {
582                retry_after_seconds: retry_after,
583            });
584        }
585        if status == reqwest::StatusCode::UNAUTHORIZED {
586            bail!(ClientError::NotAuthenticated);
587        }
588        if status == reqwest::StatusCode::NOT_FOUND {
589            let text = resp.text().await.context_to()?;
590            let message = extract_error_message(&text);
591            bail!(ClientError::NotFound(message));
592        }
593        if status.is_client_error() || status.is_server_error() {
594            let text = resp.text().await.context_to()?;
595            let message = extract_error_message(&text);
596            bail!(ClientError::Api { status, message });
597        }
598        Ok(())
599    }
600
601    async fn handle_text_response(&self, resp: reqwest::Response) -> Result<String> {
602        let status = resp.status();
603        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
604            let retry_after = parse_retry_after(&resp);
605            bail!(ClientError::RateLimited {
606                retry_after_seconds: retry_after,
607            });
608        }
609        if status == reqwest::StatusCode::UNAUTHORIZED {
610            bail!(ClientError::NotAuthenticated);
611        }
612        let text = resp.text().await.context_to()?;
613        if status == reqwest::StatusCode::NOT_FOUND {
614            let message = extract_error_message(&text);
615            bail!(ClientError::NotFound(message));
616        }
617        if status.is_client_error() || status.is_server_error() {
618            let message = extract_error_message(&text);
619            bail!(ClientError::Api { status, message });
620        }
621        Ok(text)
622    }
623}
624
625/// Parse the `Retry-After` header from a response as seconds.
626///
627/// Only the seconds-delay format (e.g. `Retry-After: 60`) is supported.
628/// HTTP-date format and non-numeric values return `None`.
629fn parse_retry_after(resp: &reqwest::Response) -> Option<u64> {
630    resp.headers()
631        .get(reqwest::header::RETRY_AFTER)?
632        .to_str()
633        .ok()?
634        .parse::<u64>()
635        .ok()
636}
637
638/// Extract an error message from a JSON response body, falling back to
639/// the raw text when the body is not JSON or has no `error` field.
640#[expect(
641    clippy::indexing_slicing,
642    reason = "serde_json::Value index operator returns Value::Null for missing keys rather than panicking; this is safe"
643)]
644pub(crate) fn extract_error_message(text: &str) -> String {
645    serde_json::from_str::<serde_json::Value>(text)
646        .ok()
647        .and_then(|v| v["error"].as_str().map(|s| s.to_string()))
648        .unwrap_or_else(|| {
649            if text.is_empty() {
650                "Request failed".to_string()
651            } else {
652                text.to_string()
653            }
654        })
655}
656
657#[cfg(test)]
658mod tests {
659    #![expect(
660        clippy::assertions_on_result_states,
661        reason = "test assertions — assert!(result.is_err()) is idiomatic in tests"
662    )]
663
664    use super::*;
665
666    #[test]
667    fn extract_error_message_from_json() {
668        let text = r#"{"error":"Not found"}"#;
669        assert_eq!(extract_error_message(text), "Not found");
670    }
671
672    #[test]
673    fn extract_error_message_from_json_without_error_field() {
674        let text = r#"{"message":"something"}"#;
675        assert_eq!(extract_error_message(text), text);
676    }
677
678    #[test]
679    fn extract_error_message_from_plain_text() {
680        let text = "Internal Server Error";
681        assert_eq!(extract_error_message(text), "Internal Server Error");
682    }
683
684    #[test]
685    fn extract_error_message_from_empty() {
686        assert_eq!(extract_error_message(""), "Request failed");
687    }
688
689    #[test]
690    fn base_url_trailing_slash_is_trimmed() {
691        let client = UptrakitClient::new("https://example.com/", None, false, None, None)
692            .expect("client creation");
693        assert_eq!(client.base_url, "https://example.com");
694    }
695
696    #[test]
697    fn base_url_without_trailing_slash_is_unchanged() {
698        let client = UptrakitClient::new("https://example.com", None, false, None, None)
699            .expect("client creation");
700        assert_eq!(client.base_url, "https://example.com");
701    }
702
703    #[test]
704    fn with_token_stores_token() {
705        let client = UptrakitClient::with_token("https://example.com", "tok-123", false, None)
706            .expect("client creation");
707        assert_eq!(client.token.as_deref(), Some("tok-123"));
708    }
709
710    #[test]
711    fn new_without_token_stores_none() {
712        let client = UptrakitClient::new("https://example.com", None, false, None, None)
713            .expect("client creation");
714        assert!(client.token.is_none());
715    }
716
717    #[test]
718    fn token_or_err_returns_token_when_present() {
719        let client = UptrakitClient::with_token("https://example.com", "tok", false, None)
720            .expect("client creation");
721        assert_eq!(client.token_or_err().expect("token"), "tok");
722    }
723
724    #[test]
725    fn token_or_err_returns_error_when_absent() {
726        let client = UptrakitClient::new("https://example.com", None, false, None, None)
727            .expect("client creation");
728        let err = client.token_or_err().unwrap_err();
729        assert!(
730            matches!(err.current_context(), ClientError::NotAuthenticated),
731            "expected NotAuthenticated, got: {err}"
732        );
733    }
734
735    #[test]
736    fn parse_retry_after_valid_seconds() {
737        let resp = http::Response::builder()
738            .status(http::StatusCode::TOO_MANY_REQUESTS)
739            .header("Retry-After", "60")
740            .body("")
741            .unwrap();
742        let reqwest_resp = reqwest::Response::from(resp);
743        assert_eq!(parse_retry_after(&reqwest_resp), Some(60));
744    }
745
746    #[test]
747    fn parse_retry_after_missing_header() {
748        let resp = http::Response::builder()
749            .status(http::StatusCode::TOO_MANY_REQUESTS)
750            .body("")
751            .unwrap();
752        let reqwest_resp = reqwest::Response::from(resp);
753        assert_eq!(parse_retry_after(&reqwest_resp), None);
754    }
755
756    #[test]
757    fn parse_retry_after_non_numeric() {
758        let resp = http::Response::builder()
759            .status(http::StatusCode::TOO_MANY_REQUESTS)
760            .header("Retry-After", "Wed, 21 Oct 2025 07:28:00 GMT")
761            .body("")
762            .unwrap();
763        let reqwest_resp = reqwest::Response::from(resp);
764        assert_eq!(parse_retry_after(&reqwest_resp), None);
765    }
766
767    #[test]
768    fn raw_response_serialization() {
769        let resp = RawResponse {
770            status: reqwest::StatusCode::OK,
771            body: serde_json::json!({"key": "value"}),
772        };
773        let json = serde_json::to_string(&resp).expect("serialize");
774        let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse");
775        assert_eq!(parsed["status"], 200);
776        assert_eq!(parsed["body"]["key"], "value");
777    }
778
779    #[test]
780    fn default_client_has_no_retry() {
781        let client =
782            UptrakitClient::new("https://example.com", None, false, None, None).expect("client");
783        assert!(client.retry.is_none());
784    }
785
786    #[test]
787    fn with_retry_sets_config() {
788        let client = UptrakitClient::new("https://example.com", None, false, None, None)
789            .expect("client")
790            .with_retry(RetryConfig::default());
791        assert!(client.retry.is_some());
792    }
793
794    #[test]
795    fn retry_config_default_values() {
796        let config = RetryConfig::default();
797        assert_eq!(config.max_retries, 3);
798        assert_eq!(config.initial_delay, Duration::from_secs(1));
799        assert_eq!(config.max_delay, Duration::from_secs(30));
800    }
801
802    /// Helper: build a client pointing at the given URL with a short retry config.
803    #[cfg(test)]
804    fn retrying_client(base_url: &str) -> UptrakitClient {
805        UptrakitClient::with_token(base_url, "test-token", false, None)
806            .expect("client")
807            .with_retry(RetryConfig {
808                max_retries: 2,
809                initial_delay: Duration::from_millis(1),
810                max_delay: Duration::from_millis(10),
811            })
812    }
813
814    // ── Retry behaviour tests ──────────────────────────────────────────
815
816    #[tokio::test]
817    async fn retry_exhausted_on_repeated_503() {
818        use crate::types_impl::pagination::PaginationParams;
819        use httpmock::prelude::*;
820
821        let server = MockServer::start_async().await;
822        let mock = server.mock(|when, then| {
823            when.method(GET).path("/api/v1/hosts");
824            then.status(503).body(r#"{"error":"down"}"#);
825        });
826
827        let params = PaginationParams {
828            page: None,
829            per_page: None,
830        };
831        let client = retrying_client(&server.base_url());
832        let result = client.list_hosts(&params).await;
833
834        assert!(result.is_err());
835        // 1 initial attempt + 2 retries = 3 total calls
836        mock.assert_calls(3);
837    }
838
839    #[tokio::test]
840    async fn no_retry_on_400() {
841        use crate::types_impl::pagination::PaginationParams;
842        use httpmock::prelude::*;
843
844        let server = MockServer::start_async().await;
845        let mock = server.mock(|when, then| {
846            when.method(GET).path("/api/v1/hosts");
847            then.status(400).body(r#"{"error":"bad request"}"#);
848        });
849
850        let params = PaginationParams {
851            page: None,
852            per_page: None,
853        };
854        let client = retrying_client(&server.base_url());
855        let result = client.list_hosts(&params).await;
856
857        assert!(result.is_err());
858        mock.assert_calls(1); // no retries for client errors
859    }
860
861    #[tokio::test]
862    async fn no_retry_on_401() {
863        use crate::types_impl::pagination::PaginationParams;
864        use httpmock::prelude::*;
865
866        let server = MockServer::start_async().await;
867        let mock = server.mock(|when, then| {
868            when.method(GET).path("/api/v1/hosts");
869            then.status(401).body(r#"{"error":"unauthorized"}"#);
870        });
871
872        let params = PaginationParams {
873            page: None,
874            per_page: None,
875        };
876        let client = retrying_client(&server.base_url());
877        let result = client.list_hosts(&params).await;
878
879        assert!(result.is_err());
880        mock.assert_calls(1); // no retries for 401
881    }
882
883    #[tokio::test]
884    async fn retry_exhausted_on_repeated_429() {
885        use crate::types_impl::pagination::PaginationParams;
886        use httpmock::prelude::*;
887
888        let server = MockServer::start_async().await;
889        let mock = server.mock(|when, then| {
890            when.method(GET).path("/api/v1/hosts");
891            then.status(429)
892                .header("Retry-After", "1")
893                .body(r#"{"error":"rate limited"}"#);
894        });
895
896        let params = PaginationParams {
897            page: None,
898            per_page: None,
899        };
900        let client = retrying_client(&server.base_url());
901        let result = client.list_hosts(&params).await;
902
903        assert!(result.is_err());
904        // 1 initial + 2 retries = 3 total calls
905        mock.assert_calls(3);
906    }
907
908    // ── Pagination tests ──────────────────────────────────────────────
909
910    /// Build a minimal valid `HostResponse`-compatible JSON object.
911    fn host_json(id: &str) -> serde_json::Value {
912        serde_json::json!({
913            "id": id,
914            "machine_id": format!("machine-{id}"),
915            "hostname": format!("host-{id}"),
916            "friendly_name": format!("Host {id}"),
917            "os_type": null,
918            "os_version": null,
919            "architecture": null,
920            "ip_address": null,
921            "last_seen_at": null,
922            "created_at": "2024-01-01T00:00:00Z",
923            "updated_at": "2024-01-01T00:00:00Z",
924            "agents": [],
925            "tags": []
926        })
927    }
928
929    fn paginated_hosts_json(
930        items: Vec<serde_json::Value>,
931        total: u64,
932        page: u64,
933        total_pages: u64,
934    ) -> serde_json::Value {
935        serde_json::json!({
936            "items": items,
937            "total": total,
938            "page": page,
939            "per_page": 1000,
940            "total_pages": total_pages
941        })
942    }
943
944    #[tokio::test]
945    async fn list_all_hosts_multi_page() {
946        use httpmock::prelude::*;
947
948        let server = MockServer::start_async().await;
949
950        let h1 = host_json("550e8400-e29b-41d4-a716-446655440001");
951        let h2 = host_json("550e8400-e29b-41d4-a716-446655440002");
952        let h3 = host_json("550e8400-e29b-41d4-a716-446655440003");
953
954        server.mock(|when, then| {
955            when.method(GET)
956                .path("/api/v1/hosts")
957                .query_param("page", "1")
958                .query_param("per_page", "1000");
959            then.status(200)
960                .header("Content-Type", "application/json")
961                .json_body(paginated_hosts_json(vec![h1.clone()], 3, 1, 3));
962        });
963        server.mock(|when, then| {
964            when.method(GET)
965                .path("/api/v1/hosts")
966                .query_param("page", "2")
967                .query_param("per_page", "1000");
968            then.status(200)
969                .header("Content-Type", "application/json")
970                .json_body(paginated_hosts_json(vec![h2.clone()], 3, 2, 3));
971        });
972        server.mock(|when, then| {
973            when.method(GET)
974                .path("/api/v1/hosts")
975                .query_param("page", "3")
976                .query_param("per_page", "1000");
977            then.status(200)
978                .header("Content-Type", "application/json")
979                .json_body(paginated_hosts_json(vec![h3.clone()], 3, 3, 3));
980        });
981
982        let client =
983            UptrakitClient::with_token(&server.base_url(), "tok", false, None).expect("client");
984        let all = client.list_all_hosts().await.expect("list_all_hosts");
985        assert_eq!(all.len(), 3);
986        assert_eq!(
987            all[0].machine_id,
988            "machine-550e8400-e29b-41d4-a716-446655440001"
989        );
990        assert_eq!(
991            all[2].machine_id,
992            "machine-550e8400-e29b-41d4-a716-446655440003"
993        );
994    }
995
996    #[tokio::test]
997    async fn list_all_hosts_single_page() {
998        use httpmock::prelude::*;
999
1000        let server = MockServer::start_async().await;
1001        let h1 = host_json("550e8400-e29b-41d4-a716-000000000001");
1002        let h2 = host_json("550e8400-e29b-41d4-a716-000000000002");
1003
1004        server.mock(|when, then| {
1005            when.method(GET).path("/api/v1/hosts");
1006            then.status(200)
1007                .header("Content-Type", "application/json")
1008                .json_body(paginated_hosts_json(vec![h1, h2], 2, 1, 1));
1009        });
1010
1011        let client =
1012            UptrakitClient::with_token(&server.base_url(), "tok", false, None).expect("client");
1013        let all = client.list_all_hosts().await.expect("list_all_hosts");
1014        assert_eq!(all.len(), 2);
1015    }
1016
1017    #[tokio::test]
1018    async fn list_all_hosts_empty() {
1019        use httpmock::prelude::*;
1020
1021        let server = MockServer::start_async().await;
1022
1023        server.mock(|when, then| {
1024            when.method(GET).path("/api/v1/hosts");
1025            then.status(200)
1026                .header("Content-Type", "application/json")
1027                .json_body(paginated_hosts_json(vec![], 0, 1, 0));
1028        });
1029
1030        let client =
1031            UptrakitClient::with_token(&server.base_url(), "tok", false, None).expect("client");
1032        let all = client.list_all_hosts().await.expect("list_all_hosts");
1033        assert!(all.is_empty());
1034    }
1035
1036    #[tokio::test]
1037    async fn list_all_hosts_forwards_page_params() {
1038        use crate::types_impl::pagination::MAX_PER_PAGE;
1039        use httpmock::prelude::*;
1040
1041        let server = MockServer::start_async().await;
1042
1043        // Verify that page=1 and per_page=MAX_PER_PAGE are sent
1044        let page_param_mock = server.mock(|when, then| {
1045            when.method(GET)
1046                .path("/api/v1/hosts")
1047                .query_param("page", "1")
1048                .query_param("per_page", MAX_PER_PAGE.to_string());
1049            then.status(200)
1050                .header("Content-Type", "application/json")
1051                .json_body(paginated_hosts_json(vec![], 0, 1, 0));
1052        });
1053
1054        let client =
1055            UptrakitClient::with_token(&server.base_url(), "tok", false, None).expect("client");
1056        client.list_all_hosts().await.expect("list_all_hosts");
1057
1058        page_param_mock.assert_calls(1);
1059    }
1060
1061    #[test]
1062    fn new_with_ca_pem_none_succeeds() {
1063        let client =
1064            UptrakitClient::new("https://example.com", None, false, None, None).expect("client");
1065        assert!(client.token.is_none());
1066    }
1067
1068    #[test]
1069    fn new_with_insecure_ignores_invalid_ca_pem() {
1070        // insecure=true skips ca_pem parsing entirely — no error even for garbage PEM
1071        let client =
1072            UptrakitClient::new("https://example.com", None, true, Some("not-a-pem"), None);
1073        assert!(client.is_ok());
1074    }
1075}