Skip to main content

unifi/
http.rs

1//! Shared HTTP plumbing: one pooled [`reqwest::Client`] per controller, one
2//! place that maps transport and status-code failures to [`UnifiError`].
3//!
4//! [`UnifiClient`](crate::UnifiClient) and the dynamic action dispatcher both
5//! call [`request_json`](crate::http::request_json) with the same borrowed
6//! `Client` rather than each building their own — a fresh `reqwest::Client`
7//! per call defeats connection pooling and keep-alive under load, so build
8//! one with [`build_client`](crate::http::build_client) and reuse it for the
9//! client's lifetime.
10
11use std::time::Duration;
12
13use reqwest::{Client, Method, StatusCode};
14use serde_json::{json, Value};
15
16use crate::error::{Result, UnifiError};
17use crate::UnifiConfig;
18
19/// Builds the pooled HTTP client used for every request against one controller.
20///
21/// No cookie jar: authentication is entirely the `X-API-KEY` header (see the
22/// crate's module docs), and UniFi controllers were verified against a real
23/// controller to never send `Set-Cookie` for API-key-authenticated requests
24/// on either the official or internal API — a cookie store here would be
25/// dead weight, not a defense-in-depth measure.
26///
27/// # Errors
28/// Returns [`UnifiError::ClientBuild`] if `reqwest` fails to construct the
29/// client (in practice, only from an invalid TLS configuration).
30pub fn build_client(cfg: &UnifiConfig) -> Result<Client> {
31    reqwest::ClientBuilder::new()
32        .danger_accept_invalid_certs(cfg.skip_tls_verify)
33        .timeout(cfg.request_timeout)
34        .build()
35        .map_err(UnifiError::ClientBuild)
36}
37
38/// Issues one request against `base_url` using the caller-supplied `client`,
39/// and maps the transport/status outcome into a [`Result`].
40///
41/// `action` is a friendly name (e.g. `"clients"`, `"official_list_sites"`)
42/// used only for the `tracing` span/logs this wraps the request in. Every
43/// dispatch path — the 8 named [`UnifiClient`](crate::UnifiClient) methods
44/// and the ~236 actions reachable only through
45/// [`ActionDispatcher`](crate::ActionDispatcher) — funnels through this one
46/// function, so this is the one place instrumentation needs to live for it
47/// to cover all of them consistently, rather than only the named methods.
48///
49/// # Errors
50/// Returns [`UnifiError::Timeout`] / [`UnifiError::Connect`] / [`UnifiError::Request`]
51/// for transport failures, [`UnifiError::Unauthorized`] / [`UnifiError::Forbidden`] /
52/// [`UnifiError::NotFound`] / [`UnifiError::RateLimited`] for the status codes UniFi
53/// controllers use for those conditions, and [`UnifiError::UnexpectedStatus`] for any
54/// other non-success status (its `body` is JSON when the response was JSON, otherwise
55/// the raw text). For a success status, returns [`UnifiError::EmptyBody`] for a `GET`
56/// with no response body, or [`UnifiError::Decode`] if the body isn't valid
57/// JSON.
58#[tracing::instrument(skip(client, api_key, query, body), fields(url))]
59#[allow(clippy::too_many_arguments)]
60pub async fn request_json(
61    client: &Client,
62    base_url: &str,
63    api_key: &str,
64    action: &str,
65    method: Method,
66    path: &str,
67    query: Option<&Value>,
68    body: Option<&Value>,
69) -> Result<Value> {
70    let url = format!("{}{path}", base_url.trim_end_matches('/'));
71    tracing::Span::current().record("url", tracing::field::display(&url));
72    let result = send_request(client, &url, api_key, method, path, query, body).await;
73    match &result {
74        Ok(value) => {
75            let count = value.get("data").and_then(Value::as_array).map(Vec::len);
76            tracing::debug!(action, count, "upstream call ok");
77        }
78        Err(error) => tracing::warn!(action, %error, "upstream call failed"),
79    }
80    result
81}
82
83#[allow(clippy::too_many_arguments)]
84async fn send_request(
85    client: &Client,
86    url: &str,
87    api_key: &str,
88    method: Method,
89    path: &str,
90    query: Option<&Value>,
91    body: Option<&Value>,
92) -> Result<Value> {
93    let url = url.to_string();
94    let mut request = client
95        .request(method.clone(), &url)
96        .header("X-API-KEY", api_key)
97        .header("Accept", "application/json");
98
99    if let Some(query) = query {
100        let query = query
101            .as_object()
102            .ok_or_else(|| UnifiError::InvalidRequest {
103                context: format!("{method} {path}"),
104                message: "query must be a JSON object".to_string(),
105            })?;
106        request = request.query(query);
107    }
108    if let Some(body) = body {
109        request = request.json(body);
110    }
111
112    let response = request
113        .send()
114        .await
115        .map_err(|source| map_transport_error(&method, &url, source))?;
116    let status = response.status();
117    match status {
118        StatusCode::UNAUTHORIZED => return Err(UnifiError::Unauthorized(url)),
119        StatusCode::FORBIDDEN => {
120            return Err(UnifiError::Forbidden {
121                method: method.to_string(),
122                url,
123            })
124        }
125        StatusCode::NOT_FOUND => {
126            return Err(UnifiError::NotFound {
127                method: method.to_string(),
128                url,
129            })
130        }
131        StatusCode::TOO_MANY_REQUESTS => {
132            let retry_after = response
133                .headers()
134                .get(reqwest::header::RETRY_AFTER)
135                .and_then(|value| value.to_str().ok())
136                .and_then(|value| value.parse::<u64>().ok())
137                .map(Duration::from_secs);
138            return Err(UnifiError::RateLimited {
139                method: method.to_string(),
140                url,
141                retry_after,
142            });
143        }
144        _ => {}
145    }
146
147    let bytes = response
148        .bytes()
149        .await
150        .map_err(|source| map_transport_error(&method, &url, source))?;
151
152    // Check the status before doing anything with the body: a non-success
153    // response (500, a proxy's HTML error page, ...) is a status failure
154    // first and foremost. Parsing it strictly as JSON here would turn a
155    // perfectly diagnosable "HTTP 500" into an opaque `UnifiError::Decode`
156    // whenever the error body isn't JSON — which upstream error pages
157    // commonly aren't. Capture the body best-effort instead.
158    if !status.is_success() {
159        let body = if bytes.is_empty() {
160            Value::Null
161        } else {
162            serde_json::from_slice::<Value>(&bytes)
163                .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).into_owned()))
164        };
165        return Err(UnifiError::UnexpectedStatus {
166            status: status.as_u16(),
167            url,
168            body: Box::new(body),
169        });
170    }
171
172    if bytes.is_empty() {
173        if method == Method::GET {
174            return Err(UnifiError::EmptyBody {
175                method: method.to_string(),
176                url,
177            });
178        }
179        return Ok(json!({
180            "success": true,
181            "status": status.as_u16(),
182            "method": method.as_str(),
183            "path": path,
184        }));
185    }
186    serde_json::from_slice::<Value>(&bytes).map_err(|source| UnifiError::Decode { url, source })
187}
188
189fn map_transport_error(method: &Method, url: &str, source: reqwest::Error) -> UnifiError {
190    if source.is_timeout() {
191        UnifiError::Timeout {
192            method: method.to_string(),
193            url: url.to_string(),
194            source,
195        }
196    } else if source.is_connect() {
197        UnifiError::Connect {
198            method: method.to_string(),
199            url: url.to_string(),
200            source,
201        }
202    } else {
203        UnifiError::Request {
204            method: method.to_string(),
205            url: url.to_string(),
206            source,
207        }
208    }
209}