unifly_api/integration/
client.rs1use 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}
47
48impl IntegrationClient {
49 pub fn from_api_key(
57 base_url: &str,
58 api_key: &secrecy::SecretString,
59 transport: &crate::TransportConfig,
60 platform: crate::ControllerPlatform,
61 ) -> Result<Self, Error> {
62 let mut headers = HeaderMap::new();
63 let mut key_value =
64 HeaderValue::from_str(api_key.expose_secret()).map_err(|e| Error::Authentication {
65 message: format!("invalid API key header value: {e}"),
66 })?;
67 key_value.set_sensitive(true);
68 headers.insert("X-API-KEY", key_value);
69
70 let http = transport.build_client_with_headers(headers)?;
71 let base_url = Self::normalize_base_url(base_url, platform)?;
72
73 Ok(Self { http, base_url })
74 }
75
76 pub fn from_reqwest(
78 base_url: &str,
79 http: reqwest::Client,
80 platform: crate::ControllerPlatform,
81 ) -> Result<Self, Error> {
82 let base_url = Self::normalize_base_url(base_url, platform)?;
83 Ok(Self { http, base_url })
84 }
85
86 fn normalize_base_url(raw: &str, platform: crate::ControllerPlatform) -> Result<Url, Error> {
91 let mut url = Url::parse(raw)?;
92
93 let path = url.path().trim_end_matches('/').to_owned();
95
96 if path.ends_with("/integration") {
97 url.set_path(&format!("{path}/"));
98 } else {
99 let prefix = platform.integration_prefix();
100 url.set_path(&format!("{path}{prefix}/"));
101 }
102
103 Ok(url)
104 }
105
106 fn url(&self, path: &str) -> Url {
110 self.base_url
112 .join(path)
113 .expect("path should be valid relative URL")
114 }
115
116 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
119 let url = self.url(path);
120 debug!("GET {url}");
121
122 let resp = self.http.get(url).send().await?;
123 self.handle_response(resp).await
124 }
125
126 async fn get_with_params<T: DeserializeOwned>(
127 &self,
128 path: &str,
129 params: &[(&str, String)],
130 ) -> Result<T, Error> {
131 let url = self.url(path);
132 debug!("GET {url} params={params:?}");
133
134 let resp = self.http.get(url).query(params).send().await?;
135 self.handle_response(resp).await
136 }
137
138 async fn post<T: DeserializeOwned, B: Serialize + Sync>(
139 &self,
140 path: &str,
141 body: &B,
142 ) -> Result<T, Error> {
143 let url = self.url(path);
144 debug!("POST {url}");
145
146 let resp = self.http.post(url).json(body).send().await?;
147 self.handle_response(resp).await
148 }
149
150 async fn post_no_response<B: Serialize + Sync>(
151 &self,
152 path: &str,
153 body: &B,
154 ) -> Result<(), Error> {
155 let url = self.url(path);
156 debug!("POST {url}");
157
158 let resp = self.http.post(url).json(body).send().await?;
159 self.handle_empty(resp).await
160 }
161
162 async fn put<T: DeserializeOwned, B: Serialize + Sync>(
163 &self,
164 path: &str,
165 body: &B,
166 ) -> Result<T, Error> {
167 let url = self.url(path);
168 debug!("PUT {url}");
169
170 let resp = self.http.put(url).json(body).send().await?;
171 self.handle_response(resp).await
172 }
173
174 async fn patch<T: DeserializeOwned, B: Serialize + Sync>(
175 &self,
176 path: &str,
177 body: &B,
178 ) -> Result<T, Error> {
179 let url = self.url(path);
180 debug!("PATCH {url}");
181
182 let resp = self.http.patch(url).json(body).send().await?;
183 self.handle_response(resp).await
184 }
185
186 async fn delete(&self, path: &str) -> Result<(), Error> {
187 let url = self.url(path);
188 debug!("DELETE {url}");
189
190 let resp = self.http.delete(url).send().await?;
191 self.handle_empty(resp).await
192 }
193
194 async fn delete_with_response<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
195 let url = self.url(path);
196 debug!("DELETE {url}");
197
198 let resp = self.http.delete(url).send().await?;
199 self.handle_response(resp).await
200 }
201
202 async fn delete_with_params<T: DeserializeOwned>(
203 &self,
204 path: &str,
205 params: &[(&str, String)],
206 ) -> Result<T, Error> {
207 let url = self.url(path);
208 debug!("DELETE {url} params={params:?}");
209
210 let resp = self.http.delete(url).query(params).send().await?;
211 self.handle_response(resp).await
212 }
213
214 async fn handle_response<T: DeserializeOwned>(
217 &self,
218 resp: reqwest::Response,
219 ) -> Result<T, Error> {
220 let status = resp.status();
221 if status.is_success() {
222 let body = resp.text().await?;
223 serde_json::from_str(&body).map_err(|e| {
224 let preview = &body[..body.len().min(200)];
225 Error::Deserialization {
226 message: format!("{e} (body preview: {preview:?})"),
227 body,
228 }
229 })
230 } else {
231 Err(self.parse_error(status, resp).await)
232 }
233 }
234
235 async fn handle_empty(&self, resp: reqwest::Response) -> Result<(), Error> {
236 let status = resp.status();
237 if status.is_success() {
238 Ok(())
239 } else {
240 Err(self.parse_error(status, resp).await)
241 }
242 }
243
244 async fn parse_error(&self, status: reqwest::StatusCode, resp: reqwest::Response) -> Error {
245 if status == reqwest::StatusCode::UNAUTHORIZED {
246 return Error::InvalidApiKey;
247 }
248
249 let raw = resp.text().await.unwrap_or_default();
250
251 if let Ok(err) = serde_json::from_str::<ErrorResponse>(&raw) {
252 Error::Integration {
253 status: status.as_u16(),
254 message: err.message.unwrap_or_else(|| status.to_string()),
255 code: err.code,
256 }
257 } else {
258 Error::Integration {
259 status: status.as_u16(),
260 message: if raw.is_empty() {
261 status.to_string()
262 } else {
263 raw
264 },
265 code: None,
266 }
267 }
268 }
269
270 pub async fn paginate_all<T, F, Fut>(&self, limit: i32, fetch: F) -> Result<Vec<T>, Error>
274 where
275 F: Fn(i64, i32) -> Fut,
276 Fut: Future<Output = Result<types::Page<T>, Error>>,
277 {
278 let mut all = Vec::new();
279 let mut offset: i64 = 0;
280
281 loop {
282 let page = fetch(offset, limit).await?;
283 let received = page.data.len();
284 all.extend(page.data);
285
286 let limit_usize = usize::try_from(limit).unwrap_or(0);
287 if received < limit_usize
288 || i64::try_from(all.len()).unwrap_or(i64::MAX) >= page.total_count
289 {
290 break;
291 }
292
293 offset += i64::try_from(received).unwrap_or(i64::MAX);
294 }
295
296 Ok(all)
297 }
298}