Skip to main content

unifi_cli/api/
client.rs

1use reqwest::header::{HeaderMap, HeaderValue};
2use serde::de::DeserializeOwned;
3
4use super::types::*;
5
6pub struct UnifiClient {
7    http: reqwest::Client,
8    base_url: String,
9    site_id: Option<String>,
10}
11
12impl UnifiClient {
13    pub fn new(host: &str, api_key: &str) -> Result<Self, ApiError> {
14        let mut headers = HeaderMap::new();
15        headers.insert(
16            "X-API-KEY",
17            HeaderValue::from_str(api_key).map_err(|e| ApiError::Other(e.to_string()))?,
18        );
19
20        let http = reqwest::Client::builder()
21            .danger_accept_invalid_certs(true)
22            .default_headers(headers)
23            .timeout(std::time::Duration::from_secs(30))
24            .build()
25            .map_err(ApiError::Http)?;
26
27        let base_url = if host.starts_with("http") {
28            host.trim_end_matches('/').to_string()
29        } else {
30            format!("https://{host}")
31        };
32
33        Ok(Self {
34            http,
35            base_url,
36            site_id: None,
37        })
38    }
39
40    fn error_for_status(status: u16, message: String) -> ApiError {
41        match status {
42            401 | 403 => ApiError::Auth(message),
43            404 => ApiError::NotFound(message),
44            _ => ApiError::Api { status, message },
45        }
46    }
47
48    // Auto-discover site UUID from Integration API
49    async fn ensure_site_id(&mut self) -> Result<&str, ApiError> {
50        if self.site_id.is_none() {
51            let resp: PaginatedResponse<Site> = self
52                .get_integration("/proxy/network/integration/v1/sites")
53                .await?;
54            let site = resp.data.into_iter().next().ok_or_else(|| {
55                ApiError::Other("No sites found — check that the API key has site access".into())
56            })?;
57            self.site_id = Some(site.id);
58        }
59        Ok(self.site_id.as_deref().unwrap())
60    }
61
62    async fn get_integration<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
63        let url = format!("{}{path}", self.base_url);
64        let resp = self.http.get(&url).send().await?;
65        let status = resp.status().as_u16();
66        if !resp.status().is_success() {
67            let body = resp.text().await.unwrap_or_default();
68            return Err(Self::error_for_status(status, body));
69        }
70        Ok(resp.json().await?)
71    }
72
73    async fn get_legacy<T: DeserializeOwned>(&self, path: &str) -> Result<Vec<T>, ApiError> {
74        let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
75        let resp = self.http.get(&url).send().await?;
76        let status = resp.status().as_u16();
77        if !resp.status().is_success() {
78            let body = resp.text().await.unwrap_or_default();
79            return Err(Self::error_for_status(status, body));
80        }
81        let legacy: LegacyResponse<T> = resp.json().await?;
82        if legacy.meta.rc != "ok" {
83            return Err(ApiError::Api {
84                status: 200,
85                message: legacy.meta.msg.unwrap_or_else(|| "unknown error".into()),
86            });
87        }
88        Ok(legacy.data)
89    }
90
91    async fn post_legacy_cmd(
92        &self,
93        manager: &str,
94        body: serde_json::Value,
95    ) -> Result<serde_json::Value, ApiError> {
96        let url = format!(
97            "{}/proxy/network/api/s/default/cmd/{manager}",
98            self.base_url
99        );
100        let resp = self.http.post(&url).json(&body).send().await?;
101        let status = resp.status().as_u16();
102        if !resp.status().is_success() {
103            let body = resp.text().await.unwrap_or_default();
104            return Err(Self::error_for_status(status, body));
105        }
106        Ok(resp.json().await?)
107    }
108
109    async fn put_legacy<T: serde::Serialize>(
110        &self,
111        path: &str,
112        body: &T,
113    ) -> Result<serde_json::Value, ApiError> {
114        let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
115        let resp = self.http.put(&url).json(body).send().await?;
116        let status = resp.status().as_u16();
117        if !resp.status().is_success() {
118            let body = resp.text().await.unwrap_or_default();
119            return Err(Self::error_for_status(status, body));
120        }
121        Ok(resp.json().await?)
122    }
123
124    async fn post_legacy<T: serde::Serialize>(
125        &self,
126        path: &str,
127        body: &T,
128    ) -> Result<serde_json::Value, ApiError> {
129        let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
130        let resp = self.http.post(&url).json(body).send().await?;
131        let status = resp.status().as_u16();
132        if !resp.status().is_success() {
133            let body = resp.text().await.unwrap_or_default();
134            return Err(Self::error_for_status(status, body));
135        }
136        Ok(resp.json().await?)
137    }
138
139    // Paginate through all results from Integration API
140    async fn paginate_all<T: DeserializeOwned>(&self, base_path: &str) -> Result<Vec<T>, ApiError> {
141        let mut all = Vec::new();
142        let mut offset = 0;
143        let limit = 200;
144
145        loop {
146            let separator = if base_path.contains('?') { '&' } else { '?' };
147            let path = format!("{base_path}{separator}offset={offset}&limit={limit}");
148            let resp: PaginatedResponse<T> = self.get_integration(&path).await?;
149            let count = resp.data.len();
150            all.extend(resp.data);
151
152            if all.len() >= resp.total_count || count < limit {
153                break;
154            }
155            offset += count;
156        }
157
158        Ok(all)
159    }
160
161    // --- Public API ---
162
163    // Clients
164    pub async fn list_clients(&mut self) -> Result<Vec<Client>, ApiError> {
165        let site_id = self.ensure_site_id().await?.to_string();
166        self.paginate_all(&format!(
167            "/proxy/network/integration/v1/sites/{site_id}/clients"
168        ))
169        .await
170    }
171
172    pub async fn get_client_detail(&self, mac: &str) -> Result<LegacyClient, ApiError> {
173        let normalized = normalize_mac(mac);
174        let clients: Vec<LegacyClient> = self.get_legacy("/stat/sta").await?;
175        clients
176            .into_iter()
177            .find(|c| {
178                c.mac
179                    .as_deref()
180                    .is_some_and(|m| normalize_mac(m) == normalized)
181            })
182            .ok_or_else(|| ApiError::NotFound(format!("Client with MAC {mac}")))
183    }
184
185    pub async fn set_fixed_ip(
186        &self,
187        mac: &str,
188        ip: &str,
189        name: Option<&str>,
190    ) -> Result<(), ApiError> {
191        let normalized = normalize_mac(mac);
192
193        // Find client _id from legacy stat/sta
194        let clients: Vec<LegacyClient> = self.get_legacy("/stat/sta").await?;
195        let client = clients
196            .into_iter()
197            .find(|c| {
198                c.mac
199                    .as_deref()
200                    .is_some_and(|m| normalize_mac(m) == normalized)
201            })
202            .ok_or_else(|| ApiError::NotFound(format!("Client with MAC {mac}")))?;
203
204        let mut payload = serde_json::json!({
205            "mac": format_mac(&normalized),
206            "use_fixedip": true,
207            "fixed_ip": ip,
208        });
209
210        if let Some(n) = name {
211            payload["name"] = serde_json::Value::String(n.to_string());
212            payload["noted"] = serde_json::Value::Bool(true);
213        }
214
215        let path = format!("/rest/user/{}", client.id);
216        match self.put_legacy(&path, &payload).await {
217            Ok(_) => Ok(()),
218            Err(ApiError::NotFound(_)) => {
219                // Client doesn't have a user entry yet, create one
220                self.post_legacy("/rest/user", &payload).await?;
221                Ok(())
222            }
223            Err(e) => Err(e),
224        }
225    }
226
227    pub async fn block_client(&self, mac: &str) -> Result<(), ApiError> {
228        let formatted = format_mac(&normalize_mac(mac));
229        self.post_legacy_cmd(
230            "stamgr",
231            serde_json::json!({"cmd": "block-sta", "mac": formatted}),
232        )
233        .await?;
234        Ok(())
235    }
236
237    pub async fn unblock_client(&self, mac: &str) -> Result<(), ApiError> {
238        let formatted = format_mac(&normalize_mac(mac));
239        self.post_legacy_cmd(
240            "stamgr",
241            serde_json::json!({"cmd": "unblock-sta", "mac": formatted}),
242        )
243        .await?;
244        Ok(())
245    }
246
247    pub async fn kick_client(&self, mac: &str) -> Result<(), ApiError> {
248        let formatted = format_mac(&normalize_mac(mac));
249        self.post_legacy_cmd(
250            "stamgr",
251            serde_json::json!({"cmd": "kick-sta", "mac": formatted}),
252        )
253        .await?;
254        Ok(())
255    }
256
257    // Devices
258    pub async fn list_devices(&mut self) -> Result<Vec<Device>, ApiError> {
259        let site_id = self.ensure_site_id().await?.to_string();
260        self.paginate_all(&format!(
261            "/proxy/network/integration/v1/sites/{site_id}/devices"
262        ))
263        .await
264    }
265
266    pub async fn get_device_detail(&self, mac: &str) -> Result<LegacyDevice, ApiError> {
267        let normalized = normalize_mac(mac);
268        let devices: Vec<LegacyDevice> = self.get_legacy("/stat/device").await?;
269        devices
270            .into_iter()
271            .find(|d| {
272                d.mac
273                    .as_deref()
274                    .is_some_and(|m| normalize_mac(m) == normalized)
275            })
276            .ok_or_else(|| ApiError::NotFound(format!("Device with MAC {mac}")))
277    }
278
279    pub async fn restart_device(&self, mac: &str) -> Result<(), ApiError> {
280        let formatted = format_mac(&normalize_mac(mac));
281        self.post_legacy_cmd(
282            "devmgr",
283            serde_json::json!({"cmd": "restart", "mac": formatted}),
284        )
285        .await?;
286        Ok(())
287    }
288
289    pub async fn upgrade_device(&self, mac: &str) -> Result<(), ApiError> {
290        let formatted = format_mac(&normalize_mac(mac));
291        self.post_legacy_cmd(
292            "devmgr",
293            serde_json::json!({"cmd": "upgrade", "mac": formatted}),
294        )
295        .await?;
296        Ok(())
297    }
298
299    pub async fn locate_device(&self, mac: &str, enable: bool) -> Result<(), ApiError> {
300        let formatted = format_mac(&normalize_mac(mac));
301        let cmd = if enable { "set-locate" } else { "unset-locate" };
302        self.post_legacy_cmd("devmgr", serde_json::json!({"cmd": cmd, "mac": formatted}))
303            .await?;
304        Ok(())
305    }
306
307    // Networks
308    pub async fn list_networks(&mut self) -> Result<Vec<Network>, ApiError> {
309        let site_id = self.ensure_site_id().await?.to_string();
310        self.paginate_all(&format!(
311            "/proxy/network/integration/v1/sites/{site_id}/networks"
312        ))
313        .await
314    }
315
316    // Events
317    pub async fn list_events(&self, limit: usize) -> Result<Vec<Event>, ApiError> {
318        let events: Vec<Event> = self
319            .get_legacy(&format!("/stat/event?_limit={limit}"))
320            .await?;
321        Ok(events)
322    }
323
324    // Port table for a specific device
325    pub async fn get_device_ports(&self, mac: &str) -> Result<DeviceWithPorts, ApiError> {
326        let normalized = normalize_mac(mac);
327        let devices: Vec<DeviceWithPorts> = self.get_legacy("/stat/device").await?;
328        devices
329            .into_iter()
330            .find(|d| {
331                d.mac
332                    .as_deref()
333                    .is_some_and(|m| normalize_mac(m) == normalized)
334            })
335            .ok_or_else(|| ApiError::NotFound(format!("Device with MAC {mac}")))
336    }
337
338    // All clients with bandwidth data (legacy endpoint for richer stats)
339    pub async fn list_clients_legacy(&self) -> Result<Vec<LegacyClient>, ApiError> {
340        self.get_legacy("/stat/sta").await
341    }
342
343    // All devices with full detail (legacy endpoint)
344    pub async fn get_legacy_devices(&self) -> Result<Vec<LegacyDevice>, ApiError> {
345        self.get_legacy("/stat/device").await
346    }
347
348    // System
349    pub async fn get_health(&self) -> Result<Vec<HealthSubsystem>, ApiError> {
350        self.get_legacy("/stat/health").await
351    }
352
353    pub async fn get_sysinfo(&self) -> Result<SysInfo, ApiError> {
354        let mut data: Vec<SysInfo> = self.get_legacy("/stat/sysinfo").await?;
355        data.pop()
356            .ok_or_else(|| ApiError::Other("No sysinfo returned".into()))
357    }
358}