Skip to main content

flares_client/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::{path::Path, time::Duration};
4
5pub mod config;
6
7use reqwest::{Client, RequestBuilder, Url};
8use serde::de::DeserializeOwned;
9
10pub use flares_types::*;
11
12#[derive(Clone)]
13pub struct ApiClient {
14    client: Client,
15    base_url: String,
16    token: String,
17}
18
19impl ApiClient {
20    /// Load one client YAML file. Relative secret paths use its canonical directory.
21    pub fn from_config(path: impl AsRef<Path>) -> Result<Self, Error> {
22        let config = config::ClientConfig::load(path.as_ref())
23            .map_err(|error| Error::Configuration(error.to_string()))?;
24        Self::with_timeout(
25            config.base_url,
26            config.api_token.expose(),
27            Duration::from_secs_f64(config.timeout),
28        )
29    }
30
31    /// Opt into XDG/HOME, then system client.yaml discovery.
32    pub fn from_default_config() -> Result<Self, Error> {
33        let path = config::default_path("client.yaml")
34            .map_err(|error| Error::Configuration(error.to_string()))?;
35        Self::from_config(path)
36    }
37
38    pub fn new(base_url: impl Into<String>, token: impl Into<String>) -> Result<Self, Error> {
39        Self::with_timeout(base_url, token, Duration::from_secs(15))
40    }
41
42    pub fn with_timeout(
43        base_url: impl Into<String>,
44        token: impl Into<String>,
45        timeout: Duration,
46    ) -> Result<Self, Error> {
47        let base_url = base_url.into();
48        let token = token.into();
49        let url = Url::parse(&base_url).map_err(|_| Error::Validation("Invalid API base URL"))?;
50        if !matches!(url.scheme(), "http" | "https")
51            || url.host_str().is_none()
52            || !url.username().is_empty()
53            || url.password().is_some()
54            || url.query().is_some()
55            || url.fragment().is_some()
56        {
57            return Err(Error::Validation(
58                "API base URL must be HTTP(S) without credentials, query, or fragment",
59            ));
60        }
61        if token.is_empty() || !token.bytes().all(|b| b.is_ascii_graphic()) {
62            return Err(Error::Validation(
63                "API token must be nonempty printable ASCII without spaces",
64            ));
65        }
66        if timeout.is_zero() || timeout > Duration::from_secs(86400) {
67            return Err(Error::Validation(
68                "Timeout must be greater than zero and at most 86400 seconds",
69            ));
70        }
71        let client = Client::builder()
72            .timeout(timeout)
73            .redirect(reqwest::redirect::Policy::none())
74            .retry(reqwest::retry::never())
75            .build()
76            .map_err(|_| Error::Transport)?;
77        Ok(Self {
78            client,
79            base_url,
80            token,
81        })
82    }
83
84    fn url(&self, path: &str) -> Result<Url, Error> {
85        Url::parse(&format!("{}{path}", self.base_url.trim_end_matches('/')))
86            .map_err(|_| Error::Validation("Invalid API base URL"))
87    }
88
89    async fn send<T: DeserializeOwned>(&self, request: RequestBuilder) -> Result<T, Error> {
90        let response = request
91            .bearer_auth(&self.token)
92            .send()
93            .await
94            .map_err(|_| Error::Transport)?;
95        if !response.status().is_success() {
96            // Do not echo untrusted response bodies, URLs, or credentials into terminal output.
97            return Err(Error::Http {
98                status: response.status().as_u16(),
99            });
100        }
101        let body = response.bytes().await.map_err(|_| Error::Transport)?;
102        serde_json::from_slice(&body).map_err(|_| Error::Decode)
103    }
104
105    pub async fn alert(&self, request: Alert, key: Option<String>) -> Result<AlertResult, Error> {
106        request.validate().map_err(Error::Validation)?;
107        let mut builder = self.client.post(self.url("/v1/alerts")?).json(&request);
108        if let Some(key) = key {
109            if key.is_empty() || key.len() > 200 || !key.bytes().all(|b| b.is_ascii_graphic()) {
110                return Err(Error::Validation("Invalid idempotency key"));
111            }
112            builder = builder.header("Idempotency-Key", key);
113        }
114        self.send(builder).await
115    }
116    pub async fn delivery(&self, id: i64) -> Result<Delivery, Error> {
117        if id <= 0 {
118            return Err(Error::Validation(
119                "delivery id must be a positive 64-bit integer",
120            ));
121        }
122        self.send(self.client.get(self.url(&format!("/v1/deliveries/{id}"))?))
123            .await
124    }
125    pub async fn register_heartbeat(&self, request: HeartbeatInput) -> Result<Heartbeat, Error> {
126        request.validate().map_err(Error::Validation)?;
127        self.send(self.client.post(self.url("/v1/heartbeats")?).json(&request))
128            .await
129    }
130    pub async fn check_in(&self, id: String) -> Result<Heartbeat, Error> {
131        validate_id(&id).map_err(Error::Validation)?;
132        self.send(
133            self.client
134                .post(self.url("/v1/heartbeats/check-in")?)
135                .json(&CloseIssue { id }),
136        )
137        .await
138    }
139    pub async fn heartbeats(&self) -> Result<Vec<Heartbeat>, Error> {
140        self.send(self.client.get(self.url("/v1/heartbeats")?))
141            .await
142    }
143    pub async fn delete_heartbeat(&self, id: String) -> Result<(), Error> {
144        validate_id(&id).map_err(Error::Validation)?;
145        let response: serde_json::Value = self
146            .send(
147                self.client
148                    .delete(self.url("/v1/heartbeat")?)
149                    .query(&[("id", id)]),
150            )
151            .await?;
152        if response.get("deleted") != Some(&serde_json::Value::Bool(true)) {
153            return Err(Error::Decode);
154        }
155        Ok(())
156    }
157
158    pub async fn open(&self, request: OpenIssue) -> Result<MutationResult, Error> {
159        request.validate().map_err(Error::Validation)?;
160        self.send(
161            self.client
162                .post(self.url("/v1/issues/open")?)
163                .json(&request),
164        )
165        .await
166    }
167
168    pub async fn close(&self, id: String) -> Result<MutationResult, Error> {
169        validate_id(&id).map_err(Error::Validation)?;
170        self.send(
171            self.client
172                .post(self.url("/v1/issues/close")?)
173                .json(&CloseIssue { id }),
174        )
175        .await
176    }
177
178    pub async fn get(&self, id: String) -> Result<Issue, Error> {
179        validate_id(&id).map_err(Error::Validation)?;
180        // Query encoding preserves even IDs such as ".", "..", or "a/../b" that URL
181        // libraries and proxies would normalize when placed in the URL path.
182        self.send(self.client.get(self.url("/v1/issue")?).query(&[("id", id)]))
183            .await
184    }
185
186    pub async fn list(
187        &self,
188        status: Option<IssueStatus>,
189        limit: u32,
190        offset: u32,
191    ) -> Result<IssueList, Error> {
192        if !(1..=1000).contains(&limit) {
193            return Err(Error::Validation("limit must be between 1 and 1000"));
194        }
195        let mut params = vec![("limit", limit.to_string()), ("offset", offset.to_string())];
196        if let Some(status) = status {
197            params.push(("status", status.as_str().into()));
198        }
199        self.send(self.client.get(self.url("/v1/issues")?).query(&params))
200            .await
201    }
202}
203
204/// Errors never include credentials, request URLs, or untrusted response bodies.
205#[derive(Debug, thiserror::Error)]
206pub enum Error {
207    #[error("{0}")]
208    Configuration(String),
209    #[error("{0}")]
210    Validation(&'static str),
211    #[error(
212        "API request failed or timed out; delivery may have occurred. Reuse the alert idempotency key or check issue state before retrying"
213    )]
214    Transport,
215    #[error("API returned HTTP {status}")]
216    Http { status: u16 },
217    #[error("API returned an invalid response")]
218    Decode,
219}
220
221impl ApiClient {
222    pub async fn health(&self) -> Result<Health, Error> {
223        self.send(self.client.get(self.url("/healthz")?)).await
224    }
225    pub async fn readiness(&self) -> Result<Health, Error> {
226        self.send(self.client.get(self.url("/readyz")?)).await
227    }
228    pub async fn metrics(&self) -> Result<String, Error> {
229        let response = self
230            .client
231            .get(self.url("/metrics")?)
232            .bearer_auth(&self.token)
233            .send()
234            .await
235            .map_err(|_| Error::Transport)?;
236        if !response.status().is_success() {
237            return Err(Error::Http {
238                status: response.status().as_u16(),
239            });
240        }
241        response.text().await.map_err(|_| Error::Transport)
242    }
243}