Skip to main content

rustlavel_search/
client.rs

1//! Talking to Elasticsearch or OpenSearch.
2//!
3//! One client for both. OpenSearch is a fork of Elasticsearch 7.10, and every
4//! endpoint this crate uses — `_search`, `_bulk`, `_doc`, `_mapping`,
5//! `_refresh`, `_cluster/health` — has the same URL, the same request body and
6//! the same response shape on both. Nothing here is compiled against either
7//! project; it is an HTTP API, and this speaks it. [`ClusterInfo::distribution`]
8//! reports which one answered, for the rare caller that has to care.
9
10use crate::error::{Result, SearchError};
11use crate::query::Search;
12use crate::response::SearchResults;
13use rustlavel_client::Client;
14use rustlavel_core::{Config, Json};
15use rustlavel_http::Method;
16use std::time::Duration;
17
18/// How a request proves who it is.
19///
20/// No `Debug`: every variant but `None` holds a credential.
21#[derive(Clone)]
22enum Auth {
23    None,
24    /// A pre-built `Authorization` header value, so the secret is encoded once
25    /// rather than on every request.
26    Header(String),
27}
28
29/// A connection to a cluster.
30///
31/// Cheap to clone — everything in it is shared or small — so one client can
32/// live in the application context and be handed to every handler.
33#[derive(Clone)]
34pub struct SearchClient {
35    base_url: String,
36    auth: Auth,
37    http: Client,
38    retries: u32,
39}
40
41impl SearchClient {
42    /// Point at a cluster: `http://localhost:9200`.
43    ///
44    /// A trailing slash is trimmed, because `{base}//_search` is a 400 that
45    /// says nothing about slashes.
46    pub fn new(base_url: impl Into<String>) -> SearchClient {
47        SearchClient {
48            base_url: base_url.into().trim_end_matches('/').to_string(),
49            auth: Auth::None,
50            // A search is meant to be fast; a long default only turns a hung
51            // cluster into a hung application. Raise it for a heavy
52            // reindex or an expensive aggregation.
53            http: Client::new().timeout(Duration::from_secs(30)),
54            retries: 2,
55        }
56    }
57
58    /// Build from `config/search.json`, so the cluster address and credentials
59    /// can come from `.env`.
60    ///
61    /// | Key                 | Meaning                                              |
62    /// |---------------------|------------------------------------------------------|
63    /// | `search.url`        | Cluster base URL. `http://localhost:9200` by default. |
64    /// | `search.username`   | With `search.password`, HTTP basic authentication.   |
65    /// | `search.api_key`    | The `encoded` API key, used as-is.                   |
66    /// | `search.token`      | A bearer token.                                      |
67    /// | `search.timeout_ms` | Per-request timeout.                                 |
68    /// | `search.retries`    | Retries for failures that retrying can fix.          |
69    ///
70    /// The first credential found wins, in the order above. Nothing connects
71    /// here — an application must boot while the cluster is restarting.
72    pub fn from_config(config: &Config) -> SearchClient {
73        let mut client = SearchClient::new(config.string("search.url", "http://localhost:9200"));
74
75        let username = config.string("search.username", "");
76        let api_key = config.string("search.api_key", "");
77        let token = config.string("search.token", "");
78        if !username.is_empty() {
79            client = client.basic_auth(&username, &config.string("search.password", ""));
80        } else if !api_key.is_empty() {
81            client = client.api_key(api_key);
82        } else if !token.is_empty() {
83            client = client.bearer(token);
84        }
85
86        let timeout_ms = config.int("search.timeout_ms", 0);
87        if timeout_ms > 0 {
88            client = client.timeout(Duration::from_millis(timeout_ms as u64));
89        }
90        let retries = config.int("search.retries", -1);
91        if retries >= 0 {
92            client = client.retries(retries.min(10) as u32);
93        }
94        client
95    }
96
97    /// Authenticate with a username and password, the way `elastic` and a
98    /// native realm user do.
99    pub fn basic_auth(mut self, username: &str, password: &str) -> SearchClient {
100        let encoded = base64(format!("{username}:{password}").as_bytes());
101        self.auth = Auth::Header(format!("Basic {encoded}"));
102        self
103    }
104
105    /// Authenticate with an API key.
106    ///
107    /// This is the `encoded` field of what `POST /_security/api_key` returned —
108    /// already base64 of `id:api_key`, so it is passed through untouched.
109    /// Encoding it a second time produces a 401 that blames the credentials
110    /// rather than the encoding.
111    pub fn api_key(mut self, encoded: impl Into<String>) -> SearchClient {
112        self.auth = Auth::Header(format!("ApiKey {}", encoded.into()));
113        self
114    }
115
116    /// Authenticate with a bearer token, for a cluster behind an OAuth or
117    /// service-token setup.
118    pub fn bearer(mut self, token: impl Into<String>) -> SearchClient {
119        self.auth = Auth::Header(format!("Bearer {}", token.into()));
120        self
121    }
122
123    pub fn timeout(mut self, timeout: Duration) -> SearchClient {
124        self.http = self.http.timeout(timeout);
125        self
126    }
127
128    /// How many times to repeat a request the cluster refused in a way that
129    /// repeating can fix. See [`SearchError::is_retryable`] for which those
130    /// are; a mapping error is never one of them.
131    pub fn retries(mut self, retries: u32) -> SearchClient {
132        self.retries = retries;
133        self
134    }
135
136    /// Answer from a script instead of the network, for tests.
137    pub fn faking(mut self, fake: rustlavel_client::Fake) -> SearchClient {
138        self.http = self.http.faking(fake);
139        self
140    }
141
142    /// The script this client is answering from, for assertions in a test.
143    pub fn fake(&self) -> Option<&std::sync::Arc<rustlavel_client::Fake>> {
144        self.http.fake()
145    }
146
147    pub fn base_url(&self) -> &str {
148        &self.base_url
149    }
150
151    /// The full URL for an API path.
152    ///
153    /// Leading slashes are trimmed so `_search` and `/_search` both work — the
154    /// difference is invisible in a config file and produces a 400.
155    pub fn url(&self, path: &str) -> String {
156        format!("{}/{}", self.base_url, path.trim_start_matches('/'))
157    }
158
159    /// Whether the cluster is up and what state it is in.
160    ///
161    /// Use this in a readiness check rather than [`SearchClient::info`]: the
162    /// root endpoint answers while shards are still recovering, and a cluster
163    /// that answers is not the same as a cluster that can serve a search.
164    pub async fn health(&self) -> Result<ClusterHealth> {
165        let body = self.json(Method::Get, "_cluster/health", "_cluster/health", None).await?;
166        ClusterHealth::parse(&body)
167    }
168
169    /// Which server this is, and which version.
170    pub async fn info(&self) -> Result<ClusterInfo> {
171        let body = self.json(Method::Get, "", "/", None).await?;
172
173        Ok(ClusterInfo {
174            name: string_at(&body, "name"),
175            cluster_name: string_at(&body, "cluster_name"),
176            version: string_at(&body, "version.number"),
177            // OpenSearch adds `distribution`; Elasticsearch has never sent it,
178            // so its absence is the answer rather than missing information.
179            distribution: body
180                .get("version.distribution")
181                .and_then(Json::as_str)
182                .unwrap_or("elasticsearch")
183                .to_string(),
184        })
185    }
186
187    /// Run a search and read the hits back.
188    ///
189    /// `index` may name several indices (`posts,comments`) or a pattern
190    /// (`logs-*`), which is what the cluster's own URL accepts.
191    pub async fn search(&self, index: &str, search: &Search) -> Result<SearchResults> {
192        let path = format!("{}/_search", encode(index));
193        let body = self.json(Method::Post, &path, index, Some(search.body())).await?;
194        SearchResults::parse(&body)
195    }
196
197    /// How many documents match, without fetching any of them.
198    ///
199    /// Unlike the total on a search this is never approximate, because
200    /// `_count` has no `track_total_hits` cutoff to stop early at.
201    pub async fn count(&self, index: &str, search: &Search) -> Result<u64> {
202        let path = format!("{}/_count", encode(index));
203        let body = self.json(Method::Post, &path, index, Some(search.count_body())).await?;
204
205        body.get("count")
206            .and_then(Json::as_i64)
207            .map(|count| count.max(0) as u64)
208            .ok_or_else(|| SearchError::Malformed {
209                context: path,
210                message: "the reply has no `count`".to_string(),
211            })
212    }
213
214    /// Send a request and parse a successful JSON body.
215    ///
216    /// `context` names the resource for an error message, because several
217    /// failures — a version conflict above all — identify the index in the
218    /// body but never the document.
219    pub(crate) async fn json(
220        &self,
221        method: Method,
222        path: &str,
223        context: &str,
224        body: Option<Json>,
225    ) -> Result<Json> {
226        let raw = self.send(method, path, context, body).await?;
227
228        if !raw.is_success() {
229            return Err(raw.error(context));
230        }
231        raw.into_json(path)
232    }
233
234    /// Send a request whose body is newline-delimited JSON, which `_bulk` and
235    /// `_msearch` require and which is not valid JSON as a whole.
236    pub(crate) async fn ndjson(&self, path: &str, context: &str, body: String) -> Result<Json> {
237        let raw = self
238            .send_payload(Method::Post, path, context, Some(Payload::Ndjson(body)))
239            .await?;
240
241        if !raw.is_success() {
242            return Err(raw.error(context));
243        }
244        raw.into_json(path)
245    }
246
247    /// Whether something is there, asked with a `GET`.
248    ///
249    /// The cluster's own answer to "does this exist" is a `HEAD`, and that is
250    /// what this used to send — until a real 8.15 refused to finish the reply.
251    /// Elasticsearch answers a `HEAD` with `Transfer-Encoding: chunked` and
252    /// then writes no body at all, not even the terminating zero-length chunk,
253    /// so a client reading the body as the framing promises waits for bytes
254    /// that never arrive. Every existence check failed as "chunked body ended
255    /// early", and only against a live server: a fake has no framing to get
256    /// wrong.
257    ///
258    /// So this asks with a `GET` and lets the caller keep the response small —
259    /// `filter_path` trims an index reply to `{}`, `_source=false` leaves a
260    /// document reply at a few fields. Slightly more bytes, and it works.
261    pub(crate) async fn exists_at(&self, path: &str, context: &str) -> Result<bool> {
262        let raw = self.send(Method::Get, path, context, None).await?;
263
264        match raw.status {
265            _ if raw.is_success() => Ok(true),
266            404 => Ok(false),
267            _ => Err(raw.error(context)),
268        }
269    }
270
271    /// Send a request and hand back whatever came, retried but not judged.
272    ///
273    /// A failing status is a successful `Ok(Raw)` here. Two callers need to
274    /// see the status before it becomes an error: a `GET` of a missing
275    /// document is a 404 that means "no", and a `_bulk` is a 200 that may
276    /// still have lost documents.
277    pub(crate) async fn send(
278        &self,
279        method: Method,
280        path: &str,
281        context: &str,
282        body: Option<Json>,
283    ) -> Result<Raw> {
284        self.send_payload(method, path, context, body.map(Payload::Json)).await
285    }
286
287    /// Send a request, retrying the failures that repeating can fix.
288    async fn send_payload(
289        &self,
290        method: Method,
291        path: &str,
292        context: &str,
293        body: Option<Payload>,
294    ) -> Result<Raw> {
295        let mut attempt = 0;
296
297        loop {
298            let (failed, error) = match self.send_once(method, path, body.clone()).await {
299                Ok(raw) if raw.is_success() => return Ok(raw),
300                Ok(raw) => {
301                    let error = raw.error(context);
302                    (Some(raw), error)
303                }
304                Err(error) => (None, error),
305            };
306
307            // A transport failure is retried only for a read. A write that
308            // timed out may well have been applied, and repeating it would
309            // index the document twice; a refusal the cluster actually
310            // answered with is safe to repeat, because the work never started.
311            let safe = matches!(method, Method::Get | Method::Head);
312            let worth_retrying = match &error {
313                SearchError::Transport(_) => safe,
314                other => other.is_retryable(),
315            };
316
317            if attempt >= self.retries || !worth_retrying {
318                // The failing response is handed back rather than classified,
319                // so a caller that reads a 404 as an answer still can.
320                return match failed {
321                    Some(raw) => Ok(raw),
322                    None => Err(error),
323                };
324            }
325
326            // A tripped circuit breaker clears in the time it takes a GC to
327            // finish, so the backoff starts small and doubles.
328            tokio::time::sleep(Duration::from_millis(200 * 2u64.pow(attempt))).await;
329            attempt += 1;
330        }
331    }
332
333    async fn send_once(&self, method: Method, path: &str, body: Option<Payload>) -> Result<Raw> {
334        let mut request = self.http.request(method, self.url(path));
335
336        if let Auth::Header(value) = &self.auth {
337            request = request.header("authorization", value.clone());
338        }
339
340        match body {
341            Some(Payload::Json(value)) => request = request.json(value),
342            // `application/x-ndjson` is what `_bulk` is defined to take.
343            // Elasticsearch 8.15 happens to parse a body labelled
344            // `application/json` anyway, but that is a leniency rather than a
345            // promise: earlier versions, OpenSearch's stricter settings and
346            // proxies in between all refuse it, and the resulting error talks
347            // about content types rather than about the body.
348            Some(Payload::Ndjson(text)) => {
349                request = request.header("content-type", "application/x-ndjson").body(text)
350            }
351            None => {}
352        }
353
354        let response =
355            request.send().await.map_err(|e| SearchError::Transport(e.to_string()))?;
356
357        Ok(Raw { status: response.status.code(), body: response.text() })
358    }
359}
360
361/// Deliberately hand-written, so a password or API key cannot reach a log.
362impl std::fmt::Debug for SearchClient {
363    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364        f.debug_struct("SearchClient")
365            .field("base_url", &self.base_url)
366            .field(
367                "auth",
368                &match &self.auth {
369                    Auth::None => "<none>",
370                    Auth::Header(_) => "<redacted>",
371                },
372            )
373            .field("retries", &self.retries)
374            .finish()
375    }
376}
377
378/// A request body, which is JSON everywhere except `_bulk`.
379#[derive(Clone)]
380enum Payload {
381    Json(Json),
382    Ndjson(String),
383}
384
385/// A response before anything has been decided about it.
386pub(crate) struct Raw {
387    pub(crate) status: u16,
388    pub(crate) body: String,
389}
390
391impl Raw {
392    pub(crate) fn is_success(&self) -> bool {
393        (200..300).contains(&self.status)
394    }
395
396    /// What this response means, once it is known to be a failure.
397    pub(crate) fn error(&self, context: &str) -> SearchError {
398        SearchError::from_response(self.status, context, &self.body)
399    }
400
401    pub(crate) fn into_json(self, path: &str) -> Result<Json> {
402        // A few endpoints (`_refresh` on some versions, a HEAD) answer with
403        // nothing at all. Parsing that as JSON would turn a success into an
404        // error.
405        if self.body.trim().is_empty() {
406            return Ok(Json::Null);
407        }
408
409        Json::parse(&self.body).map_err(|e| SearchError::Malformed {
410            context: path.to_string(),
411            message: e.to_string(),
412        })
413    }
414}
415
416/// What `_cluster/health` reports.
417#[derive(Debug, Clone, PartialEq, Eq)]
418pub struct ClusterHealth {
419    pub cluster_name: String,
420    pub status: HealthStatus,
421    pub number_of_nodes: u32,
422    pub active_shards: u32,
423    pub unassigned_shards: u32,
424    /// The health request itself timed out waiting for the state it was asked
425    /// for — not a statement about the cluster.
426    pub timed_out: bool,
427}
428
429impl ClusterHealth {
430    fn parse(body: &Json) -> Result<ClusterHealth> {
431        let status = body.get("status").and_then(Json::as_str).ok_or_else(|| {
432            SearchError::Malformed {
433                context: "_cluster/health".to_string(),
434                message: "the reply has no `status`".to_string(),
435            }
436        })?;
437
438        Ok(ClusterHealth {
439            cluster_name: string_at(body, "cluster_name"),
440            status: HealthStatus::parse(status),
441            number_of_nodes: number_at(body, "number_of_nodes"),
442            active_shards: number_at(body, "active_shards"),
443            unassigned_shards: number_at(body, "unassigned_shards"),
444            timed_out: body.get("timed_out").and_then(Json::as_bool).unwrap_or(false),
445        })
446    }
447}
448
449/// The traffic-light the cluster reports.
450#[derive(Debug, Clone, Copy, PartialEq, Eq)]
451pub enum HealthStatus {
452    /// Every shard, primary and replica, is assigned.
453    Green,
454    /// Every primary is assigned; some replica is not.
455    Yellow,
456    /// At least one primary is unassigned — some data cannot be read at all.
457    Red,
458    /// A colour this client does not know.
459    Unknown,
460}
461
462impl HealthStatus {
463    fn parse(value: &str) -> HealthStatus {
464        match value {
465            "green" => HealthStatus::Green,
466            "yellow" => HealthStatus::Yellow,
467            "red" => HealthStatus::Red,
468            _ => HealthStatus::Unknown,
469        }
470    }
471
472    /// Whether every document can be reached.
473    ///
474    /// Yellow counts. A single-node cluster is yellow the moment an index has
475    /// one replica configured, which is the default and which no single node
476    /// can ever satisfy — so a readiness check that demanded green would fail
477    /// on every developer machine and every scratch container forever.
478    pub fn is_usable(self) -> bool {
479        matches!(self, HealthStatus::Green | HealthStatus::Yellow)
480    }
481}
482
483impl std::fmt::Display for HealthStatus {
484    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
485        f.write_str(match self {
486            HealthStatus::Green => "green",
487            HealthStatus::Yellow => "yellow",
488            HealthStatus::Red => "red",
489            HealthStatus::Unknown => "unknown",
490        })
491    }
492}
493
494/// What the root endpoint says about itself.
495#[derive(Debug, Clone, PartialEq, Eq)]
496pub struct ClusterInfo {
497    pub name: String,
498    pub cluster_name: String,
499    pub version: String,
500    /// `elasticsearch` or `opensearch`.
501    pub distribution: String,
502}
503
504impl ClusterInfo {
505    pub fn is_opensearch(&self) -> bool {
506        self.distribution == "opensearch"
507    }
508}
509
510fn string_at(body: &Json, path: &str) -> String {
511    body.get(path).and_then(Json::as_str).unwrap_or_default().to_string()
512}
513
514fn number_at(body: &Json, path: &str) -> u32 {
515    body.get(path).and_then(Json::as_i64).unwrap_or(0).max(0) as u32
516}
517
518/// Percent-encode one path segment.
519///
520/// Document ids come from application data, and an id containing `/`, `?` or a
521/// space would otherwise change which endpoint is being called rather than
522/// which document. `,`, `*` and `-` are left alone because an index argument
523/// legitimately uses all three — `logs-*,metrics-*` is one index expression,
524/// not three escaped characters.
525pub(crate) fn encode(segment: &str) -> String {
526    let mut out = String::with_capacity(segment.len());
527
528    for byte in segment.bytes() {
529        match byte {
530            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b',' | b'*' => {
531                out.push(byte as char)
532            }
533            _ => out.push_str(&format!("%{byte:02X}")),
534        }
535    }
536
537    out
538}
539
540/// Base64, for the one header that needs it.
541///
542/// Twenty lines rather than a dependency, and the framework already writes its
543/// own in the crates that need it. This is not cryptography — it is an
544/// encoding, and the rule about not writing your own applies to the former.
545fn base64(input: &[u8]) -> String {
546    const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
547    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
548
549    for chunk in input.chunks(3) {
550        let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
551        let bits = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
552
553        out.push(ALPHABET[(bits >> 18) as usize & 63] as char);
554        out.push(ALPHABET[(bits >> 12) as usize & 63] as char);
555        out.push(if chunk.len() > 1 { ALPHABET[(bits >> 6) as usize & 63] as char } else { '=' });
556        out.push(if chunk.len() > 2 { ALPHABET[bits as usize & 63] as char } else { '=' });
557    }
558
559    out
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565    use rustlavel_client::{Fake, FakeResponse};
566
567    #[test]
568    fn from_config_reads_the_address_and_the_first_credential_it_finds() {
569        let config = Config::new();
570        config.set("search.url", "https://es.internal:9200/");
571        config.set("search.username", "elastic");
572        config.set("search.password", "s3cret");
573        config.set("search.api_key", "ignored-because-basic-wins");
574        config.set("search.retries", Json::from(2_i64));
575
576        let client = SearchClient::from_config(&config);
577        assert_eq!(client.base_url, "https://es.internal:9200", "trailing slash trimmed");
578        assert!(matches!(&client.auth, Auth::Header(h) if h.starts_with("Basic ")));
579        assert_eq!(client.retries, 2);
580    }
581
582    #[test]
583    fn from_config_with_nothing_set_points_at_localhost_unauthenticated() {
584        let client = SearchClient::from_config(&Config::new());
585        assert_eq!(client.base_url, "http://localhost:9200");
586        assert!(matches!(client.auth, Auth::None));
587    }
588
589    fn json(body: &str) -> Json {
590        Json::parse(body).expect("valid JSON in a test")
591    }
592
593    #[test]
594    fn builds_urls_whatever_the_slashes() {
595        let client = SearchClient::new("http://localhost:9200/");
596
597        assert_eq!(client.url("posts/_search"), "http://localhost:9200/posts/_search");
598        assert_eq!(client.url("/posts/_search"), "http://localhost:9200/posts/_search");
599    }
600
601    #[test]
602    fn debug_never_prints_the_password() {
603        let client = SearchClient::new("http://localhost:9200").basic_auth("elastic", "hunter2");
604
605        let printed = format!("{client:?}");
606        assert!(!printed.contains("hunter2"), "the password reached a log: {printed}");
607        // The encoded form would leak it just as thoroughly.
608        assert!(!printed.contains("ZWxhc3RpYzpodW50ZXIy"), "got {printed}");
609        assert!(printed.contains("redacted"));
610    }
611
612    #[test]
613    fn debug_distinguishes_no_credentials_from_hidden_ones() {
614        // Otherwise "why is it saying 401" is impossible to diagnose from a log.
615        let printed = format!("{:?}", SearchClient::new("http://localhost:9200"));
616        assert!(printed.contains("none"), "got {printed}");
617    }
618
619    #[test]
620    fn base64_matches_the_worked_example_in_rfc_4648() {
621        assert_eq!(base64(b""), "");
622        assert_eq!(base64(b"f"), "Zg==");
623        assert_eq!(base64(b"fo"), "Zm8=");
624        assert_eq!(base64(b"foo"), "Zm9v");
625        assert_eq!(base64(b"foob"), "Zm9vYg==");
626        assert_eq!(base64(b"fooba"), "Zm9vYmE=");
627        assert_eq!(base64(b"foobar"), "Zm9vYmFy");
628        assert_eq!(base64(b"elastic:changeme"), "ZWxhc3RpYzpjaGFuZ2VtZQ==");
629    }
630
631    #[test]
632    fn an_id_cannot_change_which_endpoint_is_called() {
633        // An id is application data. Without encoding, `a/b` would address
634        // `/posts/_doc/a/b`, which is a different route entirely.
635        assert_eq!(encode("a/b"), "a%2Fb");
636        assert_eq!(encode("with space"), "with%20space");
637        assert_eq!(encode("q?x=1"), "q%3Fx%3D1");
638        assert_eq!(encode("plain-id_1.2~3"), "plain-id_1.2~3");
639        // An index expression is passed in the same position and needs these.
640        assert_eq!(encode("logs-*,metrics-*"), "logs-*,metrics-*");
641    }
642
643    #[tokio::test]
644    async fn sends_the_authorization_header_basic_auth_builds() {
645        let client = SearchClient::new("http://localhost:9200")
646            .basic_auth("elastic", "changeme")
647            .faking(Fake::new().on("localhost:9200", FakeResponse::json(json(r#"{"status":"green"}"#))));
648
649        client.health().await.unwrap();
650
651        let sent = client.fake().unwrap().recorded();
652        assert_eq!(sent[0].headers.get("authorization"), Some("Basic ZWxhc3RpYzpjaGFuZ2VtZQ=="));
653    }
654
655    #[tokio::test]
656    async fn an_api_key_is_passed_through_rather_than_encoded_again() {
657        // The `encoded` field of a created API key is already base64. Encoding
658        // it twice yields a 401 that blames the key.
659        let client = SearchClient::new("http://localhost:9200")
660            .api_key("VnVhQ2ZHY0JDZGJrUW0tZTVhT3g6dWkybHAyYXhUTm1zeWFrdzl0dk5udw==")
661            .faking(Fake::new().fallback(FakeResponse::json(json(r#"{"status":"green"}"#))));
662
663        client.health().await.unwrap();
664
665        assert_eq!(
666            client.fake().unwrap().recorded()[0].headers.get("authorization"),
667            Some("ApiKey VnVhQ2ZHY0JDZGJrUW0tZTVhT3g6dWkybHAyYXhUTm1zeWFrdzl0dk5udw==")
668        );
669    }
670
671    #[tokio::test]
672    async fn no_credentials_means_no_header_at_all() {
673        // An empty `Authorization` is not the same as none, and a cluster with
674        // security off treats one as a malformed credential.
675        let client = SearchClient::new("http://localhost:9200")
676            .faking(Fake::new().fallback(FakeResponse::json(json(r#"{"status":"green"}"#))));
677
678        client.health().await.unwrap();
679
680        assert_eq!(client.fake().unwrap().recorded()[0].headers.get("authorization"), None);
681    }
682
683    #[tokio::test]
684    async fn reads_the_health_a_single_node_cluster_actually_reports() {
685        // Copied from a running 8.15 with one index at default settings. Note
686        // the yellow: one node can never assign a replica.
687        let body = r#"{"cluster_name":"docker-cluster","status":"yellow","timed_out":false,"number_of_nodes":1,"number_of_data_nodes":1,"active_primary_shards":1,"active_shards":1,"relocating_shards":0,"initializing_shards":0,"unassigned_shards":1,"delayed_unassigned_shards":0,"number_of_pending_tasks":0,"number_of_in_flight_fetch":0,"task_max_waiting_in_queue_millis":0,"active_shards_percent_as_number":50.0}"#;
688        let client = SearchClient::new("http://localhost:9200")
689            .faking(Fake::new().on("_cluster/health", FakeResponse::json(json(body))));
690
691        let health = client.health().await.unwrap();
692
693        assert_eq!(health.status, HealthStatus::Yellow);
694        assert_eq!(health.cluster_name, "docker-cluster");
695        assert_eq!(health.unassigned_shards, 1);
696        assert!(health.status.is_usable(), "yellow is the normal state of a one-node cluster");
697        assert!(!HealthStatus::Red.is_usable());
698    }
699
700    #[tokio::test]
701    async fn tells_elasticsearch_and_opensearch_apart_without_preferring_either() {
702        let elastic = r#"{"name":"e9f1c933ed49","cluster_name":"docker-cluster","cluster_uuid":"n3-0v9hLR4mBTvOqPRVFmA","version":{"number":"8.15.0","build_flavor":"default","build_type":"docker","build_hash":"1a77947f34deddb41af25e6f0ddb8e830159c179","build_date":"2024-08-05T10:05:34.233336849Z","build_snapshot":false,"lucene_version":"9.11.1","minimum_wire_compatibility_version":"7.17.0","minimum_index_compatibility_version":"7.0.0"},"tagline":"You Know, for Search"}"#;
703        let opensearch = r#"{"name":"opensearch-node1","cluster_name":"docker-cluster","cluster_uuid":"C1v1zpcYRW6cLtLBEIyOZg","version":{"distribution":"opensearch","number":"2.11.0","build_type":"tar","build_hash":"4dcad6dd1fd45b6bd91f041a041829c8687278fa","build_date":"2023-10-13T02:57:04.940191475Z","build_snapshot":false,"lucene_version":"9.7.0","minimum_wire_compatibility_version":"7.10.0","minimum_index_compatibility_version":"7.0.0"},"tagline":"The OpenSearch Project: https://opensearch.org/"}"#;
704
705        for (body, distribution, version) in [
706            (elastic, "elasticsearch", "8.15.0"),
707            (opensearch, "opensearch", "2.11.0"),
708        ] {
709            let client = SearchClient::new("http://localhost:9200")
710                .faking(Fake::new().fallback(FakeResponse::json(json(body))));
711
712            let info = client.info().await.unwrap();
713            assert_eq!(info.distribution, distribution);
714            assert_eq!(info.version, version);
715            assert_eq!(info.is_opensearch(), distribution == "opensearch");
716        }
717    }
718
719    #[tokio::test]
720    async fn a_rejected_request_is_retried_and_a_mapping_error_is_not() {
721        let rejected = r#"{"error":{"root_cause":[{"type":"circuit_breaking_exception","reason":"[parent] Data too large","bytes_wanted":1,"bytes_limit":0,"durability":"TRANSIENT"}],"type":"circuit_breaking_exception","reason":"[parent] Data too large","bytes_wanted":1,"bytes_limit":0,"durability":"TRANSIENT"},"status":429}"#;
722        let broken = r#"{"error":{"root_cause":[{"type":"parsing_exception","reason":"unknown query [matchh]","line":1,"col":30}],"type":"parsing_exception","reason":"unknown query [matchh]","line":1,"col":30},"status":400}"#;
723
724        let client = SearchClient::new("http://localhost:9200")
725            .retries(2)
726            .faking(Fake::new().fallback(FakeResponse::text(rejected).status(429)));
727        assert!(client.search("posts", &Search::new()).await.is_err());
728        assert_eq!(client.fake().unwrap().count(), 3, "the first attempt plus two retries");
729
730        let client = SearchClient::new("http://localhost:9200")
731            .retries(5)
732            .faking(Fake::new().fallback(FakeResponse::text(broken).status(400)));
733        assert!(client.search("posts", &Search::new()).await.is_err());
734        assert_eq!(client.fake().unwrap().count(), 1, "a broken query cannot be fixed by repeating it");
735    }
736
737    #[tokio::test]
738    async fn a_search_against_a_missing_index_says_so_by_name() {
739        let body = r#"{"error":{"root_cause":[{"type":"index_not_found_exception","reason":"no such index [pots]","resource.type":"index_or_alias","resource.id":"pots","index_uuid":"_na_","index":"pots"}],"type":"index_not_found_exception","reason":"no such index [pots]","resource.type":"index_or_alias","resource.id":"pots","index_uuid":"_na_","index":"pots"},"status":404}"#;
740        let client = SearchClient::new("http://localhost:9200")
741            .faking(Fake::new().fallback(FakeResponse::text(body).status(404)));
742
743        let error = client.search("pots", &Search::new()).await.unwrap_err();
744
745        assert!(error.is_index_not_found());
746        assert!(error.to_string().contains("pots"), "got {error}");
747        assert!(!error.is_retryable(), "the index will not appear by itself");
748    }
749
750    #[tokio::test]
751    async fn counting_does_not_send_the_fields_count_refuses() {
752        // `_count` answers 400 for `from`, `size`, `sort` and `aggs`, so the
753        // count body is built from the query alone.
754        let client = SearchClient::new("http://localhost:9200")
755            .faking(Fake::new().fallback(FakeResponse::json(json(r#"{"count":42,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0}}"#))));
756
757        let search = Search::new().size(10).from(20).sort_desc("created_at");
758        assert_eq!(client.count("posts", &search).await.unwrap(), 42);
759
760        let sent = client.fake().unwrap().recorded()[0].json().unwrap();
761        assert!(sent.get("size").is_none(), "size would make _count answer 400");
762        assert!(sent.get("sort").is_none(), "sort would make _count answer 400");
763        assert!(sent.get("query").is_some());
764    }
765}