Skip to main content

unifi_protect_client/
lib.rs

1//! # UniFi Protect Client
2//!
3//! A Rust client library for interacting with the UniFi Protect API.
4//!
5//! This crate provides a simple and type-safe way to interact with UniFi Protect
6//! cameras and other devices through the REST API.
7//!
8//! ## Features
9//!
10//! - Secure credential handling using the `secrecy` crate
11//! - Automatic authentication and session management
12//! - Type-safe API responses with serde deserialization
13//! - Support for camera management operations
14//!
15//! ## Quick Start
16//!
17//! ```rust
18//! # use unifi_protect_client::UnifiProtectClient;
19//! # use anyhow::Result;
20//! #
21//! # async fn example() -> Result<()> {
22//! let client = UnifiProtectClient::new(
23//!     "https://192.168.1.1",
24//!     "username",
25//!     "password"
26//! );
27//!
28//! // List all cameras
29//! let cameras = client.list_cameras().await?;
30//! println!("Found {} cameras", cameras.len());
31//! #
32//! # Ok(())
33//! # }
34//! ```
35
36use std::sync::{Arc, Mutex};
37
38use reqwest::{Client, StatusCode};
39use secrecy::SecretString;
40use serde::{Serialize, de::DeserializeOwned};
41use thiserror::Error;
42
43use crate::auth::AuthCredentials;
44
45pub mod api;
46mod auth;
47pub mod models;
48
49/// Errors that can occur when making requests to the UniFi Protect API.
50///
51/// This enum covers the various error conditions that may arise during
52/// API interactions, including network failures, authentication issues,
53/// and data parsing problems.
54#[derive(Error, Debug)]
55pub enum RequestError {
56    /// Network-related errors (connection failures, timeouts, etc.)
57    ///
58    /// This error wraps underlying `reqwest::Error` types and indicates
59    /// issues with the HTTP transport layer.
60    #[error("Network error: {0}")]
61    NetworkError(#[from] reqwest::Error),
62
63    /// Authentication or authorization failures
64    ///
65    /// Returned when the API responds with HTTP 401 (Unauthorized) or
66    /// HTTP 403 (Forbidden) status codes. This typically indicates
67    /// invalid credentials or expired sessions.
68    #[error("Unauthorized access - check your credentials")]
69    Unauthorized,
70
71    /// JSON deserialization errors
72    ///
73    /// Occurs when the API response cannot be parsed into the expected
74    /// data structure. This might happen due to API changes or unexpected
75    /// response formats.
76    #[error("Failed to parse API response: {0}")]
77    DeserializationError(String),
78
79    /// Generic error for unhandled cases
80    ///
81    /// Used for HTTP errors that don't fall into other categories
82    /// or unexpected error conditions.
83    #[error("An unknown error occurred")]
84    Unknown,
85}
86
87/// Client for interacting with the UniFi Protect API.
88///
89/// This is the main entry point for all UniFi Protect operations. The client
90/// handles authentication, session management, and provides methods for
91/// interacting with various UniFi Protect resources.
92///
93/// ## Example
94///
95/// ```rust
96/// # use unifi_protect_client::UnifiProtectClient;
97/// #
98/// let client = UnifiProtectClient::new(
99///     "https://192.168.1.1",  // Your UniFi Protect controller URL
100///     "admin",                // Username
101///     "password"              // Password
102/// );
103/// ```
104pub struct UnifiProtectClient {
105    client: Arc<Mutex<Option<Client>>>,
106    host: String,
107    credentials: AuthCredentials,
108}
109
110impl UnifiProtectClient {
111    /// Creates a new UniFi Protect client.
112    ///
113    /// This constructor initializes a new client with the provided connection
114    /// details and credentials. Authentication is performed lazily on the first
115    /// API request.
116    ///
117    /// # Arguments
118    ///
119    /// * `host` - The base URL of your UniFi Protect controller (e.g., `https://192.168.1.1`)
120    /// * `username` - Username for authentication
121    /// * `password` - Password for authentication
122    ///
123    /// # Security
124    ///
125    /// Credentials are stored securely using the `secrecy` crate and are only
126    /// exposed during authentication requests.
127    ///
128    /// # Examples
129    ///
130    /// ```rust
131    /// # use unifi_protect_client::UnifiProtectClient;
132    /// #
133    /// let client = UnifiProtectClient::new(
134    ///     "https://192.168.1.1",
135    ///     "admin",
136    ///     "your-secure-password"
137    /// );
138    /// ```
139    #[must_use]
140    pub fn new(host: &str, username: &str, password: &str) -> UnifiProtectClient {
141        UnifiProtectClient {
142            client: Arc::new(Mutex::new(None)),
143            host: host.to_owned(),
144            credentials: AuthCredentials {
145                username: SecretString::from(username),
146                password: SecretString::from(password),
147            },
148        }
149    }
150
151    /// Makes a GET request to the specified API endpoint.
152    ///
153    /// This method automatically handles authentication and will attempt to
154    /// re-authenticate once if the request fails with an authentication error.
155    ///
156    /// # Arguments
157    ///
158    /// * `path` - The API endpoint path (relative to the host)
159    ///
160    /// # Returns
161    ///
162    /// Returns the deserialized response on success, or a `RequestError` on failure.
163    ///
164    /// # Errors
165    ///
166    /// * `RequestError::Unauthorized` - Authentication failed, even after retry
167    /// * `RequestError::NetworkError` - Network-related failures
168    /// * `RequestError::DeserializationError` - Failed to parse response
169    /// * `RequestError::Unknown` - Other HTTP errors
170    async fn make_get_request<T: DeserializeOwned>(&self, path: &str) -> Result<T, RequestError> {
171        self.ensure_authenticated().await?;
172
173        let url = format!("{}/{path}", self.host);
174        let mut remaining_attempts = 2u8;
175
176        while remaining_attempts > 0 {
177            remaining_attempts -= 1;
178            let response = {
179                self.client
180                    .lock()
181                    .unwrap()
182                    .as_ref()
183                    .unwrap()
184                    .get(&url)
185                    .send()
186            }
187            .await
188            .map_err(RequestError::NetworkError)?;
189
190            if !response.status().is_success() {
191                match response.status() {
192                    StatusCode::UNAUTHORIZED => {
193                        // Re-authenticate and try again if we haven't already retried
194                        if remaining_attempts > 0 {
195                            self.authenticate().await?;
196                            continue;
197                        }
198
199                        return Err(RequestError::Unauthorized);
200                    }
201                    _ => return Err(RequestError::Unknown),
202                };
203            }
204
205            let result: T = response
206                .json()
207                .await
208                .map_err(|err| RequestError::DeserializationError(err.to_string()))?;
209
210            return Ok(result);
211        }
212
213        Err(RequestError::Unknown)
214    }
215
216    /// Makes a PATCH request to the specified API endpoint.
217    ///
218    /// This method automatically handles authentication and will attempt to
219    /// re-authenticate once if the request fails with an authentication error.
220    ///
221    /// # Arguments
222    ///
223    /// * `path` - The API endpoint path (relative to the host)
224    /// * `body` - The request body to serialize as JSON
225    ///
226    /// # Returns
227    ///
228    /// Returns `Ok(())` on success, or a `RequestError` on failure.
229    ///
230    /// # Errors
231    ///
232    /// * `RequestError::Unauthorized` - Authentication failed, even after retry
233    /// * `RequestError::NetworkError` - Network-related failures
234    /// * `RequestError::Unknown` - Other HTTP errors
235    async fn make_patch_request<T: Serialize>(
236        &self,
237        path: &str,
238        body: T,
239    ) -> Result<(), RequestError> {
240        self.ensure_authenticated().await?;
241
242        let url = format!("{}/{path}", self.host);
243        let mut retries_remaining = 1u8;
244
245        while retries_remaining > 0 {
246            let response = {
247                self.client
248                    .lock()
249                    .unwrap()
250                    .as_ref()
251                    .unwrap()
252                    .patch(&url)
253                    .json(&body)
254                    .send()
255            }
256            .await
257            .map_err(RequestError::NetworkError)?;
258
259            if !response.status().is_success() {
260                match response.status() {
261                    StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
262                        // Re-authenticate and try again if we haven't already retried
263                        if retries_remaining > 0 {
264                            retries_remaining -= 1;
265                            self.authenticate().await?;
266                            continue;
267                        }
268
269                        return Err(RequestError::Unauthorized);
270                    }
271                    _ => return Err(RequestError::Unknown),
272                };
273            }
274
275            return Ok(());
276        }
277
278        Err(RequestError::Unknown)
279    }
280}