1use 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#[derive(Clone)]
22enum Auth {
23 None,
24 Header(String),
27}
28
29#[derive(Clone)]
34pub struct SearchClient {
35 base_url: String,
36 auth: Auth,
37 http: Client,
38 retries: u32,
39}
40
41impl SearchClient {
42 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 http: Client::new().timeout(Duration::from_secs(30)),
54 retries: 2,
55 }
56 }
57
58 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 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 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 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 pub fn retries(mut self, retries: u32) -> SearchClient {
132 self.retries = retries;
133 self
134 }
135
136 pub fn faking(mut self, fake: rustlavel_client::Fake) -> SearchClient {
138 self.http = self.http.faking(fake);
139 self
140 }
141
142 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 pub fn url(&self, path: &str) -> String {
156 format!("{}/{}", self.base_url, path.trim_start_matches('/'))
157 }
158
159 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 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 distribution: body
180 .get("version.distribution")
181 .and_then(Json::as_str)
182 .unwrap_or("elasticsearch")
183 .to_string(),
184 })
185 }
186
187 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 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 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 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 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 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 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 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 return match failed {
321 Some(raw) => Ok(raw),
322 None => Err(error),
323 };
324 }
325
326 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 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
361impl 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#[derive(Clone)]
380enum Payload {
381 Json(Json),
382 Ndjson(String),
383}
384
385pub(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 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 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#[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
451pub enum HealthStatus {
452 Green,
454 Yellow,
456 Red,
458 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 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#[derive(Debug, Clone, PartialEq, Eq)]
496pub struct ClusterInfo {
497 pub name: String,
498 pub cluster_name: String,
499 pub version: String,
500 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
518pub(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
540fn 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 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 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 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 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 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 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 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 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}