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}