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 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 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(¶ms))
180 .await
181 }
182}
183
184#[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}