Skip to main content

unifi/
client.rs

1use std::fmt;
2use std::time::Duration;
3
4use reqwest::{Client, Method};
5use serde_json::Value;
6
7use crate::error::{Result, UnifiError};
8use crate::{http, UnifiConfig};
9
10/// HTTP REST client for UniFi controllers.
11///
12/// Supports both modern UniFi OS controllers (behind `/proxy/network`) and
13/// legacy controllers (no prefix, typically port 8443). Authentication uses
14/// the `X-API-KEY` header.
15///
16/// Builds and holds one pooled [`reqwest::Client`] for its lifetime — clone
17/// and share a `UnifiClient` rather than constructing a new one per request,
18/// so requests reuse connections instead of paying a fresh TLS handshake
19/// each time.
20///
21/// `Debug` redacts the API key, same as [`UnifiConfig`].
22#[derive(Clone)]
23pub struct UnifiClient {
24    http: Client,
25    /// Base URL, e.g. `https://unifi.local`, with any trailing slash trimmed.
26    pub url: String,
27    api_key: String,
28    site: String,
29    skip_tls_verify: bool,
30    legacy: bool,
31    request_timeout: Duration,
32}
33
34impl fmt::Debug for UnifiClient {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        f.debug_struct("UnifiClient")
37            .field("url", &self.url)
38            .field(
39                "api_key",
40                &format_args!("<redacted, {} bytes>", self.api_key.len()),
41            )
42            .field("site", &self.site)
43            .field("skip_tls_verify", &self.skip_tls_verify)
44            .field("legacy", &self.legacy)
45            .field("request_timeout", &self.request_timeout)
46            .finish()
47    }
48}
49
50impl UnifiClient {
51    /// Builds a client from `cfg`.
52    ///
53    /// # Errors
54    /// Returns [`UnifiError::MissingUrl`] or [`UnifiError::MissingApiKey`] if the
55    /// corresponding config field is empty, or [`UnifiError::ClientBuild`] if the
56    /// underlying HTTP client fails to construct.
57    pub fn new(cfg: &UnifiConfig) -> Result<Self> {
58        if cfg.url.is_empty() {
59            return Err(UnifiError::MissingUrl);
60        }
61        if cfg.api_key.is_empty() {
62            return Err(UnifiError::MissingApiKey);
63        }
64        let http = http::build_client(cfg)?;
65        Ok(Self {
66            http,
67            url: cfg.url.trim_end_matches('/').to_string(),
68            api_key: cfg.api_key.clone(),
69            site: cfg.site.clone(),
70            skip_tls_verify: cfg.skip_tls_verify,
71            legacy: cfg.legacy,
72            request_timeout: cfg.request_timeout,
73        })
74    }
75
76    /// Site slug this client targets (`UnifiConfig::site`).
77    pub fn site(&self) -> &str {
78        &self.site
79    }
80
81    /// Whether this client targets a legacy controller (no `/proxy/network` prefix).
82    pub fn legacy(&self) -> bool {
83        self.legacy
84    }
85
86    /// Reconstructs the [`UnifiConfig`] this client was built from.
87    pub fn config(&self) -> UnifiConfig {
88        UnifiConfig {
89            url: self.url.clone(),
90            api_key: self.api_key.clone(),
91            site: self.site.clone(),
92            skip_tls_verify: self.skip_tls_verify,
93            legacy: self.legacy,
94            request_timeout: self.request_timeout,
95        }
96    }
97
98    /// Issues a request against this client's controller, reusing its pooled
99    /// connection. This is the primitive the dynamic action dispatcher
100    /// ([`crate::ActionDispatcher`]) builds on; the named methods below
101    /// (`clients`, `devices`, ...) are thin, discoverable wrappers around it.
102    ///
103    /// `action` is a friendly name (e.g. `"clients"`, `"official_list_sites"`)
104    /// used only for `tracing` — see [`http::request_json`].
105    ///
106    /// # Errors
107    /// See [`UnifiError`] for the failure cases this can return.
108    pub async fn request_json(
109        &self,
110        action: &str,
111        method: Method,
112        path: &str,
113        query: Option<&Value>,
114        body: Option<&Value>,
115    ) -> Result<Value> {
116        http::request_json(
117            &self.http,
118            &self.url,
119            &self.api_key,
120            action,
121            method,
122            path,
123            query,
124            body,
125        )
126        .await
127    }
128
129    fn site_path(&self, suffix: &str) -> String {
130        let prefix = if self.legacy { "" } else { "/proxy/network" };
131        format!("{prefix}/api/s/{site}/{suffix}", site = self.site)
132    }
133
134    fn self_path(&self) -> &'static str {
135        if self.legacy {
136            "/api/self"
137        } else {
138            "/proxy/network/api/self"
139        }
140    }
141
142    async fn get(&self, path: &str, action: &str) -> Result<Value> {
143        self.request_json(action, Method::GET, path, None, None)
144            .await
145    }
146
147    /// Connected clients (wireless and wired).
148    ///
149    /// # Errors
150    /// See [`UnifiError`] for the failure cases this can return.
151    pub async fn clients(&self) -> Result<Value> {
152        self.get(&self.site_path("stat/sta"), "clients").await
153    }
154
155    /// Network devices: APs, switches, gateways.
156    ///
157    /// # Errors
158    /// See [`UnifiError`] for the failure cases this can return.
159    pub async fn devices(&self) -> Result<Value> {
160        self.get(&self.site_path("stat/device"), "devices").await
161    }
162
163    /// WLAN (WiFi network) configurations.
164    ///
165    /// # Errors
166    /// See [`UnifiError`] for the failure cases this can return.
167    pub async fn wlans(&self) -> Result<Value> {
168        self.get(&self.site_path("rest/wlanconf"), "wlans").await
169    }
170
171    /// Site health summary.
172    ///
173    /// # Errors
174    /// See [`UnifiError`] for the failure cases this can return.
175    pub async fn health(&self) -> Result<Value> {
176        self.get(&self.site_path("stat/health"), "health").await
177    }
178
179    /// Active alarms / alerts.
180    ///
181    /// # Errors
182    /// See [`UnifiError`] for the failure cases this can return.
183    pub async fn alarms(&self) -> Result<Value> {
184        self.get(&self.site_path("rest/alarm"), "alarms").await
185    }
186
187    /// Recent events.
188    ///
189    /// # Errors
190    /// See [`UnifiError`] for the failure cases this can return.
191    pub async fn events(&self) -> Result<Value> {
192        self.get(&self.site_path("rest/event"), "events").await
193    }
194
195    /// Controller system info.
196    ///
197    /// # Errors
198    /// See [`UnifiError`] for the failure cases this can return.
199    pub async fn sysinfo(&self) -> Result<Value> {
200        self.get(&self.site_path("stat/sysinfo"), "sysinfo").await
201    }
202
203    /// Authenticated user info.
204    ///
205    /// # Errors
206    /// See [`UnifiError`] for the failure cases this can return.
207    pub async fn me(&self) -> Result<Value> {
208        self.get(self.self_path(), "me").await
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn config_round_trips_a_non_default_request_timeout() {
218        let cfg = UnifiConfig {
219            url: "https://unifi.local".to_string(),
220            api_key: "test-key".to_string(),
221            request_timeout: Duration::from_secs(90),
222            ..UnifiConfig::default()
223        };
224
225        let client = UnifiClient::new(&cfg).unwrap();
226
227        assert_eq!(client.config().request_timeout, Duration::from_secs(90));
228    }
229}