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}
47
48impl IntegrationClient {
49    // ── Constructors ─────────────────────────────────────────────────
50
51    /// Build from an API key, transport config, and detected platform.
52    ///
53    /// Injects `X-API-KEY` as a default header on every request.
54    /// On UniFi OS the base path is `/proxy/network/integration/`;
55    /// on standalone controllers it's just `/integration/`.
56    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    /// Wrap an existing `reqwest::Client` (caller manages auth headers).
77    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    /// Build the base URL with correct platform prefix + `/integration/`.
87    ///
88    /// UniFi OS: `https://host/proxy/network/integration/`
89    /// Standalone: `https://host/integration/`
90    fn normalize_base_url(raw: &str, platform: crate::ControllerPlatform) -> Result<Url, Error> {
91        let mut url = Url::parse(raw)?;
92
93        // Strip trailing slash for uniform handling
94        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    // ── URL builder ──────────────────────────────────────────────────
107
108    /// Join a relative path (e.g. `"v1/sites"`) onto the base URL.
109    fn url(&self, path: &str) -> Url {
110        // base_url always ends with `/integration/`, so joining `v1/…` works.
111        self.base_url
112            .join(path)
113            .expect("path should be valid relative URL")
114    }
115
116    // ── HTTP verbs ───────────────────────────────────────────────────
117
118    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    // ── Response handling ────────────────────────────────────────────
215
216    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    // ── Pagination helper ────────────────────────────────────────────
271
272    /// Collect all pages into a single `Vec<T>`.
273    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}