Skip to main content

unifly_api/integration/
client.rs

1// Hand-crafted async HTTP client for the UniFi Network Integration API (v10.1.84).
2//
3// Base path: /integration/v1/
4// Auth: X-API-KEY header
5
6use 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// ── Error response shape from the Integration API ────────────────────
28
29#[derive(serde::Deserialize)]
30struct ErrorResponse {
31    #[serde(default)]
32    message: Option<String>,
33    #[serde(default)]
34    code: Option<String>,
35}
36
37// ── Client ───────────────────────────────────────────────────────────
38
39/// Async client for the UniFi Integration API.
40///
41/// Uses API-key authentication and communicates via JSON REST endpoints
42/// under `/integration/v1/`.
43pub 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    // ── Constructors ─────────────────────────────────────────────────
52
53    /// Build from an API key, transport config, and detected platform.
54    ///
55    /// Injects `X-API-KEY` as a default header on every request.
56    /// On UniFi OS the base path is `/proxy/network/integration/`;
57    /// on standalone controllers it's just `/integration/`.
58    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    /// Wrap an existing `reqwest::Client` (caller manages auth headers).
85    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    /// Build the base URL with correct platform prefix + `/integration/`.
101    ///
102    /// UniFi OS: `https://host/proxy/network/integration/`
103    /// Standalone: `https://host/integration/`
104    fn normalize_base_url(raw: &str, platform: crate::ControllerPlatform) -> Result<Url, Error> {
105        let mut url = Url::parse(raw)?;
106
107        // Strip trailing slash for uniform handling
108        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    // ── URL builder ──────────────────────────────────────────────────
159
160    /// Join a relative path (e.g. `"v1/sites"`) onto the base URL.
161    fn url(&self, path: &str) -> Url {
162        // base_url always ends with `/integration/`, so joining `v1/…` works.
163        self.base_url
164            .join(path)
165            .expect("path should be valid relative URL")
166    }
167
168    // ── HTTP verbs ───────────────────────────────────────────────────
169
170    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    // ── Response handling ────────────────────────────────────────────
267
268    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    // ── Pagination helper ────────────────────────────────────────────
344
345    /// Collect all pages into a single `Vec<T>`.
346    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}