Skip to main content

unifly_api/site_manager/
client.rs

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}