datafusion_opensearch/
client.rs1use serde_json::{json, Value};
10
11use crate::{DecodeSnafu, Error, HttpSnafu, IndexNotFoundSnafu, Result};
12use snafu::ResultExt;
13
14#[derive(Clone, Debug)]
15enum Auth {
16 None,
17 Basic { user: String, password: String },
18 Bearer(String),
19}
20
21#[derive(Clone, Debug)]
23pub struct OpenSearchClient {
24 base_url: String,
25 http: reqwest::Client,
26 auth: Auth,
27}
28
29#[derive(Clone, Debug, Default)]
31pub struct SearchPage {
32 pub hits: Vec<Value>,
34 pub scroll_id: Option<String>,
36}
37
38impl OpenSearchClient {
39 pub fn new(base_url: impl Into<String>) -> Self {
41 Self {
42 base_url: base_url.into(),
43 http: reqwest::Client::new(),
44 auth: Auth::None,
45 }
46 }
47
48 pub fn with_client(mut self, http: reqwest::Client) -> Self {
50 self.http = http;
51 self
52 }
53
54 pub fn with_basic_auth(mut self, user: impl Into<String>, password: impl Into<String>) -> Self {
56 self.auth = Auth::Basic {
57 user: user.into(),
58 password: password.into(),
59 };
60 self
61 }
62
63 pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
65 self.auth = Auth::Bearer(token.into());
66 self
67 }
68
69 pub fn base_url(&self) -> &str {
71 self.base_url.trim_end_matches('/')
72 }
73
74 fn get(&self, url: &str) -> reqwest::RequestBuilder {
75 self.authed(self.http.get(url))
76 }
77
78 fn post(&self, url: &str) -> reqwest::RequestBuilder {
79 self.authed(self.http.post(url))
80 }
81
82 fn delete(&self, url: &str) -> reqwest::RequestBuilder {
83 self.authed(self.http.delete(url))
84 }
85
86 fn authed(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
87 match &self.auth {
88 Auth::None => rb,
89 Auth::Basic { user, password } => rb.basic_auth(user, Some(password)),
90 Auth::Bearer(t) => rb.bearer_auth(t),
91 }
92 }
93
94 pub async fn search(&self, index: &str, body: &Value) -> Result<Vec<Value>> {
98 Ok(self.search_page(index, body, None).await?.hits)
99 }
100
101 pub async fn search_page(&self, index: &str, body: &Value, keep_alive: Option<&str>) -> Result<SearchPage> {
104 let url = match keep_alive {
105 Some(k) => format!("{}/{index}/_search?scroll={k}", self.base_url()),
106 None => format!("{}/{index}/_search", self.base_url()),
107 };
108 let resp = self
109 .post(&url)
110 .json(body)
111 .send()
112 .await
113 .context(HttpSnafu { url: url.clone() })?;
114 if resp.status().as_u16() == 404 {
115 return Ok(SearchPage::default());
116 }
117 let resp = check_status(resp, &url).await?;
118 let doc: Value = resp.json().await.context(DecodeSnafu { url })?;
119 Ok(page_from(doc))
120 }
121
122 pub async fn scroll(&self, scroll_id: &str, keep_alive: &str) -> Result<SearchPage> {
124 let url = format!("{}/_search/scroll", self.base_url());
125 let resp = self
126 .post(&url)
127 .json(&json!({ "scroll": keep_alive, "scroll_id": scroll_id }))
128 .send()
129 .await
130 .context(HttpSnafu { url: url.clone() })?;
131 let resp = check_status(resp, &url).await?;
132 let doc: Value = resp.json().await.context(DecodeSnafu { url })?;
133 Ok(page_from(doc))
134 }
135
136 pub async fn clear_scroll(&self, scroll_id: &str) {
138 let url = format!("{}/_search/scroll", self.base_url());
139 let _ = self
140 .delete(&url)
141 .json(&json!({ "scroll_id": [scroll_id] }))
142 .send()
143 .await;
144 }
145
146 pub async fn mapping(&self, index: &str) -> Result<Value> {
150 let url = format!("{}/{index}/_mapping", self.base_url());
151 let resp = self.get(&url).send().await.context(HttpSnafu { url: url.clone() })?;
152 if resp.status().as_u16() == 404 {
153 return IndexNotFoundSnafu { index }.fail();
154 }
155 let resp = check_status(resp, &url).await?;
156 resp.json().await.context(DecodeSnafu { url })
157 }
158
159 pub async fn indices(&self) -> Result<Vec<String>> {
162 let url = format!("{}/_cat/indices?format=json&h=index", self.base_url());
163 let resp = self.get(&url).send().await.context(HttpSnafu { url: url.clone() })?;
164 let resp = check_status(resp, &url).await?;
165 let doc: Value = resp.json().await.context(DecodeSnafu { url })?;
166 let mut names: Vec<String> = doc
167 .as_array()
168 .map(|rows| {
169 rows.iter()
170 .filter_map(|r| r["index"].as_str())
171 .filter(|n| !n.starts_with('.'))
172 .map(str::to_string)
173 .collect()
174 })
175 .unwrap_or_default();
176 names.sort();
177 Ok(names)
178 }
179}
180
181fn page_from(doc: Value) -> SearchPage {
182 let hits = doc["hits"]["hits"]
183 .as_array()
184 .map(|hits| hits.iter().map(|h| h["_source"].clone()).collect())
185 .unwrap_or_default();
186 let scroll_id = doc["_scroll_id"].as_str().map(str::to_string);
187 SearchPage { hits, scroll_id }
188}
189
190async fn check_status(resp: reqwest::Response, url: &str) -> Result<reqwest::Response> {
192 if resp.status().is_success() {
193 return Ok(resp);
194 }
195 let status = resp.status().as_u16();
196 let body = resp.text().await.unwrap_or_default();
197 Err(Error::Status {
198 status,
199 url: url.to_string(),
200 body,
201 })
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn base_url_drops_a_trailing_slash() {
210 assert_eq!(OpenSearchClient::new("http://x:9200/").base_url(), "http://x:9200");
211 assert_eq!(OpenSearchClient::new("http://x:9200").base_url(), "http://x:9200");
212 }
213
214 #[test]
215 fn auth_is_recorded() {
216 let c = OpenSearchClient::new("http://x").with_basic_auth("admin", "pw");
217 assert!(matches!(c.auth, Auth::Basic { .. }));
218 let c = OpenSearchClient::new("http://x").with_bearer_token("t");
219 assert!(matches!(c.auth, Auth::Bearer(_)));
220 }
221
222 #[test]
223 fn page_parses_hits_and_scroll_id() {
224 let p = page_from(json!({ "_scroll_id": "abc", "hits": { "hits": [ { "_source": { "a": 1 } } ] } }));
225 assert_eq!(p.hits, vec![json!({ "a": 1 })]);
226 assert_eq!(p.scroll_id.as_deref(), Some("abc"));
227 let empty = page_from(json!({ "hits": { "hits": [] } }));
228 assert!(empty.hits.is_empty() && empty.scroll_id.is_none());
229 }
230}