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