1use crate::auth::Credential;
5use crate::capabilities::{Capabilities, Feature};
6use crate::config::Profile;
7use crate::error::{Error, ErrorKind, Result};
8use reqwest::{Client, Method, Response, StatusCode};
9use serde_json::Value;
10use std::collections::BTreeMap;
11use std::io::Write;
12use std::time::Duration;
13use tokio::sync::OnceCell;
14
15const API_VERSION: &str = "2023-10-31";
17const MAX_ATTEMPTS: u32 = 3;
18
19const CAPTURED_HEADERS: [&str; 3] = [
28 "x-found-handling-cluster",
29 "x-found-handling-instance",
30 "x-elastic-product",
31];
32
33fn parse_response_json(text: &str) -> Result<Value> {
36 validate_json_integer_ranges(text)?;
37 serde_json::from_str(text)
38 .map_err(|e| Error::new(ErrorKind::Http, format!("parsing response JSON: {e}")))
39}
40
41fn validate_json_integer_ranges(text: &str) -> Result<()> {
46 let bytes = text.as_bytes();
47 let mut index = 0;
48
49 while index < bytes.len() {
50 if bytes[index] == b'"' {
51 index += 1;
52 while index < bytes.len() {
53 match bytes[index] {
54 b'\\' => index += 2,
55 b'"' => {
56 index += 1;
57 break;
58 }
59 _ => index += 1,
60 }
61 }
62 continue;
63 }
64
65 if bytes[index] != b'-' && !bytes[index].is_ascii_digit() {
66 index += 1;
67 continue;
68 }
69
70 let start = index;
71 if bytes[index] == b'-' {
72 index += 1;
73 }
74 if index == bytes.len() || !bytes[index].is_ascii_digit() {
75 index = start + 1;
76 continue;
77 }
78
79 if bytes[index] == b'0' {
80 index += 1;
81 } else {
82 while index < bytes.len() && bytes[index].is_ascii_digit() {
83 index += 1;
84 }
85 }
86
87 let mut is_integer = true;
88 if bytes.get(index) == Some(&b'.') {
89 is_integer = false;
90 index += 1;
91 while index < bytes.len() && bytes[index].is_ascii_digit() {
92 index += 1;
93 }
94 }
95 if matches!(bytes.get(index), Some(b'e' | b'E')) {
96 is_integer = false;
97 index += 1;
98 if matches!(bytes.get(index), Some(b'+' | b'-')) {
99 index += 1;
100 }
101 while index < bytes.len() && bytes[index].is_ascii_digit() {
102 index += 1;
103 }
104 }
105
106 if is_integer {
107 let number = &text[start..index];
108 let in_range = if bytes[start] == b'-' {
109 number.parse::<i64>().is_ok()
110 } else {
111 number.parse::<u64>().is_ok()
112 };
113 if !in_range {
114 return Err(Error::new(
115 ErrorKind::Http,
116 format!(
117 "parsing response JSON: integer {number} is outside supported integer range"
118 ),
119 ));
120 }
121 }
122 }
123
124 Ok(())
125}
126
127#[derive(Debug, Clone)]
132pub struct Responded {
133 pub body: Value,
134 pub headers: BTreeMap<String, String>,
135}
136
137impl Responded {
138 pub fn header(&self, name: &str) -> Option<&str> {
143 self.headers.get(&name.to_ascii_lowercase()).map(|s| &**s)
144 }
145}
146
147pub fn urlencode(s: &str) -> String {
152 let mut out = String::with_capacity(s.len());
153 for b in s.bytes() {
154 match b {
155 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
156 out.push(b as char)
157 }
158 _ => out.push_str(&format!("%{b:02X}")),
159 }
160 }
161 out
162}
163
164pub struct Transport {
165 client: Client,
166 base: String,
167 kibana_url: String,
171 es_base: String,
174 space: String,
175 auth_header: String,
176 debug: bool,
177 capabilities: OnceCell<Capabilities>,
178}
179
180impl Transport {
181 pub fn new(profile: &Profile) -> Result<Transport> {
182 Self::with_debug(profile, false)
183 }
184
185 pub fn with_debug(profile: &Profile, debug: bool) -> Result<Transport> {
189 let mut profile = profile.clone();
192 profile.strip_userinfo();
193 let credential = Credential::from_profile(&profile)?;
194 let client = Client::builder()
195 .timeout(Duration::from_secs(profile.timeout_secs))
196 .danger_accept_invalid_certs(!profile.verify)
197 .build()
198 .map_err(|e| Error::new(ErrorKind::Connection, format!("building HTTP client: {e}")))?;
199
200 let base = profile.kibana_url.trim_end_matches('/').to_string();
201 let kibana_url = profile.kibana_url.clone();
202 let es_base = profile
203 .es_url
204 .as_deref()
205 .unwrap_or(&profile.kibana_url)
206 .trim_end_matches('/')
207 .to_string();
208
209 Ok(Transport {
210 client,
211 base,
212 kibana_url,
213 es_base,
214 space: profile.space.clone(),
215 auth_header: credential.header_value(),
216 debug,
217 capabilities: OnceCell::new(),
218 })
219 }
220
221 fn debug_log(&self, method: &Method, url: &str, status: u16, attempt: u32) {
227 if !self.debug {
228 return;
229 }
230 if attempt > 1 {
231 eprintln!(
232 "[debug] {} {url} -> {status} (attempt {attempt})",
233 method.as_str()
234 );
235 } else {
236 eprintln!("[debug] {} {url} -> {status}", method.as_str());
237 }
238 }
239
240 fn debug_request(&self, method: &Method, url: &str, attempt: u32) {
242 if !self.debug {
243 return;
244 }
245 if attempt > 1 {
246 eprintln!("[debug] -> {} {url} (attempt {attempt})", method.as_str());
247 } else {
248 eprintln!("[debug] -> {} {url}", method.as_str());
249 }
250 }
251
252 fn debug_failure(&self, method: &Method, url: &str, what: &str) {
254 if !self.debug {
255 return;
256 }
257 let _ = writeln!(
258 std::io::stderr(),
259 "[debug] {} {url} -> {what}",
260 method.as_str()
261 );
262 }
263
264 pub fn space_path(space: &str, path: &str) -> String {
268 if space.is_empty() || space == "default" {
269 path.to_string()
270 } else {
271 format!("/s/{space}{path}")
272 }
273 }
274
275 pub fn kibana_url(&self) -> &str {
277 &self.kibana_url
278 }
279
280 pub async fn capabilities(&self) -> Result<&Capabilities> {
282 self.capabilities
283 .get_or_try_init(|| Capabilities::probe(self, self.kibana_url()))
284 .await
285 }
286
287 pub async fn require_feature(&self, feature: Feature) -> Result<()> {
289 self.capabilities().await?.require_feature(feature)
290 }
291
292 fn url(&self, path: &str) -> String {
293 format!("{}{}", self.base, Self::space_path(&self.space, path))
294 }
295
296 async fn response_text(
299 &self,
300 method: &Method,
301 url: &str,
302 response: Response,
303 ) -> Result<String> {
304 match response.text().await {
305 Ok(text) => Ok(text),
306 Err(e) if e.is_timeout() => {
307 self.debug_failure(method, url, "timeout");
308 Err(Error::new(
309 ErrorKind::Timeout,
310 format!("request timed out while reading response body: {e}"),
311 ))
312 }
313 Err(e) => {
314 self.debug_failure(method, url, "connection error");
315 Err(Error::new(
316 ErrorKind::Connection,
317 format!("request failed while reading response body: {e}"),
318 ))
319 }
320 }
321 }
322
323 async fn send_retrying<F>(&self, method: Method, url: &str, mut build: F) -> Result<Response>
324 where
325 F: FnMut() -> Result<reqwest::RequestBuilder>,
326 {
327 let mut attempt = 0;
328
329 loop {
330 attempt += 1;
331 let req = build()?;
332
333 self.debug_request(&method, url, attempt);
334 let result = req.send().await;
335
336 let response = match result {
337 Ok(r) => r,
338 Err(e) if e.is_timeout() => {
339 self.debug_failure(&method, url, "timeout");
340 return Err(Error::new(
341 ErrorKind::Timeout,
342 format!("request timed out: {e}"),
343 ));
344 }
345 Err(e) => {
346 self.debug_failure(&method, url, "connection error");
347 return Err(Error::new(
348 ErrorKind::Connection,
349 format!("request failed: {e}"),
350 ));
351 }
352 };
353
354 let status = response.status();
355 self.debug_log(&method, url, status.as_u16(), attempt);
356 if status.is_success() {
357 return Ok(response);
358 }
359
360 let transient = status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error();
363 if transient && attempt < MAX_ATTEMPTS {
364 let backoff = Duration::from_millis(200 * 2u64.pow(attempt - 1));
365 tokio::time::sleep(backoff).await;
366 continue;
367 }
368
369 let code = status.as_u16();
370 let text = self.response_text(&method, url, response).await?;
371 return Err(Error::from_response_body(code, &text));
372 }
373 }
374
375 async fn send(&self, method: Method, path: &str, body: Option<&Value>) -> Result<Response> {
376 let url = self.url(path);
377 let request_method = method.clone();
378 self.send_retrying(method, &url, || {
379 let mut req = self
380 .client
381 .request(request_method.clone(), &url)
382 .header("Authorization", &self.auth_header)
383 .header("elastic-api-version", API_VERSION);
384
385 if request_method != Method::GET {
387 req = req.header("kbn-xsrf", "true");
388 }
389 if let Some(b) = body {
390 req = req.json(b);
391 }
392
393 Ok(req)
394 })
395 .await
396 }
397
398 async fn send_json(&self, method: Method, path: &str, body: Option<&Value>) -> Result<Value> {
399 let url = self.url(path);
400 let response = self.send(method.clone(), path, body).await?;
401 let text = self.response_text(&method, &url, response).await?;
402 if text.trim().is_empty() {
403 return Ok(Value::Null);
404 }
405 parse_response_json(&text)
406 }
407
408 pub async fn get(&self, path: &str) -> Result<Value> {
409 self.send_json(Method::GET, path, None).await
410 }
411
412 pub async fn get_with_headers(&self, path: &str) -> Result<Responded> {
417 let method = Method::GET;
418 let url = self.url(path);
419 let response = self.send(method.clone(), path, None).await?;
420
421 let mut headers = BTreeMap::new();
422 for name in CAPTURED_HEADERS {
423 if let Some(value) = response.headers().get(name)
424 && let Ok(text) = value.to_str()
425 {
426 headers.insert(name.to_string(), text.to_string());
427 }
428 }
429
430 let text = self.response_text(&method, &url, response).await?;
431 let body = if text.trim().is_empty() {
432 Value::Null
433 } else {
434 parse_response_json(&text)?
435 };
436
437 Ok(Responded { body, headers })
438 }
439
440 pub async fn post(&self, path: &str, body: Option<&Value>) -> Result<Value> {
441 self.send_json(Method::POST, path, body).await
442 }
443
444 pub async fn put(&self, path: &str, body: &Value) -> Result<Value> {
445 self.send_json(Method::PUT, path, Some(body)).await
446 }
447
448 pub async fn patch(&self, path: &str, body: &Value) -> Result<Value> {
449 self.send_json(Method::PATCH, path, Some(body)).await
450 }
451
452 pub async fn delete(&self, path: &str) -> Result<Value> {
453 self.send_json(Method::DELETE, path, None).await
454 }
455
456 pub async fn get_absolute_es(&self, path: &str) -> Result<Value> {
460 self.send_absolute_es(Method::GET, path, None).await
461 }
462
463 pub async fn post_absolute_es(&self, path: &str, body: &Value) -> Result<Value> {
466 self.send_absolute_es(Method::POST, path, Some(body)).await
467 }
468
469 pub async fn delete_absolute_es(&self, path: &str) -> Result<Value> {
473 self.send_absolute_es(Method::DELETE, path, None).await
474 }
475
476 pub async fn delete_absolute_es_json(&self, path: &str, body: &Value) -> Result<Value> {
479 self.send_absolute_es(Method::DELETE, path, Some(body))
480 .await
481 }
482
483 async fn send_absolute_es(
484 &self,
485 method: Method,
486 path: &str,
487 body: Option<&Value>,
488 ) -> Result<Value> {
489 let url = format!("{}{}", self.es_base, path);
490 let request_method = method.clone();
491 let response = self
492 .send_retrying(method.clone(), &url, || {
493 let mut req = self
494 .client
495 .request(request_method.clone(), &url)
496 .header("Authorization", &self.auth_header);
497 if let Some(b) = body {
498 req = req.json(b);
499 }
500 Ok(req)
501 })
502 .await?;
503
504 let text = self.response_text(&method, &url, response).await?;
505 if text.trim().is_empty() {
506 return Ok(Value::Null);
507 }
508 parse_response_json(&text)
509 }
510
511 pub async fn post_text(&self, path: &str, body: Option<&Value>) -> Result<String> {
513 let method = Method::POST;
514 let url = self.url(path);
515 let response = self.send(method.clone(), path, body).await?;
516 self.response_text(&method, &url, response).await
517 }
518
519 pub async fn post_multipart_ndjson(&self, path: &str, ndjson: &str) -> Result<Value> {
521 let method = Method::POST;
522 let url = self.url(path);
523 let response = self
524 .send_retrying(method.clone(), &url, || {
525 let part = reqwest::multipart::Part::text(ndjson.to_string())
528 .file_name("rules.ndjson")
529 .mime_str("application/octet-stream")
530 .map_err(|e| Error::new(ErrorKind::Error, format!("building upload: {e}")))?;
531 let form = reqwest::multipart::Form::new().part("file", part);
532
533 Ok(self
534 .client
535 .post(&url)
536 .header("Authorization", &self.auth_header)
537 .header("elastic-api-version", API_VERSION)
538 .header("kbn-xsrf", "true")
539 .multipart(form))
540 })
541 .await?;
542
543 let text = self.response_text(&method, &url, response).await?;
544 parse_response_json(&text)
545 }
546}