Skip to main content

flares_client/
lib.rs

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