1use std::ffi::OsStr;
8use std::fmt;
9use std::net::IpAddr;
10use std::time::Duration;
11
12use reqwest::header::CONTENT_TYPE;
13use reqwest::{Client, Response, Url};
14use secrecy::{ExposeSecret, SecretString};
15use serde::de::DeserializeOwned;
16use serde::Serialize;
17use uqa_sql::{AsyncSQLEngine, SQLParam, SQLResult};
18
19use crate::cli_connection;
20use crate::server_error_envelope::ServerErrorEnvelope;
21use crate::sql_batch_execution::SQLBatchWireResponse;
22use crate::sql_execution::SQLWireResponse;
23use crate::{HttpEngineError, SQLBatchExecution, SQLExecution, SQLStatement, SQLStream};
24
25const JSON_CONTENT_TYPE: &str = "application/json";
26const NDJSON_CONTENT_TYPE: &str = "application/x-ndjson";
27const REQUEST_ID_HEADER: &str = "x-request-id";
28const MAX_JSON_RESPONSE_BYTES: usize = 65 * 1024 * 1024;
29const MAX_ERROR_RESPONSE_BYTES: usize = 64 * 1024;
30
31#[derive(Clone)]
33pub struct HttpEngine {
34 http: Client,
35 base_url: Url,
36 credential: SecretString,
37}
38
39#[derive(Serialize)]
40struct SQLBatchRequest<'a> {
41 statements: &'a [SQLStatement],
42}
43
44impl HttpEngine {
45 pub fn new(base_url: &str, credential: SecretString) -> Result<Self, HttpEngineError> {
49 if credential.expose_secret().is_empty() {
50 return Err(HttpEngineError::InvalidCredential);
51 }
52 let base_url = parse_base_url(base_url)?;
53 let http = Client::builder()
54 .no_proxy()
55 .connect_timeout(Duration::from_secs(10))
56 .redirect(reqwest::redirect::Policy::none())
57 .user_agent(concat!("uqa-client/", env!("CARGO_PKG_VERSION")))
58 .build()
59 .map_err(HttpEngineError::build_client)?;
60 Ok(Self {
61 http,
62 base_url,
63 credential,
64 })
65 }
66
67 pub fn from_env() -> Result<Self, HttpEngineError> {
69 let base_url = std::env::var("UQA_URL")
70 .map_err(|_| HttpEngineError::MissingEnvironmentVariable("UQA_URL"))?;
71 let credential = std::env::var("UQA_TOKEN")
72 .map_err(|_| HttpEngineError::MissingEnvironmentVariable("UQA_TOKEN"))?;
73 Self::new(&base_url, SecretString::from(credential))
74 }
75
76 pub async fn local(project: &str) -> Result<Self, HttpEngineError> {
80 Self::local_with_cli(project, "uqa").await
81 }
82
83 pub async fn local_with_cli(
85 project: &str,
86 cli_path: impl AsRef<OsStr>,
87 ) -> Result<Self, HttpEngineError> {
88 let connection = cli_connection::resolve_local(cli_path.as_ref(), project).await?;
89 Self::new(&connection.url, connection.token)
90 }
91
92 pub async fn cloud(project: &str, organization: Option<&str>) -> Result<Self, HttpEngineError> {
97 Self::cloud_with_cli(project, organization, "uqa").await
98 }
99
100 pub async fn cloud_with_cli(
102 project: &str,
103 organization: Option<&str>,
104 cli_path: impl AsRef<OsStr>,
105 ) -> Result<Self, HttpEngineError> {
106 let connection =
107 cli_connection::resolve_cloud(cli_path.as_ref(), project, organization).await?;
108 Self::new(&connection.url, connection.token)
109 }
110
111 pub async fn sql(
113 &self,
114 query: &str,
115 params: &[SQLParam],
116 ) -> Result<SQLResult, HttpEngineError> {
117 Ok(self.sql_with_metadata(query, params).await?.into_result())
118 }
119
120 pub async fn sql_with_metadata(
122 &self,
123 query: &str,
124 params: &[SQLParam],
125 ) -> Result<SQLExecution, HttpEngineError> {
126 let statement = SQLStatement::new(query, params)?;
127 let response = self
128 .authorized(self.http.post(self.endpoint("v1/sql")?))
129 .json(&statement)
130 .send()
131 .await
132 .map_err(HttpEngineError::transport)?;
133 let request_id = response_request_id(&response)?;
134 let result = decode_json_response::<SQLWireResponse>(response).await?;
135 validate_request_id(&request_id, &result.request_id)?;
136 Ok(SQLExecution::from_wire(result))
137 }
138
139 pub async fn sql_batch(
141 &self,
142 statements: &[(&str, &[SQLParam])],
143 ) -> Result<Vec<SQLResult>, HttpEngineError> {
144 Ok(self
145 .sql_batch_with_metadata(statements)
146 .await?
147 .into_results())
148 }
149
150 pub async fn sql_batch_with_metadata(
152 &self,
153 statements: &[(&str, &[SQLParam])],
154 ) -> Result<SQLBatchExecution, HttpEngineError> {
155 let statements = statements
156 .iter()
157 .map(|(query, params)| SQLStatement::new(*query, params))
158 .collect::<Result<Vec<_>, _>>()?;
159 let response = self
160 .authorized(self.http.post(self.endpoint("v1/sql/batch")?))
161 .json(&SQLBatchRequest {
162 statements: &statements,
163 })
164 .send()
165 .await
166 .map_err(HttpEngineError::transport)?;
167 let request_id = response_request_id(&response)?;
168 let result = decode_json_response::<SQLBatchWireResponse>(response).await?;
169 validate_request_id(&request_id, &result.request_id)?;
170 Ok(SQLBatchExecution::from_wire(result))
171 }
172
173 pub async fn sql_stream(
175 &self,
176 query: &str,
177 params: &[SQLParam],
178 ) -> Result<SQLStream, HttpEngineError> {
179 let statement = SQLStatement::new(query, params)?;
180 let response = self
181 .authorized(self.http.post(self.endpoint("v1/sql/stream")?))
182 .header(reqwest::header::ACCEPT, NDJSON_CONTENT_TYPE)
183 .json(&statement)
184 .send()
185 .await
186 .map_err(HttpEngineError::transport)?;
187 if !response.status().is_success() {
188 return Err(error_from_response(response).await);
189 }
190 validate_content_type(&response, NDJSON_CONTENT_TYPE)?;
191 let request_id = response_request_id(&response)?;
192 Ok(SQLStream::new(response, request_id))
193 }
194
195 fn authorized(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
196 request.bearer_auth(self.credential.expose_secret())
197 }
198
199 fn endpoint(&self, path: &str) -> Result<Url, HttpEngineError> {
200 self.base_url
201 .join(path)
202 .map_err(|_| HttpEngineError::InvalidBaseURL)
203 }
204}
205
206impl AsyncSQLEngine for HttpEngine {
207 type Error = HttpEngineError;
208
209 async fn sql<'a>(
210 &'a self,
211 query: &'a str,
212 params: &'a [SQLParam],
213 ) -> Result<SQLResult, Self::Error> {
214 HttpEngine::sql(self, query, params).await
215 }
216
217 async fn sql_batch<'a>(
218 &'a self,
219 statements: &'a [(&'a str, &'a [SQLParam])],
220 ) -> Result<Vec<SQLResult>, Self::Error> {
221 HttpEngine::sql_batch(self, statements).await
222 }
223}
224
225impl fmt::Debug for HttpEngine {
226 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
227 formatter
228 .debug_struct("HttpEngine")
229 .field("base_url", &"[REDACTED]")
230 .field("credential", &"[REDACTED]")
231 .finish()
232 }
233}
234
235fn parse_base_url(source: &str) -> Result<Url, HttpEngineError> {
236 let url = Url::parse(source).map_err(|_| HttpEngineError::InvalidBaseURL)?;
237 let valid_origin = url.username().is_empty()
238 && url.password().is_none()
239 && url.query().is_none()
240 && url.fragment().is_none()
241 && url.path() == "/"
242 && url.host_str().is_some();
243 if !valid_origin || !matches!(url.scheme(), "http" | "https") {
244 return Err(HttpEngineError::InvalidBaseURL);
245 }
246 if url.scheme() == "http" && !url.host_str().is_some_and(is_loopback_host) {
247 return Err(HttpEngineError::InsecureRemoteURL);
248 }
249 Ok(url)
250}
251
252fn is_loopback_host(host: &str) -> bool {
253 let host = host
254 .strip_prefix('[')
255 .and_then(|host| host.strip_suffix(']'))
256 .unwrap_or(host);
257 host.eq_ignore_ascii_case("localhost")
258 || host
259 .parse::<IpAddr>()
260 .is_ok_and(|address| address.is_loopback())
261}
262
263async fn decode_json_response<T: DeserializeOwned>(
264 response: Response,
265) -> Result<T, HttpEngineError> {
266 if !response.status().is_success() {
267 return Err(error_from_response(response).await);
268 }
269 validate_content_type(&response, JSON_CONTENT_TYPE)?;
270 let body = read_bounded(response, MAX_JSON_RESPONSE_BYTES).await?;
271 serde_json::from_slice(&body).map_err(HttpEngineError::InvalidResponse)
272}
273
274async fn error_from_response(response: Response) -> HttpEngineError {
275 let status = response.status();
276 if let Err(error) = validate_content_type(&response, JSON_CONTENT_TYPE) {
277 return error;
278 }
279 let header_request_id = match response_request_id(&response) {
280 Ok(request_id) => request_id,
281 Err(error) => return error,
282 };
283 let body = match read_bounded(response, MAX_ERROR_RESPONSE_BYTES).await {
284 Ok(body) => body,
285 Err(error) => return error,
286 };
287 let Ok(envelope) = serde_json::from_slice::<ServerErrorEnvelope>(&body) else {
288 return HttpEngineError::Server {
289 status,
290 code: "HTTP_ERROR".to_owned(),
291 message: "UQA returned a non-success response".to_owned(),
292 request_id: Some(header_request_id),
293 };
294 };
295 if header_request_id != envelope.request_id {
296 return HttpEngineError::ResponseRequestIdMismatch;
297 }
298 HttpEngineError::Server {
299 status,
300 code: envelope.error.code,
301 message: envelope.error.message,
302 request_id: Some(envelope.request_id),
303 }
304}
305
306async fn read_bounded(
307 mut response: Response,
308 maximum_bytes: usize,
309) -> Result<Vec<u8>, HttpEngineError> {
310 if response
311 .content_length()
312 .is_some_and(|length| length > maximum_bytes as u64)
313 {
314 return Err(HttpEngineError::ResponseTooLarge);
315 }
316 let mut body = Vec::new();
317 while let Some(chunk) = response.chunk().await.map_err(HttpEngineError::transport)? {
318 if body.len().saturating_add(chunk.len()) > maximum_bytes {
319 return Err(HttpEngineError::ResponseTooLarge);
320 }
321 body.extend_from_slice(&chunk);
322 }
323 Ok(body)
324}
325
326fn validate_content_type(response: &Response, expected: &str) -> Result<(), HttpEngineError> {
327 let valid = response
328 .headers()
329 .get(CONTENT_TYPE)
330 .and_then(|value| value.to_str().ok())
331 .and_then(|value| value.split(';').next())
332 .is_some_and(|value| value.trim().eq_ignore_ascii_case(expected));
333 if valid {
334 Ok(())
335 } else {
336 Err(HttpEngineError::UnexpectedContentType)
337 }
338}
339
340fn response_request_id(response: &Response) -> Result<String, HttpEngineError> {
341 response
342 .headers()
343 .get(REQUEST_ID_HEADER)
344 .and_then(|value| value.to_str().ok())
345 .filter(|value| !value.is_empty())
346 .map(str::to_owned)
347 .ok_or(HttpEngineError::MissingRequestId)
348}
349
350fn validate_request_id(header: &str, body: &str) -> Result<(), HttpEngineError> {
351 if header == body {
352 Ok(())
353 } else {
354 Err(HttpEngineError::ResponseRequestIdMismatch)
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn base_url_rejects_credentials_paths_and_remote_plain_http() {
364 for source in [
365 "http://user:secret@127.0.0.1:8432/",
366 "http://127.0.0.1:8432/v1",
367 "http://example.com/",
368 "ftp://127.0.0.1/",
369 ] {
370 assert!(parse_base_url(source).is_err(), "accepted {source}");
371 }
372 assert!(parse_base_url("http://127.0.0.1:8432/").is_ok());
373 assert!(parse_base_url("http://[::1]:8432/").is_ok());
374 assert!(parse_base_url("https://example.com/").is_ok());
375 }
376
377 #[test]
378 fn debug_output_redacts_endpoint_and_credential() {
379 let credential = "uqa_db_customer-secret";
380 let client =
381 HttpEngine::new("http://127.0.0.1:8432/", SecretString::from(credential)).unwrap();
382 let debug = format!("{client:?}");
383 assert!(!debug.contains("127.0.0.1"));
384 assert!(!debug.contains(credential));
385 }
386}