Skip to main content

harmont_cloud/
client.rs

1//! The high-level Harmont Cloud client.
2
3use harmont_cloud_raw::Client as RawClient;
4use crate::{HarmontError, Result};
5
6/// Default production API base URL.
7pub const DEFAULT_BASE_URL: &str = "https://api.harmont.dev";
8
9/// A configured client for the Harmont Cloud API.
10///
11/// Construct with [`HarmontClient::new`] (prod) or
12/// [`HarmontClient::with_base_url`] (self-hosted / local dev). The bearer
13/// token is attached to every request via a preconfigured [`reqwest::Client`].
14#[derive(Clone, Debug)]
15pub struct HarmontClient {
16    pub(crate) raw: RawClient,
17    pub(crate) http: reqwest::Client,
18    pub(crate) base: String,
19}
20
21impl HarmontClient {
22    /// Create a client against the production API.
23    pub fn new(token: impl Into<String>) -> Self {
24        Self::with_base_url(token, DEFAULT_BASE_URL)
25    }
26
27    /// Create a client against an explicit base URL.
28    ///
29    /// # Panics
30    ///
31    /// Panics if `token` contains characters that are invalid for an HTTP
32    /// header value (e.g. non-ASCII or control characters).
33    pub fn with_base_url(token: impl Into<String>, base: impl Into<String>) -> Self {
34        let token = token.into();
35        let base = base.into();
36        let mut headers = reqwest::header::HeaderMap::new();
37        let mut auth = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
38            .expect("API token contains characters invalid for an HTTP Authorization header");
39        auth.set_sensitive(true); // keep the token out of any header debug output
40        headers.insert(reqwest::header::AUTHORIZATION, auth);
41        let http = reqwest::Client::builder()
42            .default_headers(headers)
43            .build()
44            .expect("reqwest client builds with static config");
45        let raw = RawClient::new_with_client(&base, http.clone());
46        Self { raw, http, base }
47    }
48
49    /// Create a client with no credentials, for anonymous endpoints
50    /// (the CLI auth flows: redeem/claim). Sends no Authorization header.
51    pub fn anonymous(base: impl Into<String>) -> Self {
52        let base = base.into();
53        let http = reqwest::Client::builder()
54            .build()
55            .expect("reqwest client builds with static config");
56        let raw = RawClient::new_with_client(&base, http.clone());
57        Self { raw, http, base }
58    }
59
60    /// The configured base URL.
61    pub fn base_url(&self) -> &str {
62        &self.base
63    }
64
65    /// The raw generated client, for endpoints the high-level API does not wrap.
66    pub fn raw(&self) -> &RawClient {
67        &self.raw
68    }
69
70    /// Decode a response, mapping non-2xx to a structured [`HarmontError`].
71    pub(crate) async fn parse_json<T: serde::de::DeserializeOwned>(
72        &self, resp: reqwest::Response,
73    ) -> Result<T> {
74        let status = resp.status();
75        if status == reqwest::StatusCode::UNAUTHORIZED {
76            return Err(HarmontError::Unauthorized);
77        }
78        let bytes = resp.bytes().await?;
79        if status.is_success() {
80            return serde_json::from_slice(&bytes).map_err(|e| HarmontError::Decode(e.to_string()));
81        }
82        if status == reqwest::StatusCode::NOT_FOUND {
83            return Err(HarmontError::NotFound(String::from_utf8_lossy(&bytes).into()));
84        }
85        let (code, message) = parse_error_body(&bytes);
86        Err(HarmontError::Api { status: status.as_u16(), code, message })
87    }
88
89    /// Like [`Self::parse_json`], but maps a 404 through the structured error
90    /// body too (rather than the opaque `NotFound(raw_body)` path). Use for
91    /// endpoints whose 404 carries a meaningful `code`/`message` worth
92    /// surfacing verbatim — e.g. create-by-source's "pipeline not found".
93    pub(crate) async fn parse_json_structured<T: serde::de::DeserializeOwned>(
94        &self,
95        resp: reqwest::Response,
96    ) -> Result<T> {
97        let status = resp.status();
98        if status == reqwest::StatusCode::UNAUTHORIZED {
99            return Err(HarmontError::Unauthorized);
100        }
101        let bytes = resp.bytes().await?;
102        if status.is_success() {
103            return serde_json::from_slice(&bytes).map_err(|e| HarmontError::Decode(e.to_string()));
104        }
105        let (code, message) = parse_error_body(&bytes);
106        Err(HarmontError::Api { status: status.as_u16(), code, message })
107    }
108}
109
110fn parse_error_body(bytes: &[u8]) -> (String, String) {
111    let v: serde_json::Value = serde_json::from_slice(bytes).unwrap_or(serde_json::Value::Null);
112    let obj = v.get("error").unwrap_or(&v);
113    let code = obj.get("code").and_then(|c| c.as_str()).unwrap_or("unknown").to_string();
114    let message = obj.get("message").and_then(|m| m.as_str())
115        .unwrap_or_else(|| std::str::from_utf8(bytes).unwrap_or("")).to_string();
116    (code, message)
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn default_base_url_is_prod() {
125        let c = HarmontClient::new("hm_test");
126        assert_eq!(c.base_url(), "https://api.harmont.dev");
127    }
128
129    #[test]
130    fn custom_base_url_is_used() {
131        let c = HarmontClient::with_base_url("hm_test", "http://localhost:4000");
132        assert_eq!(c.base_url(), "http://localhost:4000");
133    }
134}