1use std::future::Future;
7
8use reqwest::header::{HeaderMap, HeaderValue};
9use secrecy::ExposeSecret;
10use serde::Serialize;
11use serde::de::DeserializeOwned;
12use tracing::debug;
13use url::Url;
14
15use super::types;
16use crate::Error;
17
18mod clients;
19mod devices;
20mod firewall;
21mod networks;
22mod policy;
23mod reference;
24mod system;
25mod wifi;
26
27#[derive(serde::Deserialize)]
30struct ErrorResponse {
31 #[serde(default)]
32 message: Option<String>,
33 #[serde(default)]
34 code: Option<String>,
35}
36
37pub struct IntegrationClient {
44 http: reqwest::Client,
45 base_url: Url,
46 platform: crate::ControllerPlatform,
47 cloud_host_id: Option<String>,
48}
49
50impl IntegrationClient {
51 pub fn from_api_key(
59 base_url: &str,
60 api_key: &secrecy::SecretString,
61 transport: &crate::TransportConfig,
62 platform: crate::ControllerPlatform,
63 ) -> Result<Self, Error> {
64 let mut headers = HeaderMap::new();
65 let mut key_value =
66 HeaderValue::from_str(api_key.expose_secret()).map_err(|e| Error::Authentication {
67 message: format!("invalid API key header value: {e}"),
68 })?;
69 key_value.set_sensitive(true);
70 headers.insert("X-API-KEY", key_value);
71
72 let http = transport.build_client_with_headers(headers)?;
73 let base_url = Self::normalize_base_url(base_url, platform)?;
74 let cloud_host_id = Self::extract_cloud_host_id(&base_url, platform);
75
76 Ok(Self {
77 http,
78 base_url,
79 platform,
80 cloud_host_id,
81 })
82 }
83
84 pub fn from_reqwest(
86 base_url: &str,
87 http: reqwest::Client,
88 platform: crate::ControllerPlatform,
89 ) -> Result<Self, Error> {
90 let base_url = Self::normalize_base_url(base_url, platform)?;
91 let cloud_host_id = Self::extract_cloud_host_id(&base_url, platform);
92 Ok(Self {
93 http,
94 base_url,
95 platform,
96 cloud_host_id,
97 })
98 }
99
100 fn normalize_base_url(raw: &str, platform: crate::ControllerPlatform) -> Result<Url, Error> {
105 let mut url = Url::parse(raw)?;
106
107 let path = url.path().trim_end_matches('/').to_owned();
109
110 if path.ends_with("/integration") {
111 url.set_path(&format!("{path}/"));
112 } else {
113 let prefix = platform.integration_prefix();
114 url.set_path(&format!("{path}{prefix}/"));
115 }
116
117 Ok(url)
118 }
119
120 fn extract_cloud_host_id(
121 base_url: &Url,
122 platform: crate::ControllerPlatform,
123 ) -> Option<String> {
124 if platform != crate::ControllerPlatform::Cloud {
125 return None;
126 }
127
128 let mut segments = base_url.path_segments()?;
129 while let Some(segment) = segments.next() {
130 if segment == "consoles" {
131 return segments.next().map(str::to_owned);
132 }
133 }
134
135 None
136 }
137
138 fn parse_retry_after(value: &str) -> Option<u64> {
139 let trimmed = value.trim();
140 let numeric = trimmed.strip_suffix('s').unwrap_or(trimmed);
141 if let Ok(seconds) = numeric.parse::<u64>() {
142 return Some(seconds);
143 }
144
145 let (whole, fractional) = numeric.split_once('.')?;
146 let whole = whole.parse::<u64>().ok()?;
147 let has_fraction = fractional.chars().any(|ch| ch != '0');
148 Some(whole + u64::from(has_fraction))
149 }
150
151 fn retry_after_secs(resp: &reqwest::Response) -> Option<u64> {
152 resp.headers()
153 .get(reqwest::header::RETRY_AFTER)
154 .and_then(|value| value.to_str().ok())
155 .and_then(Self::parse_retry_after)
156 }
157
158 fn url(&self, path: &str) -> Url {
162 self.base_url
164 .join(path)
165 .expect("path should be valid relative URL")
166 }
167
168 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
171 let url = self.url(path);
172 debug!("GET {url}");
173
174 let resp = self.http.get(url).send().await?;
175 self.handle_response(resp).await
176 }
177
178 async fn get_with_params<T: DeserializeOwned>(
179 &self,
180 path: &str,
181 params: &[(&str, String)],
182 ) -> Result<T, Error> {
183 let url = self.url(path);
184 debug!("GET {url} params={params:?}");
185
186 let resp = self.http.get(url).query(params).send().await?;
187 self.handle_response(resp).await
188 }
189
190 async fn post<T: DeserializeOwned, B: Serialize + Sync>(
191 &self,
192 path: &str,
193 body: &B,
194 ) -> Result<T, Error> {
195 let url = self.url(path);
196 debug!("POST {url}");
197
198 let resp = self.http.post(url).json(body).send().await?;
199 self.handle_response(resp).await
200 }
201
202 async fn post_no_response<B: Serialize + Sync>(
203 &self,
204 path: &str,
205 body: &B,
206 ) -> Result<(), Error> {
207 let url = self.url(path);
208 debug!("POST {url}");
209
210 let resp = self.http.post(url).json(body).send().await?;
211 self.handle_empty(resp).await
212 }
213
214 async fn put<T: DeserializeOwned, B: Serialize + Sync>(
215 &self,
216 path: &str,
217 body: &B,
218 ) -> Result<T, Error> {
219 let url = self.url(path);
220 debug!("PUT {url}");
221
222 let resp = self.http.put(url).json(body).send().await?;
223 self.handle_response(resp).await
224 }
225
226 async fn patch<T: DeserializeOwned, B: Serialize + Sync>(
227 &self,
228 path: &str,
229 body: &B,
230 ) -> Result<T, Error> {
231 let url = self.url(path);
232 debug!("PATCH {url}");
233
234 let resp = self.http.patch(url).json(body).send().await?;
235 self.handle_response(resp).await
236 }
237
238 async fn delete(&self, path: &str) -> Result<(), Error> {
239 let url = self.url(path);
240 debug!("DELETE {url}");
241
242 let resp = self.http.delete(url).send().await?;
243 self.handle_empty(resp).await
244 }
245
246 async fn delete_with_response<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
247 let url = self.url(path);
248 debug!("DELETE {url}");
249
250 let resp = self.http.delete(url).send().await?;
251 self.handle_response(resp).await
252 }
253
254 async fn delete_with_params<T: DeserializeOwned>(
255 &self,
256 path: &str,
257 params: &[(&str, String)],
258 ) -> Result<T, Error> {
259 let url = self.url(path);
260 debug!("DELETE {url} params={params:?}");
261
262 let resp = self.http.delete(url).query(params).send().await?;
263 self.handle_response(resp).await
264 }
265
266 async fn handle_response<T: DeserializeOwned>(
269 &self,
270 resp: reqwest::Response,
271 ) -> Result<T, Error> {
272 let status = resp.status();
273 if status.is_success() {
274 let body = resp.text().await?;
275 serde_json::from_str(&body).map_err(|e| {
276 let preview = &body[..body.len().min(200)];
277 Error::Deserialization {
278 message: format!("{e} (body preview: {preview:?})"),
279 body,
280 }
281 })
282 } else {
283 Err(self.parse_error(status, resp).await)
284 }
285 }
286
287 async fn handle_empty(&self, resp: reqwest::Response) -> Result<(), Error> {
288 let status = resp.status();
289 if status.is_success() {
290 Ok(())
291 } else {
292 Err(self.parse_error(status, resp).await)
293 }
294 }
295
296 async fn parse_error(&self, status: reqwest::StatusCode, resp: reqwest::Response) -> Error {
297 if status == reqwest::StatusCode::UNAUTHORIZED {
298 return Error::InvalidApiKey;
299 }
300
301 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
302 return Error::RateLimited {
303 retry_after_secs: Self::retry_after_secs(&resp).unwrap_or(5),
304 };
305 }
306
307 if self.platform == crate::ControllerPlatform::Cloud {
308 let host_id = self
309 .cloud_host_id
310 .clone()
311 .unwrap_or_else(|| "<unknown>".into());
312
313 if status == reqwest::StatusCode::FORBIDDEN {
314 return Error::ConsoleAccessDenied { host_id };
315 }
316
317 if status == reqwest::StatusCode::REQUEST_TIMEOUT {
318 return Error::ConsoleOffline { host_id };
319 }
320 }
321
322 let raw = resp.text().await.unwrap_or_default();
323
324 if let Ok(err) = serde_json::from_str::<ErrorResponse>(&raw) {
325 Error::Integration {
326 status: status.as_u16(),
327 message: err.message.unwrap_or_else(|| status.to_string()),
328 code: err.code,
329 }
330 } else {
331 Error::Integration {
332 status: status.as_u16(),
333 message: if raw.is_empty() {
334 status.to_string()
335 } else {
336 raw
337 },
338 code: None,
339 }
340 }
341 }
342
343 pub async fn paginate_all<T, F, Fut>(&self, limit: i32, fetch: F) -> Result<Vec<T>, Error>
347 where
348 F: Fn(i64, i32) -> Fut,
349 Fut: Future<Output = Result<types::Page<T>, Error>>,
350 {
351 let mut all = Vec::new();
352 let mut offset: i64 = 0;
353
354 loop {
355 let page = fetch(offset, limit).await?;
356 let received = page.data.len();
357 all.extend(page.data);
358
359 let limit_usize = usize::try_from(limit).unwrap_or(0);
360 if received < limit_usize
361 || i64::try_from(all.len()).unwrap_or(i64::MAX) >= page.total_count
362 {
363 break;
364 }
365
366 offset += i64::try_from(received).unwrap_or(i64::MAX);
367 }
368
369 Ok(all)
370 }
371}