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 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 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 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 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(¶ms))
200 .await
201 }
202}
203
204#[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}