unifly_api/legacy/
client.rs1use std::sync::{Arc, RwLock};
9
10use reqwest::cookie::{CookieStore, Jar};
11use serde::Serialize;
12use serde::de::DeserializeOwned;
13use tracing::{debug, trace};
14use url::Url;
15
16use crate::auth::ControllerPlatform;
17use crate::error::Error;
18use crate::legacy::models::LegacyResponse;
19use crate::transport::TransportConfig;
20
21#[derive(serde::Deserialize)]
23struct UnifiOsError {
24 error: Option<UnifiOsErrorInner>,
25}
26
27#[derive(serde::Deserialize)]
28struct UnifiOsErrorInner {
29 code: u16,
30 message: Option<String>,
31}
32
33pub struct LegacyClient {
40 http: reqwest::Client,
41 base_url: Url,
42 site: String,
43 platform: ControllerPlatform,
44 csrf_token: RwLock<Option<String>>,
48 cookie_jar: Option<Arc<Jar>>,
50}
51
52impl LegacyClient {
53 pub fn new(
60 base_url: Url,
61 site: String,
62 platform: ControllerPlatform,
63 transport: &TransportConfig,
64 ) -> Result<Self, Error> {
65 let config = if transport.cookie_jar.is_some() {
66 transport.clone()
67 } else {
68 transport.clone().with_cookie_jar()
69 };
70 let cookie_jar = config.cookie_jar.clone();
71 let http = config.build_client()?;
72 Ok(Self {
73 http,
74 base_url,
75 site,
76 platform,
77 csrf_token: RwLock::new(None),
78 cookie_jar,
79 })
80 }
81
82 pub fn with_client(
87 http: reqwest::Client,
88 base_url: Url,
89 site: String,
90 platform: ControllerPlatform,
91 ) -> Self {
92 Self {
93 http,
94 base_url,
95 site,
96 platform,
97 csrf_token: RwLock::new(None),
98 cookie_jar: None,
99 }
100 }
101
102 pub fn site(&self) -> &str {
104 &self.site
105 }
106
107 pub fn http(&self) -> &reqwest::Client {
109 &self.http
110 }
111
112 pub fn base_url(&self) -> &Url {
114 &self.base_url
115 }
116
117 pub fn platform(&self) -> ControllerPlatform {
119 self.platform
120 }
121
122 pub fn cookie_header(&self) -> Option<String> {
127 let jar = self.cookie_jar.as_ref()?;
128 let cookies = jar.cookies(&self.base_url)?;
129 cookies.to_str().ok().map(String::from)
130 }
131
132 pub(crate) fn set_csrf_token(&self, token: String) {
136 debug!("storing CSRF token");
137 *self.csrf_token.write().expect("CSRF lock poisoned") = Some(token);
138 }
139
140 fn update_csrf_from_response(&self, headers: &reqwest::header::HeaderMap) {
142 let new_token = headers
144 .get("X-Updated-CSRF-Token")
145 .or_else(|| headers.get("x-csrf-token"))
146 .and_then(|v| v.to_str().ok())
147 .map(String::from);
148
149 if let Some(token) = new_token {
150 trace!("CSRF token rotated");
151 *self.csrf_token.write().expect("CSRF lock poisoned") = Some(token);
152 }
153 }
154
155 fn apply_csrf(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
157 let guard = self.csrf_token.read().expect("CSRF lock poisoned");
158 match guard.as_deref() {
159 Some(token) => builder.header("X-CSRF-Token", token),
160 None => builder,
161 }
162 }
163
164 pub(crate) fn api_url(&self, path: &str) -> Url {
171 let prefix = self.platform.legacy_prefix().unwrap_or("");
172 let base = self.base_url.as_str().trim_end_matches('/');
173 let prefix = prefix.trim_end_matches('/');
174 let full = format!("{base}{prefix}/api/{path}");
175 Url::parse(&full).expect("invalid API URL")
176 }
177
178 pub(crate) fn site_url(&self, path: &str) -> Url {
182 let prefix = self.platform.legacy_prefix().unwrap_or("");
183 let base = self.base_url.as_str().trim_end_matches('/');
184 let prefix = prefix.trim_end_matches('/');
185 let full = format!("{base}{prefix}/api/s/{}/{path}", self.site);
186 Url::parse(&full).expect("invalid site URL")
187 }
188
189 pub(crate) async fn get<T: DeserializeOwned>(&self, url: Url) -> Result<Vec<T>, Error> {
193 debug!("GET {}", url);
194
195 let resp = self.http.get(url).send().await.map_err(Error::Transport)?;
196
197 self.parse_envelope(resp).await
198 }
199
200 pub(crate) async fn post<T: DeserializeOwned>(
202 &self,
203 url: Url,
204 body: &(impl Serialize + Sync),
205 ) -> Result<Vec<T>, Error> {
206 debug!("POST {}", url);
207
208 let builder = self.apply_csrf(self.http.post(url).json(body));
209 let resp = builder.send().await.map_err(Error::Transport)?;
210
211 self.parse_envelope(resp).await
212 }
213
214 #[allow(dead_code)]
216 pub(crate) async fn put<T: DeserializeOwned>(
217 &self,
218 url: Url,
219 body: &(impl Serialize + Sync),
220 ) -> Result<Vec<T>, Error> {
221 debug!("PUT {}", url);
222
223 let builder = self.apply_csrf(self.http.put(url).json(body));
224 let resp = builder.send().await.map_err(Error::Transport)?;
225
226 self.parse_envelope(resp).await
227 }
228
229 #[allow(dead_code)]
231 pub(crate) async fn delete<T: DeserializeOwned>(&self, url: Url) -> Result<Vec<T>, Error> {
232 debug!("DELETE {}", url);
233
234 let builder = self.apply_csrf(self.http.delete(url));
235 let resp = builder.send().await.map_err(Error::Transport)?;
236
237 self.parse_envelope(resp).await
238 }
239
240 async fn parse_envelope<T: DeserializeOwned>(
246 &self,
247 resp: reqwest::Response,
248 ) -> Result<Vec<T>, Error> {
249 let status = resp.status();
250
251 self.update_csrf_from_response(resp.headers());
253
254 if status == reqwest::StatusCode::UNAUTHORIZED {
255 return Err(Error::Authentication {
256 message: "session expired or invalid credentials".into(),
257 });
258 }
259
260 if status == reqwest::StatusCode::FORBIDDEN {
261 return Err(Error::LegacyApi {
262 message: "insufficient permissions (HTTP 403)".into(),
263 });
264 }
265
266 if !status.is_success() {
267 let body = resp.text().await.unwrap_or_default();
268 return Err(Error::LegacyApi {
269 message: format!("HTTP {status}: {}", &body[..body.len().min(200)]),
270 });
271 }
272
273 let body = resp.text().await.map_err(Error::Transport)?;
274
275 if let Ok(wrapper) = serde_json::from_str::<UnifiOsError>(&body) {
277 if let Some(err) = wrapper.error {
278 let msg = err.message.unwrap_or_default();
279 return Err(if err.code == 401 {
280 Error::Authentication { message: msg }
281 } else {
282 Error::LegacyApi {
283 message: format!("UniFi OS error {}: {msg}", err.code),
284 }
285 });
286 }
287 }
288
289 let envelope: LegacyResponse<T> = serde_json::from_str(&body).map_err(|e| {
290 let preview = &body[..body.len().min(200)];
291 Error::Deserialization {
292 message: format!("{e} (body preview: {preview:?})"),
293 body: body.clone(),
294 }
295 })?;
296
297 match envelope.meta.rc.as_str() {
298 "ok" => Ok(envelope.data),
299 _ => Err(Error::LegacyApi {
300 message: envelope
301 .meta
302 .msg
303 .unwrap_or_else(|| format!("rc={}", envelope.meta.rc)),
304 }),
305 }
306 }
307}