1use std::time::Duration;
12
13use reqwest::{Client, Method, StatusCode};
14use serde_json::{json, Value};
15
16use crate::error::{Result, UnifiError};
17use crate::UnifiConfig;
18
19pub 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#[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 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}