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 pub fn clone_http(&self) -> reqwest::Client {
41 self.http.clone()
42 }
43
44 pub fn base_url(&self) -> &str {
45 &self.base_url
46 }
47
48 fn error_for_status(status: u16, message: String) -> ApiError {
49 match status {
50 401 | 403 => ApiError::Auth(message),
51 404 => ApiError::NotFound(message),
52 _ => ApiError::Api { status, message },
53 }
54 }
55
56 pub fn error_for_status_pub(status: u16, message: String) -> ApiError {
57 Self::error_for_status(status, message)
58 }
59
60 async fn ensure_site_id(&mut self) -> Result<&str, ApiError> {
62 if self.site_id.is_none() {
63 let resp: PaginatedResponse<Site> = self
64 .get_integration("/proxy/network/integration/v1/sites")
65 .await?;
66 let site = resp.data.into_iter().next().ok_or_else(|| {
67 ApiError::Other("No sites found — check that the API key has site access".into())
68 })?;
69 self.site_id = Some(site.id);
70 }
71 Ok(self.site_id.as_deref().unwrap())
72 }
73
74 async fn get_integration<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
75 let url = format!("{}{path}", self.base_url);
76 let resp = self.http.get(&url).send().await?;
77 let status = resp.status().as_u16();
78 if !resp.status().is_success() {
79 let body = resp.text().await.unwrap_or_default();
80 return Err(Self::error_for_status(status, body));
81 }
82 Ok(resp.json().await?)
83 }
84
85 async fn get_legacy<T: DeserializeOwned>(&self, path: &str) -> Result<Vec<T>, ApiError> {
86 let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
87 let resp = self.http.get(&url).send().await?;
88 let status = resp.status().as_u16();
89 if !resp.status().is_success() {
90 let body = resp.text().await.unwrap_or_default();
91 return Err(Self::error_for_status(status, body));
92 }
93 let legacy: LegacyResponse<T> = resp.json().await?;
94 if legacy.meta.rc != "ok" {
95 return Err(ApiError::Api {
96 status: 200,
97 message: legacy.meta.msg.unwrap_or_else(|| "unknown error".into()),
98 });
99 }
100 Ok(legacy.data)
101 }
102
103 async fn post_legacy_cmd(
104 &self,
105 manager: &str,
106 body: serde_json::Value,
107 ) -> Result<serde_json::Value, ApiError> {
108 let url = format!(
109 "{}/proxy/network/api/s/default/cmd/{manager}",
110 self.base_url
111 );
112 let resp = self.http.post(&url).json(&body).send().await?;
113 let status = resp.status().as_u16();
114 if !resp.status().is_success() {
115 let body = resp.text().await.unwrap_or_default();
116 return Err(Self::error_for_status(status, body));
117 }
118 Ok(resp.json().await?)
119 }
120
121 async fn put_legacy<T: serde::Serialize>(
122 &self,
123 path: &str,
124 body: &T,
125 ) -> Result<serde_json::Value, ApiError> {
126 let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
127 let resp = self.http.put(&url).json(body).send().await?;
128 let status = resp.status().as_u16();
129 if !resp.status().is_success() {
130 let body = resp.text().await.unwrap_or_default();
131 return Err(Self::error_for_status(status, body));
132 }
133 Ok(resp.json().await?)
134 }
135
136 async fn post_legacy<T: serde::Serialize>(
137 &self,
138 path: &str,
139 body: &T,
140 ) -> Result<serde_json::Value, ApiError> {
141 let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
142 let resp = self.http.post(&url).json(body).send().await?;
143 let status = resp.status().as_u16();
144 if !resp.status().is_success() {
145 let body = resp.text().await.unwrap_or_default();
146 return Err(Self::error_for_status(status, body));
147 }
148 Ok(resp.json().await?)
149 }
150
151 async fn paginate_all<T: DeserializeOwned>(&self, base_path: &str) -> Result<Vec<T>, ApiError> {
153 let mut all = Vec::new();
154 let mut offset = 0;
155 let limit = 200;
156
157 loop {
158 let separator = if base_path.contains('?') { '&' } else { '?' };
159 let path = format!("{base_path}{separator}offset={offset}&limit={limit}");
160 let resp: PaginatedResponse<T> = self.get_integration(&path).await?;
161 let count = resp.data.len();
162 all.extend(resp.data);
163
164 if all.len() >= resp.total_count || count < limit {
165 break;
166 }
167 offset += count;
168 }
169
170 Ok(all)
171 }
172
173 pub async fn list_clients(&mut self) -> Result<Vec<Client>, ApiError> {
177 let site_id = self.ensure_site_id().await?.to_string();
178 self.paginate_all(&format!(
179 "/proxy/network/integration/v1/sites/{site_id}/clients"
180 ))
181 .await
182 }
183
184 pub async fn get_client_detail(&self, mac: &str) -> Result<LegacyClient, ApiError> {
185 let normalized = normalize_mac(mac);
186 let clients: Vec<LegacyClient> = self.get_legacy("/stat/sta").await?;
187 clients
188 .into_iter()
189 .find(|c| {
190 c.mac
191 .as_deref()
192 .is_some_and(|m| normalize_mac(m) == normalized)
193 })
194 .ok_or_else(|| ApiError::NotFound(format!("Client with MAC {mac}")))
195 }
196
197 pub async fn set_fixed_ip(
198 &self,
199 mac: &str,
200 ip: &str,
201 name: Option<&str>,
202 ) -> Result<(), ApiError> {
203 let normalized = normalize_mac(mac);
204
205 let clients: Vec<LegacyClient> = self.get_legacy("/stat/sta").await?;
207 let client = clients
208 .into_iter()
209 .find(|c| {
210 c.mac
211 .as_deref()
212 .is_some_and(|m| normalize_mac(m) == normalized)
213 })
214 .ok_or_else(|| ApiError::NotFound(format!("Client with MAC {mac}")))?;
215
216 let mut payload = serde_json::json!({
217 "mac": format_mac(&normalized),
218 "use_fixedip": true,
219 "fixed_ip": ip,
220 });
221
222 if let Some(n) = name {
223 payload["name"] = serde_json::Value::String(n.to_string());
224 payload["noted"] = serde_json::Value::Bool(true);
225 }
226
227 let path = format!("/rest/user/{}", client.id);
228 match self.put_legacy(&path, &payload).await {
229 Ok(_) => Ok(()),
230 Err(ApiError::NotFound(_)) => {
231 self.post_legacy("/rest/user", &payload).await?;
233 Ok(())
234 }
235 Err(e) => Err(e),
236 }
237 }
238
239 pub async fn block_client(&self, mac: &str) -> Result<(), ApiError> {
240 let formatted = format_mac(&normalize_mac(mac));
241 self.post_legacy_cmd(
242 "stamgr",
243 serde_json::json!({"cmd": "block-sta", "mac": formatted}),
244 )
245 .await?;
246 Ok(())
247 }
248
249 pub async fn unblock_client(&self, mac: &str) -> Result<(), ApiError> {
250 let formatted = format_mac(&normalize_mac(mac));
251 self.post_legacy_cmd(
252 "stamgr",
253 serde_json::json!({"cmd": "unblock-sta", "mac": formatted}),
254 )
255 .await?;
256 Ok(())
257 }
258
259 pub async fn kick_client(&self, mac: &str) -> Result<(), ApiError> {
260 let formatted = format_mac(&normalize_mac(mac));
261 self.post_legacy_cmd(
262 "stamgr",
263 serde_json::json!({"cmd": "kick-sta", "mac": formatted}),
264 )
265 .await?;
266 Ok(())
267 }
268
269 pub async fn list_devices(&mut self) -> Result<Vec<Device>, ApiError> {
271 let site_id = self.ensure_site_id().await?.to_string();
272 self.paginate_all(&format!(
273 "/proxy/network/integration/v1/sites/{site_id}/devices"
274 ))
275 .await
276 }
277
278 pub async fn get_device_detail(&self, mac: &str) -> Result<LegacyDevice, ApiError> {
279 let normalized = normalize_mac(mac);
280 let devices: Vec<LegacyDevice> = self.get_legacy("/stat/device").await?;
281 devices
282 .into_iter()
283 .find(|d| {
284 d.mac
285 .as_deref()
286 .is_some_and(|m| normalize_mac(m) == normalized)
287 })
288 .ok_or_else(|| ApiError::NotFound(format!("Device with MAC {mac}")))
289 }
290
291 pub async fn restart_device(&self, mac: &str) -> Result<(), ApiError> {
292 let formatted = format_mac(&normalize_mac(mac));
293 self.post_legacy_cmd(
294 "devmgr",
295 serde_json::json!({"cmd": "restart", "mac": formatted}),
296 )
297 .await?;
298 Ok(())
299 }
300
301 pub async fn upgrade_device(&self, mac: &str) -> Result<(), ApiError> {
302 let formatted = format_mac(&normalize_mac(mac));
303 self.post_legacy_cmd(
304 "devmgr",
305 serde_json::json!({"cmd": "upgrade", "mac": formatted}),
306 )
307 .await?;
308 Ok(())
309 }
310
311 pub async fn locate_device(&self, mac: &str, enable: bool) -> Result<(), ApiError> {
312 let formatted = format_mac(&normalize_mac(mac));
313 let cmd = if enable { "set-locate" } else { "unset-locate" };
314 self.post_legacy_cmd("devmgr", serde_json::json!({"cmd": cmd, "mac": formatted}))
315 .await?;
316 Ok(())
317 }
318
319 pub async fn list_networks(&mut self) -> Result<Vec<Network>, ApiError> {
321 let site_id = self.ensure_site_id().await?.to_string();
322 self.paginate_all(&format!(
323 "/proxy/network/integration/v1/sites/{site_id}/networks"
324 ))
325 .await
326 }
327
328 pub async fn list_events(&self, limit: usize) -> Result<Vec<Event>, ApiError> {
330 let events: Vec<Event> = self
331 .get_legacy(&format!("/stat/event?_limit={limit}"))
332 .await?;
333 Ok(events)
334 }
335
336 pub async fn get_device_ports(&self, mac: &str) -> Result<DeviceWithPorts, ApiError> {
338 let normalized = normalize_mac(mac);
339 let devices: Vec<DeviceWithPorts> = self.get_legacy("/stat/device").await?;
340 devices
341 .into_iter()
342 .find(|d| {
343 d.mac
344 .as_deref()
345 .is_some_and(|m| normalize_mac(m) == normalized)
346 })
347 .ok_or_else(|| ApiError::NotFound(format!("Device with MAC {mac}")))
348 }
349
350 pub async fn list_clients_legacy(&self) -> Result<Vec<LegacyClient>, ApiError> {
352 self.get_legacy("/stat/sta").await
353 }
354
355 pub async fn get_legacy_devices(&self) -> Result<Vec<LegacyDevice>, ApiError> {
357 self.get_legacy("/stat/device").await
358 }
359
360 pub async fn get_health(&self) -> Result<Vec<HealthSubsystem>, ApiError> {
362 self.get_legacy("/stat/health").await
363 }
364
365 pub async fn get_sysinfo(&self) -> Result<SysInfo, ApiError> {
366 let mut data: Vec<SysInfo> = self.get_legacy("/stat/sysinfo").await?;
367 data.pop()
368 .ok_or_else(|| ApiError::Other("No sysinfo returned".into()))
369 }
370
371 pub async fn get_host_system(&self) -> Result<HostSystem, ApiError> {
372 let url = format!("{}/api/system", self.base_url);
373 let resp = self.http.get(&url).send().await?;
374 let status = resp.status().as_u16();
375 if !resp.status().is_success() {
376 let body = resp.text().await.unwrap_or_default();
377 return Err(Self::error_for_status(status, body));
378 }
379 Ok(resp.json().await?)
380 }
381}