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 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 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 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 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 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 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 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 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 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 pub async fn list_clients_legacy(&self) -> Result<Vec<LegacyClient>, ApiError> {
340 self.get_legacy("/stat/sta").await
341 }
342
343 pub async fn get_legacy_devices(&self) -> Result<Vec<LegacyDevice>, ApiError> {
345 self.get_legacy("/stat/device").await
346 }
347
348 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}