use serde_json::{json, Value};
use crate::{DecodeSnafu, Error, HttpSnafu, IndexNotFoundSnafu, Result};
use snafu::ResultExt;
#[derive(Clone, Debug)]
enum Auth {
None,
Basic { user: String, password: String },
Bearer(String),
}
#[derive(Clone, Debug)]
pub struct OpenSearchClient {
base_url: String,
http: reqwest::Client,
auth: Auth,
}
#[derive(Clone, Debug, Default)]
pub struct SearchPage {
pub hits: Vec<Value>,
pub scroll_id: Option<String>,
}
impl OpenSearchClient {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
http: reqwest::Client::new(),
auth: Auth::None,
}
}
pub fn with_client(mut self, http: reqwest::Client) -> Self {
self.http = http;
self
}
pub fn with_basic_auth(mut self, user: impl Into<String>, password: impl Into<String>) -> Self {
self.auth = Auth::Basic {
user: user.into(),
password: password.into(),
};
self
}
pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
self.auth = Auth::Bearer(token.into());
self
}
pub fn base_url(&self) -> &str {
self.base_url.trim_end_matches('/')
}
fn get(&self, url: &str) -> reqwest::RequestBuilder {
self.authed(self.http.get(url))
}
fn post(&self, url: &str) -> reqwest::RequestBuilder {
self.authed(self.http.post(url))
}
fn delete(&self, url: &str) -> reqwest::RequestBuilder {
self.authed(self.http.delete(url))
}
fn authed(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
match &self.auth {
Auth::None => rb,
Auth::Basic { user, password } => rb.basic_auth(user, Some(password)),
Auth::Bearer(t) => rb.bearer_auth(t),
}
}
pub async fn search(&self, index: &str, body: &Value) -> Result<Vec<Value>> {
Ok(self.search_page(index, body, None).await?.hits)
}
pub async fn search_page(&self, index: &str, body: &Value, keep_alive: Option<&str>) -> Result<SearchPage> {
let url = match keep_alive {
Some(k) => format!("{}/{index}/_search?scroll={k}", self.base_url()),
None => format!("{}/{index}/_search", self.base_url()),
};
let resp = self
.post(&url)
.json(body)
.send()
.await
.context(HttpSnafu { url: url.clone() })?;
if resp.status().as_u16() == 404 {
return Ok(SearchPage::default());
}
let resp = check_status(resp, &url).await?;
let doc: Value = resp.json().await.context(DecodeSnafu { url })?;
Ok(page_from(doc))
}
pub async fn scroll(&self, scroll_id: &str, keep_alive: &str) -> Result<SearchPage> {
let url = format!("{}/_search/scroll", self.base_url());
let resp = self
.post(&url)
.json(&json!({ "scroll": keep_alive, "scroll_id": scroll_id }))
.send()
.await
.context(HttpSnafu { url: url.clone() })?;
let resp = check_status(resp, &url).await?;
let doc: Value = resp.json().await.context(DecodeSnafu { url })?;
Ok(page_from(doc))
}
pub async fn clear_scroll(&self, scroll_id: &str) {
let url = format!("{}/_search/scroll", self.base_url());
let _ = self
.delete(&url)
.json(&json!({ "scroll_id": [scroll_id] }))
.send()
.await;
}
pub async fn mapping(&self, index: &str) -> Result<Value> {
let url = format!("{}/{index}/_mapping", self.base_url());
let resp = self.get(&url).send().await.context(HttpSnafu { url: url.clone() })?;
if resp.status().as_u16() == 404 {
return IndexNotFoundSnafu { index }.fail();
}
let resp = check_status(resp, &url).await?;
resp.json().await.context(DecodeSnafu { url })
}
pub async fn indices(&self) -> Result<Vec<String>> {
let url = format!("{}/_cat/indices?format=json&h=index", self.base_url());
let resp = self.get(&url).send().await.context(HttpSnafu { url: url.clone() })?;
let resp = check_status(resp, &url).await?;
let doc: Value = resp.json().await.context(DecodeSnafu { url })?;
let mut names: Vec<String> = doc
.as_array()
.map(|rows| {
rows.iter()
.filter_map(|r| r["index"].as_str())
.filter(|n| !n.starts_with('.'))
.map(str::to_string)
.collect()
})
.unwrap_or_default();
names.sort();
Ok(names)
}
}
fn page_from(doc: Value) -> SearchPage {
let hits = doc["hits"]["hits"]
.as_array()
.map(|hits| hits.iter().map(|h| h["_source"].clone()).collect())
.unwrap_or_default();
let scroll_id = doc["_scroll_id"].as_str().map(str::to_string);
SearchPage { hits, scroll_id }
}
async fn check_status(resp: reqwest::Response, url: &str) -> Result<reqwest::Response> {
if resp.status().is_success() {
return Ok(resp);
}
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
Err(Error::Status {
status,
url: url.to_string(),
body,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base_url_drops_a_trailing_slash() {
assert_eq!(OpenSearchClient::new("http://x:9200/").base_url(), "http://x:9200");
assert_eq!(OpenSearchClient::new("http://x:9200").base_url(), "http://x:9200");
}
#[test]
fn auth_is_recorded() {
let c = OpenSearchClient::new("http://x").with_basic_auth("admin", "pw");
assert!(matches!(c.auth, Auth::Basic { .. }));
let c = OpenSearchClient::new("http://x").with_bearer_token("t");
assert!(matches!(c.auth, Auth::Bearer(_)));
}
#[test]
fn page_parses_hits_and_scroll_id() {
let p = page_from(json!({ "_scroll_id": "abc", "hits": { "hits": [ { "_source": { "a": 1 } } ] } }));
assert_eq!(p.hits, vec![json!({ "a": 1 })]);
assert_eq!(p.scroll_id.as_deref(), Some("abc"));
let empty = page_from(json!({ "hits": { "hits": [] } }));
assert!(empty.hits.is_empty() && empty.scroll_id.is_none());
}
}