1use reqwest::header::{HeaderMap, HeaderValue};
2use secrecy::ExposeSecret;
3use serde::Serialize;
4use serde::de::DeserializeOwned;
5use serde_json::Value;
6use tracing::debug;
7use url::Url;
8
9use super::types::{
10 CloudDevice, FleetPage, FleetSite, Host, IspMetric, IspMetricInterval, SdWanConfig, SdWanStatus,
11};
12use crate::Error;
13
14#[derive(serde::Deserialize)]
15struct FleetEnvelope {
16 data: Value,
17 #[serde(rename = "traceId")]
18 trace_id: Option<String>,
19 #[serde(rename = "nextToken")]
20 next_token: Option<String>,
21 status: Option<String>,
22}
23
24#[derive(serde::Deserialize)]
25struct ErrorResponse {
26 #[serde(default)]
27 message: Option<String>,
28 #[serde(default)]
29 code: Option<String>,
30}
31
32pub struct SiteManagerClient {
33 http: reqwest::Client,
34 base_url: Url,
35}
36
37impl SiteManagerClient {
38 pub fn from_api_key(
39 base_url: &str,
40 api_key: &secrecy::SecretString,
41 transport: &crate::TransportConfig,
42 ) -> Result<Self, Error> {
43 let mut headers = HeaderMap::new();
44 let mut key_value = HeaderValue::from_str(api_key.expose_secret()).map_err(|error| {
45 Error::Authentication {
46 message: format!("invalid API key header value: {error}"),
47 }
48 })?;
49 key_value.set_sensitive(true);
50 headers.insert("X-API-KEY", key_value);
51
52 let http = transport.build_client_with_headers(headers)?;
53 let base_url = Self::normalize_base_url(base_url)?;
54
55 Ok(Self { http, base_url })
56 }
57
58 pub fn from_reqwest(base_url: &str, http: reqwest::Client) -> Result<Self, Error> {
59 let base_url = Self::normalize_base_url(base_url)?;
60 Ok(Self { http, base_url })
61 }
62
63 fn normalize_base_url(raw: &str) -> Result<Url, Error> {
64 let mut url = Url::parse(raw)?;
65 let path = url.path().trim_end_matches('/').to_owned();
66 if path.ends_with("/v1") {
67 url.set_path(&format!("{path}/"));
68 } else {
69 url.set_path(&format!("{path}/v1/"));
70 }
71 Ok(url)
72 }
73
74 fn url(&self, path: &str) -> Url {
75 self.base_url
76 .join(path)
77 .expect("site manager path should be a valid relative URL")
78 }
79
80 pub async fn list_hosts(&self) -> Result<Vec<Host>, Error> {
81 Ok(self.paginate("hosts", Vec::new()).await?.data)
82 }
83
84 pub async fn get_host(&self, host_id: &str) -> Result<Host, Error> {
85 let envelope = self.get_envelope(&format!("hosts/{host_id}"), &[]).await?;
86 Self::decode_single(envelope)
87 }
88
89 pub async fn list_sites(&self) -> Result<Vec<FleetSite>, Error> {
90 Ok(self.paginate("sites", Vec::new()).await?.data)
91 }
92
93 pub async fn list_devices(&self, host_ids: &[String]) -> Result<Vec<CloudDevice>, Error> {
94 let params = host_ids
95 .iter()
96 .map(|host_id| ("hostIds", host_id.clone()))
97 .collect();
98 Ok(self.paginate("devices", params).await?.data)
99 }
100
101 pub async fn get_isp_metrics(
102 &self,
103 interval: IspMetricInterval,
104 ) -> Result<FleetPage<IspMetric>, Error> {
105 self.paginate(
106 &format!("isp-metrics/{}", interval.as_path_segment()),
107 Vec::new(),
108 )
109 .await
110 }
111
112 pub async fn query_isp_metrics(
113 &self,
114 interval: IspMetricInterval,
115 site_ids: &[String],
116 ) -> Result<FleetPage<IspMetric>, Error> {
117 let body = serde_json::json!({ "siteIds": site_ids });
118 let envelope = self
119 .post_envelope(
120 &format!("isp-metrics/{}/query", interval.as_path_segment()),
121 &body,
122 )
123 .await?;
124 Self::decode_list(envelope)
125 }
126
127 pub async fn list_sdwan_configs(&self) -> Result<Vec<SdWanConfig>, Error> {
128 Ok(self.paginate("sd-wan-configs", Vec::new()).await?.data)
129 }
130
131 pub async fn get_sdwan_config(&self, config_id: &str) -> Result<SdWanConfig, Error> {
132 let envelope = self
133 .get_envelope(&format!("sd-wan-configs/{config_id}"), &[])
134 .await?;
135 Self::decode_single(envelope)
136 }
137
138 pub async fn get_sdwan_status(&self, config_id: &str) -> Result<SdWanStatus, Error> {
139 let envelope = self
140 .get_envelope(&format!("sd-wan-configs/{config_id}/status"), &[])
141 .await?;
142 Self::decode_single(envelope)
143 }
144
145 async fn paginate<T>(
146 &self,
147 path: &str,
148 params: Vec<(&str, String)>,
149 ) -> Result<FleetPage<T>, Error>
150 where
151 T: DeserializeOwned,
152 {
153 let mut data = Vec::new();
154 let mut next_token: Option<String> = None;
155 let mut trace_id: Option<String> = None;
156 let mut status: Option<String> = None;
157
158 loop {
159 let mut page_params = params.clone();
160 if let Some(ref token) = next_token {
161 page_params.push(("nextToken", token.clone()));
162 }
163
164 let envelope = self.get_envelope(path, &page_params).await?;
165 let page = Self::decode_list(envelope)?;
166 trace_id = page.trace_id.clone().or(trace_id);
167 status = page.status.clone().or(status);
168 next_token.clone_from(&page.next_token);
169 data.extend(page.data);
170
171 if next_token.is_none() {
172 break;
173 }
174 }
175
176 Ok(FleetPage {
177 data,
178 next_token,
179 trace_id,
180 status,
181 })
182 }
183
184 async fn get_envelope(
185 &self,
186 path: &str,
187 params: &[(&str, String)],
188 ) -> Result<FleetEnvelope, Error> {
189 let url = self.url(path);
190 debug!("GET {url} params={params:?}");
191 let response = self.http.get(url).query(params).send().await?;
192 self.handle_response(response).await
193 }
194
195 async fn post_envelope<B: Serialize + Sync>(
196 &self,
197 path: &str,
198 body: &B,
199 ) -> Result<FleetEnvelope, Error> {
200 let url = self.url(path);
201 debug!("POST {url}");
202 let response = self.http.post(url).json(body).send().await?;
203 self.handle_response(response).await
204 }
205
206 async fn handle_response(&self, response: reqwest::Response) -> Result<FleetEnvelope, Error> {
207 let status = response.status();
208 if status.is_success() {
209 let body = response.text().await?;
210 serde_json::from_str(&body).map_err(|error| {
211 let preview = &body[..body.len().min(200)];
212 Error::Deserialization {
213 message: format!("{error} (body preview: {preview:?})"),
214 body,
215 }
216 })
217 } else {
218 Err(self.parse_error(status, response).await)
219 }
220 }
221
222 async fn parse_error(&self, status: reqwest::StatusCode, response: reqwest::Response) -> Error {
223 if status == reqwest::StatusCode::UNAUTHORIZED {
224 return Error::InvalidApiKey;
225 }
226
227 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
228 return Error::RateLimited {
229 retry_after_secs: retry_after_secs(&response).unwrap_or(5),
230 };
231 }
232
233 let raw = response.text().await.unwrap_or_default();
234 if let Ok(error) = serde_json::from_str::<ErrorResponse>(&raw) {
235 return Error::Integration {
236 status: status.as_u16(),
237 message: error.message.unwrap_or_else(|| status.to_string()),
238 code: error.code,
239 };
240 }
241
242 Error::Integration {
243 status: status.as_u16(),
244 message: if raw.is_empty() {
245 status.to_string()
246 } else {
247 raw
248 },
249 code: None,
250 }
251 }
252
253 fn decode_list<T>(envelope: FleetEnvelope) -> Result<FleetPage<T>, Error>
254 where
255 T: DeserializeOwned,
256 {
257 let items = match envelope.data {
258 Value::Array(items) => items,
259 Value::Null => Vec::new(),
260 item => vec![item],
261 };
262
263 let mut decoded = Vec::with_capacity(items.len());
264 for item in items {
265 decoded.push(decode_item(&item)?);
266 }
267
268 Ok(FleetPage {
269 data: decoded,
270 next_token: envelope.next_token,
271 trace_id: envelope.trace_id,
272 status: envelope.status,
273 })
274 }
275
276 fn decode_single<T>(envelope: FleetEnvelope) -> Result<T, Error>
277 where
278 T: DeserializeOwned,
279 {
280 match envelope.data {
281 Value::Array(mut items) => {
282 let item = items.pop().ok_or_else(|| Error::Deserialization {
283 message: "expected a single Site Manager record, got an empty list".into(),
284 body: "[]".into(),
285 })?;
286 decode_item(&item)
287 }
288 item => decode_item(&item),
289 }
290 }
291}
292
293fn decode_item<T>(item: &Value) -> Result<T, Error>
294where
295 T: DeserializeOwned,
296{
297 serde_json::from_value(item.clone()).map_err(|error| Error::Deserialization {
298 message: error.to_string(),
299 body: item.to_string(),
300 })
301}
302
303fn retry_after_secs(response: &reqwest::Response) -> Option<u64> {
304 response
305 .headers()
306 .get(reqwest::header::RETRY_AFTER)
307 .and_then(|value| value.to_str().ok())
308 .and_then(parse_retry_after)
309}
310
311fn parse_retry_after(value: &str) -> Option<u64> {
312 let trimmed = value.trim();
313 let numeric = trimmed.strip_suffix('s').unwrap_or(trimmed);
314 if let Ok(seconds) = numeric.parse::<u64>() {
315 return Some(seconds);
316 }
317
318 let (whole, fractional) = numeric.split_once('.')?;
319 let whole = whole.parse::<u64>().ok()?;
320 let has_fraction = fractional.chars().any(|ch| ch != '0');
321 Some(whole + u64::from(has_fraction))
322}