use std::sync::atomic::{AtomicU8, Ordering};
use std::time::Duration;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use super::types::{CacheStatus, QueryRequest, QueryResponse, SearchResult};
use super::upstream::{
read_body_with_timeout, read_json_with_timeout, AdapterMetadata, QueryMode, UpstreamAdapter,
UpstreamError,
};
#[derive(Debug, Clone)]
pub struct ElasticsearchConfig {
pub base_url: String,
pub index: String,
pub timeout: Duration,
pub search_fields: Vec<String>,
pub return_fields: Vec<String>,
pub api_key: Option<String>,
pub score_threshold: Option<f32>,
}
impl Default for ElasticsearchConfig {
fn default() -> Self {
Self {
base_url: "http://localhost:9200".to_string(),
index: "documents".to_string(),
timeout: Duration::from_secs(30),
search_fields: vec!["content".to_string()],
return_fields: Vec::new(),
api_key: None,
score_threshold: None,
}
}
}
pub struct ElasticsearchAdapter {
client: reqwest::Client,
config: ElasticsearchConfig,
query_mode: AtomicU8,
}
impl ElasticsearchAdapter {
pub fn new(config: ElasticsearchConfig) -> Result<Self, reqwest::Error> {
let mut builder = super::socket_tuning::create_tuned_client_default(config.timeout);
if let Some(ref api_key) = config.api_key {
let mut headers = reqwest::header::HeaderMap::new();
let auth_value = format!("ApiKey {}", api_key);
if let Ok(value) = reqwest::header::HeaderValue::from_str(&auth_value) {
headers.insert(reqwest::header::AUTHORIZATION, value);
builder = builder.default_headers(headers);
}
}
let client = builder.build()?;
Ok(Self {
client,
config,
query_mode: AtomicU8::new(QueryMode::TextNative as u8),
})
}
pub fn simple(base_url: &str, index: &str, timeout: Duration) -> Result<Self, reqwest::Error> {
Self::new(ElasticsearchConfig {
base_url: base_url.to_string(),
index: index.to_string(),
timeout,
..Default::default()
})
}
fn search_url(&self) -> String {
format!(
"{}/{}/_search",
self.config.base_url.trim_end_matches('/'),
self.config.index
)
}
fn health_url(&self) -> String {
format!(
"{}/_cluster/health?timeout=5s",
self.config.base_url.trim_end_matches('/')
)
}
fn build_query_body(&self, query: &str, size: usize) -> serde_json::Value {
let mut body = serde_json::json!({
"query": {
"multi_match": {
"query": query,
"fields": self.config.search_fields,
"type": "best_fields"
}
},
"size": size
});
if !self.config.return_fields.is_empty() {
if let Some(obj) = body.as_object_mut() {
obj.insert(
"_source".to_string(),
serde_json::json!(self.config.return_fields),
);
}
}
if let Some(threshold) = self.config.score_threshold {
if let Some(obj) = body.as_object_mut() {
obj.insert("min_score".to_string(), serde_json::json!(threshold));
}
}
body
}
fn normalize_score(score: f32, max_score: f32) -> f32 {
if max_score <= 0.0 {
0.0
} else {
(score / max_score).clamp(0.0, 1.0)
}
}
fn parse_hits(response: &EsSearchResponse) -> Vec<SearchResult> {
let max_score = response.hits.max_score.unwrap_or(0.0);
response
.hits
.hits
.iter()
.map(|hit| {
let content = hit
.source
.as_ref()
.and_then(|src| {
src.get("content")
.or_else(|| src.get("text"))
.or_else(|| src.get("body"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.unwrap_or_default();
let metadata = hit.source.as_ref().map(|src| {
let mut filtered = src.clone();
if let Some(obj) = filtered.as_object_mut() {
obj.remove("content");
obj.remove("text");
obj.remove("body");
}
filtered
});
SearchResult {
id: hit.id.clone(),
score: Self::normalize_score(hit.score.unwrap_or(0.0), max_score),
content,
metadata,
upstream_id: None,
}
})
.collect()
}
}
#[derive(Debug, Serialize)]
#[allow(dead_code)]
struct EsSearchRequest {
query: serde_json::Value,
size: usize,
#[serde(skip_serializing_if = "Option::is_none")]
_source: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
min_score: Option<f32>,
}
#[derive(Debug, Deserialize)]
struct EsSearchResponse {
hits: EsHitsContainer,
}
#[derive(Debug, Deserialize)]
struct EsHitsContainer {
#[allow(dead_code)] total: EsHitsTotal,
max_score: Option<f32>,
hits: Vec<EsHit>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
#[allow(dead_code)]
enum EsHitsTotal {
Object { value: u64 },
Integer(u64),
}
#[derive(Debug, Deserialize)]
struct EsHit {
#[serde(rename = "_id")]
id: String,
#[serde(rename = "_score")]
score: Option<f32>,
#[serde(rename = "_source")]
source: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct EsClusterHealthResponse {
status: String,
#[allow(dead_code)] cluster_name: Option<String>,
}
#[async_trait]
impl UpstreamAdapter for ElasticsearchAdapter {
async fn query(&self, request: &QueryRequest) -> Result<QueryResponse, UpstreamError> {
let start = std::time::Instant::now();
let size = request.top_k.unwrap_or(10);
let query_body = self.build_query_body(&request.query, size);
debug!(
upstream = %self.config.base_url,
index = %self.config.index,
query_len = request.query.len(),
"Sending multi_match query to Elasticsearch"
);
let response = self
.client
.post(self.search_url())
.json(&query_body)
.send()
.await
.map_err(|e| {
if e.is_timeout() {
warn!("Elasticsearch request timed out");
UpstreamError::Timeout
} else {
warn!(error = %e, "Elasticsearch network error");
UpstreamError::Network(e.to_string())
}
})?;
let status = response.status();
if !status.is_success() {
let body = read_body_with_timeout(response, Duration::from_secs(5))
.await
.unwrap_or_default();
warn!(status = %status, "Elasticsearch returned error status");
return Err(UpstreamError::Status(status.as_u16(), body));
}
let es_response: EsSearchResponse =
read_json_with_timeout(response, self.config.timeout).await?;
let results = Self::parse_hits(&es_response);
Ok(QueryResponse {
results,
cache_status: CacheStatus::Miss,
took_ms: start.elapsed().as_millis() as u64,
generated_at: Some(QueryResponse::current_time_ms()),
miss_reason: None,
})
}
async fn health_check(&self) -> Result<bool, UpstreamError> {
debug!(
upstream = %self.config.base_url,
"Checking Elasticsearch cluster health"
);
let response = self
.client
.get(self.health_url())
.send()
.await
.map_err(|e| {
if e.is_timeout() {
UpstreamError::Timeout
} else {
UpstreamError::Network(e.to_string())
}
})?;
if !response.status().is_success() {
return Ok(false);
}
let health: EsClusterHealthResponse =
read_json_with_timeout(response, self.config.timeout).await?;
Ok(health.status == "green" || health.status == "yellow")
}
fn identifier(&self) -> &str {
&self.config.base_url
}
fn timeout(&self) -> Duration {
self.config.timeout
}
fn metadata(&self) -> AdapterMetadata {
let mut properties = std::collections::HashMap::new();
properties.insert("index".to_string(), self.config.index.clone());
properties.insert(
"search_fields".to_string(),
self.config.search_fields.join(", "),
);
AdapterMetadata {
adapter_type: "elasticsearch".to_string(),
version: None,
properties,
}
}
fn query_mode(&self) -> QueryMode {
QueryMode::from(self.query_mode.load(Ordering::Relaxed))
}
fn set_query_mode(&self, mode: QueryMode) {
self.query_mode.store(mode as u8, Ordering::Relaxed);
}
async fn discover_query_mode(&self) -> Result<QueryMode, UpstreamError> {
Ok(QueryMode::TextNative)
}
}
#[cfg(test)]
#[path = "tests/elasticsearch_tests.rs"]
mod tests;