Skip to main content

elasticctl_core/
transport.rs

1//! HTTP transport, including URL construction, headers, retries, and error
2//! classification.
3
4use 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
15/// Version of the public API this client targets.
16const API_VERSION: &str = "2023-10-31";
17const MAX_ATTEMPTS: u32 = 3;
18
19/// Response headers retained past the transport boundary.
20///
21/// These headers are allowlisted because recorded fixtures are public.
22/// Capturing all headers could record cookies, rate-limit counters, or future
23/// proxy headers that do not belong in the repository.
24///
25/// The capability probe reads `x-found-handling-cluster`. The other two show
26/// which Cloud headers the recorded response contained.
27const CAPTURED_HEADERS: [&str; 3] = [
28    "x-found-handling-cluster",
29    "x-found-handling-instance",
30    "x-elastic-product",
31];
32
33/// Parse a JSON response without letting serde_json coerce an out-of-range
34/// integer literal into an imprecise floating-point number.
35fn 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
41/// Reject positive integer lexemes above `u64::MAX` and negative integer
42/// lexemes below `i64::MIN`. Numbers with a decimal point or exponent remain
43/// floating-point JSON values, even when their magnitude is greater than
44/// `u64::MAX`.
45fn 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/// A response body and its captured headers.
128///
129/// Hosted and self-managed stacks return the same `/api/status` body. An
130/// edge-proxy header distinguishes them.
131#[derive(Debug, Clone)]
132pub struct Responded {
133    pub body: Value,
134    pub headers: BTreeMap<String, String>,
135}
136
137impl Responded {
138    /// Look up a header case-insensitively.
139    ///
140    /// The Elastic proxy varies the casing of `x-found-handling-cluster` by
141    /// endpoint.
142    pub fn header(&self, name: &str) -> Option<&str> {
143        self.headers.get(&name.to_ascii_lowercase()).map(|s| &**s)
144    }
145}
146
147/// Percent-encode a query value while leaving URL-safe characters unchanged.
148///
149/// The API client and fixture recorder share this encoder so they produce the
150/// same scoped-filter URL.
151pub 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    one_shot_client: Client,
167    base: String,
168    /// The Kibana URL exactly as configured, before trailing-slash
169    /// normalization. `doctor` reports it as the connectivity target and the
170    /// capability probe uses it for hostname-based flavor detection.
171    kibana_url: String,
172    /// Elasticsearch host. Cloud deployments use a different host from
173    /// Kibana; otherwise this uses the Kibana host.
174    es_base: String,
175    /// Whether the profile configured an explicit `es_url`. `es_base` falls
176    /// back to the Kibana host either way, so a caller that needs to know
177    /// whether that fallback happened (e.g. to name a likely cause in an
178    /// error message) reads this instead of comparing hosts.
179    has_es_url: bool,
180    space: String,
181    auth_header: String,
182    debug: bool,
183    capabilities: OnceCell<Capabilities>,
184}
185
186impl Transport {
187    fn client_builder(profile: &Profile) -> reqwest::ClientBuilder {
188        Client::builder()
189            .timeout(Duration::from_secs(profile.timeout_secs))
190            .danger_accept_invalid_certs(!profile.verify)
191    }
192
193    pub fn new(profile: &Profile) -> Result<Transport> {
194        Self::with_debug(profile, false)
195    }
196
197    /// Build a transport with HTTP request logging enabled or disabled.
198    ///
199    /// Keeping `debug` as a `bool` prevents CLI `clap` types entering `-core`.
200    pub fn with_debug(profile: &Profile, debug: bool) -> Result<Transport> {
201        // Scrub URL userinfo before deriving any base URL or logging, so a
202        // credential embedded in a URL never reaches a request or debug line.
203        let mut profile = profile.clone();
204        profile.strip_userinfo();
205        let credential = Credential::from_profile(&profile)?;
206        let client = Self::client_builder(&profile)
207            .build()
208            .map_err(|e| Error::new(ErrorKind::Connection, format!("building HTTP client: {e}")))?;
209        let one_shot_client = Self::client_builder(&profile)
210            .redirect(reqwest::redirect::Policy::none())
211            // hyper-util retries a canceled checkout only through its connection
212            // pool. Zero idle connections disables that pool, so this client
213            // cannot replay an unstarted mutation after a reused socket fails.
214            .pool_max_idle_per_host(0)
215            .retry(reqwest::retry::never())
216            .build()
217            .map_err(|e| {
218                Error::new(
219                    ErrorKind::Connection,
220                    format!("building one-shot HTTP client: {e}"),
221                )
222            })?;
223
224        let base = profile.kibana_url.trim_end_matches('/').to_string();
225        let kibana_url = profile.kibana_url.clone();
226        let has_es_url = profile.es_url.is_some();
227        let es_base = profile
228            .es_url
229            .as_deref()
230            .unwrap_or(&profile.kibana_url)
231            .trim_end_matches('/')
232            .to_string();
233
234        Ok(Transport {
235            client,
236            one_shot_client,
237            base,
238            kibana_url,
239            es_base,
240            has_es_url,
241            space: profile.space.clone(),
242            auth_header: credential.header_value(),
243            debug,
244            capabilities: OnceCell::new(),
245        })
246    }
247
248    /// Log one request or response line to stderr.
249    ///
250    /// Logs include the method, complete URL, and status. They exclude
251    /// authorization headers and bodies. Callers must not put credentials in
252    /// query strings.
253    fn debug_log(&self, method: &Method, url: &str, status: u16, attempt: u32) {
254        if !self.debug {
255            return;
256        }
257        if attempt > 1 {
258            eprintln!(
259                "[debug] {} {url} -> {status} (attempt {attempt})",
260                method.as_str()
261            );
262        } else {
263            eprintln!("[debug] {} {url} -> {status}", method.as_str());
264        }
265    }
266
267    /// Log the request before sending it so timeouts produce debug output.
268    fn debug_request(&self, method: &Method, url: &str, attempt: u32) {
269        if !self.debug {
270            return;
271        }
272        if attempt > 1 {
273            eprintln!("[debug] -> {} {url} (attempt {attempt})", method.as_str());
274        } else {
275            eprintln!("[debug] -> {} {url}", method.as_str());
276        }
277    }
278
279    /// Log a timeout or connection failure in the response-line format.
280    fn debug_failure(&self, method: &Method, url: &str, what: &str) {
281        if !self.debug {
282            return;
283        }
284        let _ = writeln!(
285            std::io::stderr(),
286            "[debug] {} {url} -> {what}",
287            method.as_str()
288        );
289    }
290
291    /// Prefix non-default spaces with `/s/<name>`.
292    ///
293    /// Kibana serves the default space at the bare path.
294    pub fn space_path(space: &str, path: &str) -> String {
295        if space.is_empty() || space == "default" {
296            path.to_string()
297        } else {
298            format!("/s/{space}{path}")
299        }
300    }
301
302    /// The Kibana URL this transport targets, exactly as configured.
303    pub fn kibana_url(&self) -> &str {
304        &self.kibana_url
305    }
306
307    /// The Kibana space this transport targets, exactly as the profile
308    /// resolved it. Fleet normalization compares `space_ids` against it.
309    pub fn space(&self) -> &str {
310        &self.space
311    }
312
313    /// Whether the profile configured an explicit `es_url`. When it did not,
314    /// every `*_absolute_es` call silently falls back to the Kibana host —
315    /// callers that need to distinguish "no Elasticsearch host configured"
316    /// from "the real Elasticsearch host answered" read this.
317    pub fn has_es_url(&self) -> bool {
318        self.has_es_url
319    }
320
321    /// Probe deployment capabilities once for this transport.
322    pub async fn capabilities(&self) -> Result<&Capabilities> {
323        self.capabilities
324            .get_or_try_init(|| Capabilities::probe(self, self.kibana_url()))
325            .await
326    }
327
328    /// Refuse an unverified feature before its public route is called.
329    pub async fn require_feature(&self, feature: Feature) -> Result<()> {
330        self.capabilities().await?.require_feature(feature)
331    }
332
333    fn url(&self, path: &str) -> String {
334        format!("{}{}", self.base, Self::space_path(&self.space, path))
335    }
336
337    /// Read a response body without retrying an operation that may have
338    /// completed after its headers were received.
339    async fn response_text(
340        &self,
341        method: &Method,
342        url: &str,
343        response: Response,
344    ) -> Result<String> {
345        match response.text().await {
346            Ok(text) => Ok(text),
347            Err(e) if e.is_timeout() => {
348                self.debug_failure(method, url, "timeout");
349                Err(Error::new(
350                    ErrorKind::Timeout,
351                    format!("request timed out while reading response body: {e}"),
352                ))
353            }
354            Err(e) => {
355                self.debug_failure(method, url, "connection error");
356                Err(Error::new(
357                    ErrorKind::Connection,
358                    format!("request failed while reading response body: {e}"),
359                ))
360            }
361        }
362    }
363
364    async fn send_retrying<F>(
365        &self,
366        method: Method,
367        url: &str,
368        attempt_limit: u32,
369        mut build: F,
370    ) -> Result<Response>
371    where
372        F: FnMut() -> Result<reqwest::RequestBuilder>,
373    {
374        let mut attempt = 0;
375
376        loop {
377            attempt += 1;
378            let req = build()?;
379
380            self.debug_request(&method, url, attempt);
381            let result = req.send().await;
382
383            let response = match result {
384                Ok(r) => r,
385                Err(e) if e.is_timeout() => {
386                    self.debug_failure(&method, url, "timeout");
387                    return Err(Error::new(
388                        ErrorKind::Timeout,
389                        format!("request timed out: {e}"),
390                    ));
391                }
392                Err(e) => {
393                    self.debug_failure(&method, url, "connection error");
394                    return Err(Error::new(
395                        ErrorKind::Connection,
396                        format!("request failed: {e}"),
397                    ));
398                }
399            };
400
401            let status = response.status();
402            self.debug_log(&method, url, status.as_u16(), attempt);
403            if status.is_success() {
404                return Ok(response);
405            }
406
407            // Retry transient failures only. Retrying a 4xx repeats the same
408            // caller error.
409            let transient = status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error();
410            if transient && attempt < attempt_limit {
411                let backoff = Duration::from_millis(200 * 2u64.pow(attempt - 1));
412                tokio::time::sleep(backoff).await;
413                continue;
414            }
415
416            let code = status.as_u16();
417            let text = self.response_text(&method, url, response).await?;
418            return Err(Error::from_response_body(code, &text));
419        }
420    }
421
422    async fn send(&self, method: Method, path: &str, body: Option<&Value>) -> Result<Response> {
423        self.send_with_attempt_limit(method, path, body, MAX_ATTEMPTS)
424            .await
425    }
426
427    async fn send_with_attempt_limit(
428        &self,
429        method: Method,
430        path: &str,
431        body: Option<&Value>,
432        attempt_limit: u32,
433    ) -> Result<Response> {
434        self.send_with_client_attempt_limit(&self.client, method, path, body, attempt_limit)
435            .await
436    }
437
438    async fn send_with_client_attempt_limit(
439        &self,
440        client: &Client,
441        method: Method,
442        path: &str,
443        body: Option<&Value>,
444        attempt_limit: u32,
445    ) -> Result<Response> {
446        let url = self.url(path);
447        let request_method = method.clone();
448        self.send_retrying(method, &url, attempt_limit, || {
449            let mut req = client
450                .request(request_method.clone(), &url)
451                .header("Authorization", &self.auth_header)
452                .header("elastic-api-version", API_VERSION);
453
454            // Kibana rejects any state-changing request without this header.
455            if request_method != Method::GET {
456                req = req.header("kbn-xsrf", "true");
457            }
458            if let Some(b) = body {
459                req = req.json(b);
460            }
461
462            Ok(req)
463        })
464        .await
465    }
466
467    async fn send_json(&self, method: Method, path: &str, body: Option<&Value>) -> Result<Value> {
468        self.send_json_with_attempt_limit(method, path, body, MAX_ATTEMPTS)
469            .await
470    }
471
472    async fn send_json_with_attempt_limit(
473        &self,
474        method: Method,
475        path: &str,
476        body: Option<&Value>,
477        attempt_limit: u32,
478    ) -> Result<Value> {
479        let url = self.url(path);
480        let response = self
481            .send_with_attempt_limit(method.clone(), path, body, attempt_limit)
482            .await?;
483        let text = self.response_text(&method, &url, response).await?;
484        if text.trim().is_empty() {
485            return Ok(Value::Null);
486        }
487        parse_response_json(&text)
488    }
489
490    async fn send_json_once(
491        &self,
492        method: Method,
493        path: &str,
494        body: Option<&Value>,
495    ) -> Result<Value> {
496        let url = self.url(path);
497        let response = self
498            .send_with_client_attempt_limit(&self.one_shot_client, method.clone(), path, body, 1)
499            .await?;
500        let text = self.response_text(&method, &url, response).await?;
501        if text.trim().is_empty() {
502            return Ok(Value::Null);
503        }
504        parse_response_json(&text)
505    }
506
507    pub async fn get(&self, path: &str) -> Result<Value> {
508        self.send_json(Method::GET, path, None).await
509    }
510
511    /// GET a Kibana *internal* route. Internal routes sit outside the public
512    /// API's versioning contract: Kibana requires `x-elastic-internal-origin`
513    /// on them (without it the route family answers 400 "exists but is not
514    /// available"), and `elastic-api-version: 2023-10-31` names a public-API
515    /// version internal routes do not serve, so it is omitted.
516    pub async fn get_internal(&self, path: &str) -> Result<Value> {
517        let method = Method::GET;
518        let url = self.url(path);
519        let response = self
520            .send_retrying(method.clone(), &url, MAX_ATTEMPTS, || {
521                Ok(self
522                    .client
523                    .request(Method::GET, &url)
524                    .header("Authorization", &self.auth_header)
525                    .header("x-elastic-internal-origin", "Kibana"))
526            })
527            .await?;
528
529        // Convert the response exactly as `get` does: the same
530        // response_text/parse_response_json path `send_json` uses after its
531        // send, with the same `Error::from_response_body` classification
532        // already applied inside `send_retrying`.
533        let text = self.response_text(&method, &url, response).await?;
534        if text.trim().is_empty() {
535            return Ok(Value::Null);
536        }
537        parse_response_json(&text)
538    }
539
540    /// POST a Kibana *internal* route with a JSON body. Mirrors
541    /// `get_internal`'s reasoning (same `x-elastic-internal-origin`
542    /// requirement, `elastic-api-version` still omitted), plus `kbn-xsrf`:
543    /// Kibana rejects any state-changing request without it, same as every
544    /// other non-GET call `send` makes.
545    ///
546    /// Its only caller today is xtask's profile-activation login
547    /// (`POST /internal/security/login`) — not reachable from
548    /// `elasticctl-api` or `elasticctl-cli`.
549    pub async fn post_internal(&self, path: &str, body: &Value) -> Result<Value> {
550        let method = Method::POST;
551        let url = self.url(path);
552        let response = self
553            .send_retrying(method.clone(), &url, MAX_ATTEMPTS, || {
554                Ok(self
555                    .client
556                    .request(Method::POST, &url)
557                    .header("Authorization", &self.auth_header)
558                    .header("kbn-xsrf", "true")
559                    .header("x-elastic-internal-origin", "Kibana")
560                    .json(body))
561            })
562            .await?;
563
564        let text = self.response_text(&method, &url, response).await?;
565        if text.trim().is_empty() {
566            return Ok(Value::Null);
567        }
568        parse_response_json(&text)
569    }
570
571    /// GET a body with its captured headers.
572    ///
573    /// This is separate from `get` because only the capability probe needs
574    /// headers.
575    pub async fn get_with_headers(&self, path: &str) -> Result<Responded> {
576        let method = Method::GET;
577        let url = self.url(path);
578        let response = self.send(method.clone(), path, None).await?;
579
580        let mut headers = BTreeMap::new();
581        for name in CAPTURED_HEADERS {
582            if let Some(value) = response.headers().get(name)
583                && let Ok(text) = value.to_str()
584            {
585                headers.insert(name.to_string(), text.to_string());
586            }
587        }
588
589        let text = self.response_text(&method, &url, response).await?;
590        let body = if text.trim().is_empty() {
591            Value::Null
592        } else {
593            parse_response_json(&text)?
594        };
595
596        Ok(Responded { body, headers })
597    }
598
599    pub async fn post(&self, path: &str, body: Option<&Value>) -> Result<Value> {
600        self.send_json(Method::POST, path, body).await
601    }
602
603    /// Send a JSON POST exactly once.
604    ///
605    /// This is for mutations whose endpoint does not provide an idempotency
606    /// key. It disables redirects, connection pooling, and protocol-level
607    /// retries. It otherwise uses the same request construction, response
608    /// parsing, timeout handling, and error classification as [`Self::post`].
609    pub async fn post_once(&self, path: &str, body: Option<&Value>) -> Result<Value> {
610        self.send_json_once(Method::POST, path, body).await
611    }
612
613    pub async fn put(&self, path: &str, body: &Value) -> Result<Value> {
614        self.send_json(Method::PUT, path, Some(body)).await
615    }
616
617    /// Send a JSON PUT exactly once.
618    ///
619    /// This is for mutations whose endpoint does not provide an idempotency
620    /// key. It disables redirects, connection pooling, and protocol-level
621    /// retries. It otherwise uses the same request construction, response
622    /// parsing, timeout handling, and error classification as [`Self::put`].
623    pub async fn put_once(&self, path: &str, body: &Value) -> Result<Value> {
624        self.send_json_once(Method::PUT, path, Some(body)).await
625    }
626
627    pub async fn patch(&self, path: &str, body: &Value) -> Result<Value> {
628        self.send_json(Method::PATCH, path, Some(body)).await
629    }
630
631    pub async fn delete(&self, path: &str) -> Result<Value> {
632        self.send_json(Method::DELETE, path, None).await
633    }
634
635    /// Send a JSON DELETE exactly once.
636    ///
637    /// This is for mutations whose endpoint does not provide an idempotency
638    /// key. It disables redirects, connection pooling, and protocol-level
639    /// retries. It otherwise uses the same request construction, response
640    /// parsing, timeout handling, and error classification as [`Self::delete`].
641    pub async fn delete_once(&self, path: &str) -> Result<Value> {
642        self.send_json_once(Method::DELETE, path, None).await
643    }
644
645    /// GET Elasticsearch without a Kibana space prefix.
646    ///
647    /// Cloud deployments use a different Elasticsearch host.
648    pub async fn get_absolute_es(&self, path: &str) -> Result<Value> {
649        self.send_absolute_es(Method::GET, path, None).await
650    }
651
652    /// POST JSON to Elasticsearch without a Kibana space prefix or `kbn-xsrf`
653    /// header.
654    pub async fn post_absolute_es(&self, path: &str, body: &Value) -> Result<Value> {
655        self.send_absolute_es(Method::POST, path, Some(body)).await
656    }
657
658    /// DELETE from Elasticsearch.
659    ///
660    /// The fixture recorder uses this to remove its scratch index.
661    pub async fn delete_absolute_es(&self, path: &str) -> Result<Value> {
662        self.send_absolute_es(Method::DELETE, path, None).await
663    }
664
665    /// DELETE from Elasticsearch with a JSON body. The PIT close needs one;
666    /// the plain `delete_absolute_es` sends no body.
667    pub async fn delete_absolute_es_json(&self, path: &str, body: &Value) -> Result<Value> {
668        self.send_absolute_es(Method::DELETE, path, Some(body))
669            .await
670    }
671
672    async fn send_absolute_es(
673        &self,
674        method: Method,
675        path: &str,
676        body: Option<&Value>,
677    ) -> Result<Value> {
678        let url = format!("{}{}", self.es_base, path);
679        let request_method = method.clone();
680        let response = self
681            .send_retrying(method.clone(), &url, MAX_ATTEMPTS, || {
682                let mut req = self
683                    .client
684                    .request(request_method.clone(), &url)
685                    .header("Authorization", &self.auth_header);
686                if let Some(b) = body {
687                    req = req.json(b);
688                }
689                Ok(req)
690            })
691            .await?;
692
693        let text = self.response_text(&method, &url, response).await?;
694        if text.trim().is_empty() {
695            return Ok(Value::Null);
696        }
697        parse_response_json(&text)
698    }
699
700    /// POST and return the raw body for NDJSON endpoints.
701    pub async fn post_text(&self, path: &str, body: Option<&Value>) -> Result<String> {
702        let method = Method::POST;
703        let url = self.url(path);
704        let response = self.send(method.clone(), path, body).await?;
705        self.response_text(&method, &url, response).await
706    }
707
708    /// Upload a multipart NDJSON file for Kibana rule import.
709    pub async fn post_multipart_ndjson(&self, path: &str, ndjson: &str) -> Result<Value> {
710        self.post_multipart_ndjson_named(path, "rules.ndjson", ndjson)
711            .await
712    }
713
714    /// Upload a multipart NDJSON file for Kibana import with the requested filename.
715    pub async fn post_multipart_ndjson_named(
716        &self,
717        path: &str,
718        filename: &str,
719        ndjson: &str,
720    ) -> Result<Value> {
721        let method = Method::POST;
722        let url = self.url(path);
723        let response = self
724            .send_retrying(method.clone(), &url, MAX_ATTEMPTS, || {
725                // Retryable HTTP responses deliberately replay this POST. Part and Form are
726                // recreated here because reqwest consumes multipart bodies while sending.
727                let part = reqwest::multipart::Part::text(ndjson.to_string())
728                    .file_name(filename.to_string())
729                    .mime_str("application/octet-stream")
730                    .map_err(|e| Error::new(ErrorKind::Error, format!("building upload: {e}")))?;
731                let form = reqwest::multipart::Form::new().part("file", part);
732
733                Ok(self
734                    .client
735                    .post(&url)
736                    .header("Authorization", &self.auth_header)
737                    .header("elastic-api-version", API_VERSION)
738                    .header("kbn-xsrf", "true")
739                    .multipart(form))
740            })
741            .await?;
742
743        let text = self.response_text(&method, &url, response).await?;
744        parse_response_json(&text)
745    }
746}