use camel_component_api::CamelError;
use camel_component_api::NetworkRetryPolicy;
use camel_component_api::parse_uri;
use std::convert::TryFrom;
use std::fmt;
use std::str::FromStr;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
#[serde(try_from = "String")]
pub enum OpenSearchOperation {
INDEX,
SEARCH,
GET,
DELETE,
EXISTS,
UPDATE,
BULK,
MULTIGET,
DELETEINDEX,
MULTISEARCH,
PING,
UNKNOWN(String),
}
impl FromStr for OpenSearchOperation {
type Err = CamelError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_uppercase().as_str() {
"INDEX" => Ok(OpenSearchOperation::INDEX),
"SEARCH" => Ok(OpenSearchOperation::SEARCH),
"GET" => Ok(OpenSearchOperation::GET),
"DELETE" => Ok(OpenSearchOperation::DELETE),
"EXISTS" => Ok(OpenSearchOperation::EXISTS),
"UPDATE" => Ok(OpenSearchOperation::UPDATE),
"BULK" => Ok(OpenSearchOperation::BULK),
"MULTIGET" => Ok(OpenSearchOperation::MULTIGET),
"DELETE_INDEX" => Ok(OpenSearchOperation::DELETEINDEX),
"MULTI_SEARCH" => Ok(OpenSearchOperation::MULTISEARCH),
"PING" => Ok(OpenSearchOperation::PING),
other => Err(CamelError::Config(format!(
"unknown OpenSearch operation: {}",
other
))),
}
}
}
impl fmt::Display for OpenSearchOperation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OpenSearchOperation::INDEX => write!(f, "INDEX"),
OpenSearchOperation::SEARCH => write!(f, "SEARCH"),
OpenSearchOperation::GET => write!(f, "GET"),
OpenSearchOperation::DELETE => write!(f, "DELETE"),
OpenSearchOperation::EXISTS => write!(f, "EXISTS"),
OpenSearchOperation::UPDATE => write!(f, "UPDATE"),
OpenSearchOperation::BULK => write!(f, "BULK"),
OpenSearchOperation::MULTIGET => write!(f, "MULTIGET"),
OpenSearchOperation::DELETEINDEX => write!(f, "DELETE_INDEX"),
OpenSearchOperation::MULTISEARCH => write!(f, "MULTI_SEARCH"),
OpenSearchOperation::PING => write!(f, "PING"),
OpenSearchOperation::UNKNOWN(s) => write!(f, "{}", s),
}
}
}
impl TryFrom<String> for OpenSearchOperation {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
OpenSearchOperation::from_str(&s).map_err(|e| e.to_string())
}
}
#[derive(Clone, serde::Deserialize)]
pub struct OpenSearchConfig {
#[serde(default = "OpenSearchConfig::default_host")]
pub host: String,
#[serde(default = "OpenSearchConfig::default_port")]
pub port: u16,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password: Option<String>,
#[serde(default)]
pub default_operation: Option<OpenSearchOperation>,
#[serde(default)]
pub index_name: Option<String>,
#[serde(default)]
pub timeout_ms: Option<u64>,
#[serde(default)]
pub max_bulk_bytes: Option<usize>,
#[serde(default)]
pub size: Option<u32>,
#[serde(default)]
pub from: Option<u32>,
#[serde(default)]
pub retry: NetworkRetryPolicy,
}
impl OpenSearchConfig {
fn default_host() -> String {
"localhost".to_string()
}
fn default_port() -> u16 {
9200
}
pub fn with_host(mut self, v: impl Into<String>) -> Self {
self.host = v.into();
self
}
pub fn with_port(mut self, v: u16) -> Self {
self.port = v;
self
}
pub fn with_default_operation(mut self, v: OpenSearchOperation) -> Self {
self.default_operation = Some(v);
self
}
pub fn with_username(mut self, v: impl Into<String>) -> Self {
self.username = Some(v.into());
self
}
pub fn with_password(mut self, v: impl Into<String>) -> Self {
self.password = Some(v.into());
self
}
pub fn with_index_name(mut self, v: impl Into<String>) -> Self {
self.index_name = Some(v.into());
self
}
pub fn with_timeout_ms(mut self, v: u64) -> Self {
self.timeout_ms = Some(v);
self
}
pub fn with_max_bulk_bytes(mut self, v: usize) -> Self {
self.max_bulk_bytes = Some(v);
self
}
pub fn with_size(mut self, v: u32) -> Self {
self.size = Some(v);
self
}
pub fn with_from(mut self, v: u32) -> Self {
self.from = Some(v);
self
}
pub fn with_retry(mut self, v: NetworkRetryPolicy) -> Self {
self.retry = v;
self
}
}
impl Default for OpenSearchConfig {
fn default() -> Self {
Self {
host: Self::default_host(),
port: Self::default_port(),
username: None,
password: None,
default_operation: None,
index_name: None,
timeout_ms: None,
max_bulk_bytes: None,
size: None,
from: None,
retry: NetworkRetryPolicy::default(),
}
}
}
impl fmt::Debug for OpenSearchConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OpenSearchConfig")
.field("host", &self.host)
.field("port", &self.port)
.field("username", &self.username)
.field("password", &self.password.as_ref().map(|_| "<redacted>"))
.field("default_operation", &self.default_operation)
.field("index_name", &self.index_name)
.field("timeout_ms", &self.timeout_ms)
.field("max_bulk_bytes", &self.max_bulk_bytes)
.field("size", &self.size)
.field("from", &self.from)
.field("retry", &self.retry)
.finish()
}
}
#[derive(Clone)]
#[non_exhaustive]
pub struct OpenSearchEndpointConfig {
pub host: Option<String>,
pub port: Option<u16>,
pub username: Option<String>,
pub password: Option<String>,
pub index_name: String,
pub operation: OpenSearchOperation,
pub is_tls: bool,
pub timeout_ms: Option<u64>,
pub max_bulk_bytes: Option<usize>,
pub size: Option<u32>,
pub from: Option<u32>,
pub retry: NetworkRetryPolicy,
retry_set_from_uri: bool,
}
impl fmt::Debug for OpenSearchEndpointConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OpenSearchEndpointConfig")
.field("host", &self.host)
.field("port", &self.port)
.field("username", &self.username)
.field("password", &self.password.as_ref().map(|_| "<redacted>"))
.field("index_name", &self.index_name)
.field("operation", &self.operation)
.field("is_tls", &self.is_tls)
.field("timeout_ms", &self.timeout_ms)
.field("max_bulk_bytes", &self.max_bulk_bytes)
.field("size", &self.size)
.field("from", &self.from)
.field("retry", &self.retry)
.finish()
}
}
impl OpenSearchEndpointConfig {
pub fn from_uri(uri: &str) -> Result<Self, CamelError> {
let parts = parse_uri(uri)?;
let is_tls = parts.scheme == "opensearchs";
if parts.scheme != "opensearch" && parts.scheme != "opensearchs" {
return Err(CamelError::InvalidUri(format!(
"expected scheme 'opensearch' or 'opensearchs', got '{}'",
parts.scheme
)));
}
let (host, port, path_remainder) = if parts.path.starts_with("//") {
let path = &parts.path[2..]; if path.is_empty() {
(None, None, "")
} else {
let (authority, remainder) = match path.split_once('/') {
Some((auth, rem)) => (auth, rem),
None => (path, ""),
};
let (host_part, port_part) = match authority.split_once(':') {
Some((h, p)) => (h, Some(p)),
None => (authority, None),
};
let host = if host_part.is_empty() {
None
} else {
Some(host_part.to_string())
};
let port = port_part.and_then(|p| p.parse().ok());
(host, port, remainder)
}
} else {
(None, None, parts.path.as_str())
};
let index_name = path_remainder
.split('/')
.find(|s| !s.is_empty())
.ok_or_else(|| CamelError::InvalidUri("missing index name in URI path".to_string()))?
.to_string();
if index_name.contains('\0') {
return Err(CamelError::InvalidUri(
"index name must not contain null bytes".into(),
));
}
if index_name.contains("..") {
return Err(CamelError::InvalidUri(
"index name must not contain '..'".into(),
));
}
if !index_name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
{
return Err(CamelError::InvalidUri(
"index name must contain only lowercase letters, digits, hyphens, and underscores"
.into(),
));
}
if index_name.len() > 255 {
return Err(CamelError::InvalidUri(
"index name must be at most 255 bytes".into(),
));
}
let operation = parts
.params
.get("operation")
.map(|s| OpenSearchOperation::from_str(s))
.transpose()?
.unwrap_or(OpenSearchOperation::SEARCH);
let username = parts.params.get("username").cloned();
let password = parts.params.get("password").cloned();
let timeout_ms = parts
.params
.get("timeout_ms")
.map(|v| {
v.parse::<u64>().map_err(|_| {
CamelError::InvalidUri("timeout_ms must be a valid u64".to_string())
})
})
.transpose()?;
let max_bulk_bytes = parts
.params
.get("max_bulk_bytes")
.map(|v| {
v.parse::<usize>().map_err(|_| {
CamelError::InvalidUri("max_bulk_bytes must be a valid usize".to_string())
})
})
.transpose()?;
let size = parts
.params
.get("size")
.map(|v| {
v.parse::<u32>()
.map_err(|_| CamelError::InvalidUri("size must be a valid u32".to_string()))
})
.transpose()?;
let from = parts
.params
.get("from")
.map(|v| {
v.parse::<u32>()
.map_err(|_| CamelError::InvalidUri("from must be a valid u32".to_string()))
})
.transpose()?;
let mut retry = NetworkRetryPolicy::default();
let mut retry_set_from_uri = false;
if let Some(raw) = parts.params.get("retryEnabled") {
retry.enabled = raw.parse::<bool>().map_err(|_| {
CamelError::InvalidUri(format!("retryEnabled must be a boolean, got '{raw}'"))
})?;
retry_set_from_uri = true;
}
if let Some(raw) = parts.params.get("retryMaxAttempts") {
retry.max_attempts = raw.parse::<u32>().map_err(|_| {
CamelError::InvalidUri(format!("retryMaxAttempts must be a u32, got '{raw}'"))
})?;
retry_set_from_uri = true;
}
if let Some(raw) = parts.params.get("retryInitialDelayMs") {
retry.initial_delay = Duration::from_millis(raw.parse::<u64>().map_err(|_| {
CamelError::InvalidUri(format!("retryInitialDelayMs must be a u64, got '{raw}'"))
})?);
retry_set_from_uri = true;
}
if let Some(raw) = parts.params.get("retryMultiplier") {
retry.multiplier = raw.parse::<f64>().map_err(|_| {
CamelError::InvalidUri(format!("retryMultiplier must be a f64, got '{raw}'"))
})?;
retry_set_from_uri = true;
}
if let Some(raw) = parts.params.get("retryMaxDelayMs") {
retry.max_delay = Duration::from_millis(raw.parse::<u64>().map_err(|_| {
CamelError::InvalidUri(format!("retryMaxDelayMs must be a u64, got '{raw}'"))
})?);
retry_set_from_uri = true;
}
if let Some(raw) = parts.params.get("retryJitter") {
retry.jitter_factor = raw.parse::<f64>().map_err(|_| {
CamelError::InvalidUri(format!("retryJitter must be a f64, got '{raw}'"))
})?;
retry_set_from_uri = true;
}
Ok(Self {
host,
port,
username,
password,
index_name,
operation,
is_tls,
timeout_ms,
max_bulk_bytes,
size,
from,
retry,
retry_set_from_uri,
})
}
pub fn merge_with_global(&self, global: &OpenSearchConfig) -> Self {
let operation = match &self.operation {
OpenSearchOperation::UNKNOWN(_) => global
.default_operation
.clone()
.unwrap_or(OpenSearchOperation::SEARCH),
op => op.clone(),
};
Self {
host: self.host.clone().or_else(|| Some(global.host.clone())),
port: self.port.or(Some(global.port)),
username: self.username.clone().or_else(|| global.username.clone()),
password: self.password.clone().or_else(|| global.password.clone()),
index_name: if self.index_name.is_empty() {
global.index_name.clone().unwrap_or_default()
} else {
self.index_name.clone()
},
operation,
is_tls: self.is_tls,
timeout_ms: self.timeout_ms.or(global.timeout_ms),
max_bulk_bytes: self.max_bulk_bytes.or(global.max_bulk_bytes),
size: self.size.or(global.size),
from: self.from.or(global.from),
retry: if self.retry_set_from_uri {
self.retry.clone()
} else {
global.retry.clone()
},
retry_set_from_uri: self.retry_set_from_uri,
}
}
pub fn with_host(mut self, v: impl Into<String>) -> Self {
self.host = Some(v.into());
self
}
pub fn with_port(mut self, v: u16) -> Self {
self.port = Some(v);
self
}
pub fn with_username(mut self, v: impl Into<String>) -> Self {
self.username = Some(v.into());
self
}
pub fn with_password(mut self, v: impl Into<String>) -> Self {
self.password = Some(v.into());
self
}
pub fn with_operation(mut self, v: OpenSearchOperation) -> Self {
self.operation = v;
self
}
pub fn with_timeout_ms(mut self, v: u64) -> Self {
self.timeout_ms = Some(v);
self
}
pub fn with_max_bulk_bytes(mut self, v: usize) -> Self {
self.max_bulk_bytes = Some(v);
self
}
pub fn with_size(mut self, v: u32) -> Self {
self.size = Some(v);
self
}
pub fn with_from(mut self, v: u32) -> Self {
self.from = Some(v);
self
}
pub fn base_url(&self) -> String {
let scheme = if self.is_tls { "https" } else { "http" };
let host = self.host.as_deref().unwrap_or("localhost");
let port = self.port.unwrap_or(9200);
format!("{}://{}:{}", scheme, host, port)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_operation_from_str() {
assert_eq!(
OpenSearchOperation::from_str("INDEX").unwrap(),
OpenSearchOperation::INDEX
);
assert_eq!(
OpenSearchOperation::from_str("SEARCH").unwrap(),
OpenSearchOperation::SEARCH
);
assert_eq!(
OpenSearchOperation::from_str("GET").unwrap(),
OpenSearchOperation::GET
);
assert_eq!(
OpenSearchOperation::from_str("DELETE").unwrap(),
OpenSearchOperation::DELETE
);
assert_eq!(
OpenSearchOperation::from_str("EXISTS").unwrap(),
OpenSearchOperation::EXISTS
);
assert_eq!(
OpenSearchOperation::from_str("UPDATE").unwrap(),
OpenSearchOperation::UPDATE
);
assert_eq!(
OpenSearchOperation::from_str("BULK").unwrap(),
OpenSearchOperation::BULK
);
assert_eq!(
OpenSearchOperation::from_str("MULTIGET").unwrap(),
OpenSearchOperation::MULTIGET
);
assert_eq!(
OpenSearchOperation::from_str("DELETE_INDEX").unwrap(),
OpenSearchOperation::DELETEINDEX
);
assert_eq!(
OpenSearchOperation::from_str("MULTI_SEARCH").unwrap(),
OpenSearchOperation::MULTISEARCH
);
assert_eq!(
OpenSearchOperation::from_str("PING").unwrap(),
OpenSearchOperation::PING
);
assert_eq!(
OpenSearchOperation::from_str("index").unwrap(),
OpenSearchOperation::INDEX
);
assert_eq!(
OpenSearchOperation::from_str("Search").unwrap(),
OpenSearchOperation::SEARCH
);
let err = OpenSearchOperation::from_str("CUSTOM_OP").unwrap_err();
assert!(
err.to_string().contains("unknown OpenSearch operation"),
"expected unknown operation error, got: {}",
err
);
}
#[test]
fn test_operation_display() {
assert_eq!(OpenSearchOperation::INDEX.to_string(), "INDEX");
assert_eq!(OpenSearchOperation::SEARCH.to_string(), "SEARCH");
assert_eq!(OpenSearchOperation::GET.to_string(), "GET");
assert_eq!(OpenSearchOperation::DELETE.to_string(), "DELETE");
assert_eq!(OpenSearchOperation::EXISTS.to_string(), "EXISTS");
assert_eq!(OpenSearchOperation::UPDATE.to_string(), "UPDATE");
assert_eq!(OpenSearchOperation::BULK.to_string(), "BULK");
assert_eq!(OpenSearchOperation::MULTIGET.to_string(), "MULTIGET");
assert_eq!(OpenSearchOperation::DELETEINDEX.to_string(), "DELETE_INDEX");
assert_eq!(OpenSearchOperation::MULTISEARCH.to_string(), "MULTI_SEARCH");
assert_eq!(OpenSearchOperation::PING.to_string(), "PING");
assert_eq!(
OpenSearchOperation::UNKNOWN("CUSTOM".to_string()).to_string(),
"CUSTOM"
);
for s in &[
"INDEX",
"SEARCH",
"GET",
"DELETE",
"EXISTS",
"UPDATE",
"BULK",
"MULTIGET",
"DELETE_INDEX",
"MULTI_SEARCH",
"PING",
] {
let op = OpenSearchOperation::from_str(s).unwrap();
assert_eq!(op.to_string(), *s);
}
}
#[test]
fn test_endpoint_config_basic() {
let cfg = OpenSearchEndpointConfig::from_uri(
"opensearch://localhost:9200/myindex?operation=INDEX",
)
.unwrap();
assert_eq!(cfg.host, Some("localhost".to_string()));
assert_eq!(cfg.port, Some(9200));
assert_eq!(cfg.index_name, "myindex");
assert_eq!(cfg.operation, OpenSearchOperation::INDEX);
assert!(!cfg.is_tls);
}
#[test]
fn test_endpoint_config_https() {
let cfg = OpenSearchEndpointConfig::from_uri("opensearchs://host:443/idx?operation=SEARCH")
.unwrap();
assert_eq!(cfg.host, Some("host".to_string()));
assert_eq!(cfg.port, Some(443));
assert_eq!(cfg.index_name, "idx");
assert_eq!(cfg.operation, OpenSearchOperation::SEARCH);
assert!(cfg.is_tls);
}
#[test]
fn test_endpoint_config_defaults() {
let cfg =
OpenSearchEndpointConfig::from_uri("opensearch://localhost:9200/myindex").unwrap();
assert_eq!(cfg.operation, OpenSearchOperation::SEARCH);
}
#[test]
fn test_endpoint_config_with_auth() {
let cfg = OpenSearchEndpointConfig::from_uri(
"opensearch://localhost:9200/myindex?operation=GET&username=admin&password=secret",
)
.unwrap();
assert_eq!(cfg.username, Some("admin".to_string()));
assert_eq!(cfg.password, Some("secret".to_string()));
assert_eq!(cfg.operation, OpenSearchOperation::GET);
}
#[test]
fn test_endpoint_config_with_search_pagination() {
let cfg = OpenSearchEndpointConfig::from_uri(
"opensearch://localhost:9200/myindex?operation=SEARCH&size=25&from=100",
)
.unwrap();
assert_eq!(cfg.size, Some(25));
assert_eq!(cfg.from, Some(100));
}
#[test]
fn test_endpoint_config_host_only_no_port() {
let cfg =
OpenSearchEndpointConfig::from_uri("opensearch://localhost/myindex?operation=GET")
.unwrap();
assert_eq!(cfg.host, Some("localhost".to_string()));
assert_eq!(cfg.port, None);
assert_eq!(cfg.operation, OpenSearchOperation::GET);
}
#[test]
fn test_endpoint_config_wrong_scheme() {
let result = OpenSearchEndpointConfig::from_uri("http://localhost:9200/myindex");
assert!(result.is_err());
}
#[test]
fn test_endpoint_config_missing_index() {
let result = OpenSearchEndpointConfig::from_uri("opensearch://localhost:9200");
assert!(result.is_err());
}
#[test]
fn test_merge_with_global() {
let ep = OpenSearchEndpointConfig::from_uri("opensearch://localhost/myindex?operation=GET")
.unwrap();
assert_eq!(ep.host, Some("localhost".to_string()));
assert_eq!(ep.port, None);
assert_eq!(ep.username, None);
assert_eq!(ep.password, None);
let global = OpenSearchConfig::default()
.with_port(9200)
.with_host("global-host")
.with_default_operation(OpenSearchOperation::SEARCH);
let merged = ep.merge_with_global(&global);
assert_eq!(merged.host, Some("localhost".to_string()));
assert_eq!(merged.port, Some(9200));
assert_eq!(merged.username, None);
assert_eq!(merged.password, None);
assert_eq!(merged.operation, OpenSearchOperation::GET);
}
#[test]
fn test_merge_with_global_fills_all_nones() {
let ep =
OpenSearchEndpointConfig::from_uri("opensearch:///myindex?operation=SEARCH").unwrap();
assert_eq!(ep.host, None);
assert_eq!(ep.port, None);
assert_eq!(ep.index_name, "myindex");
let global = OpenSearchConfig::default()
.with_host("es-server")
.with_port(9300)
.with_username("admin")
.with_password("secret");
let merged = ep.merge_with_global(&global);
assert_eq!(merged.host, Some("es-server".to_string()));
assert_eq!(merged.port, Some(9300));
assert_eq!(merged.username, Some("admin".to_string()));
assert_eq!(merged.password, Some("secret".to_string()));
}
#[test]
fn test_merge_with_global_default_operation_fallback() {
let ep = OpenSearchEndpointConfig {
host: Some("localhost".to_string()),
port: Some(9200),
username: None,
password: None,
index_name: "myindex".to_string(),
operation: OpenSearchOperation::UNKNOWN("UNKNOWN_OP".to_string()),
is_tls: false,
timeout_ms: None,
max_bulk_bytes: None,
size: None,
from: None,
retry: NetworkRetryPolicy::default(),
retry_set_from_uri: false,
};
let global = OpenSearchConfig::default().with_default_operation(OpenSearchOperation::INDEX);
let merged = ep.merge_with_global(&global);
assert_eq!(merged.operation, OpenSearchOperation::INDEX);
}
#[test]
fn test_base_url_http() {
let cfg = OpenSearchEndpointConfig::from_uri(
"opensearch://es-host:9200/myindex?operation=SEARCH",
)
.unwrap();
assert_eq!(cfg.base_url(), "http://es-host:9200");
}
#[test]
fn test_base_url_https() {
let cfg = OpenSearchEndpointConfig::from_uri(
"opensearchs://es-host:443/myindex?operation=SEARCH",
)
.unwrap();
assert_eq!(cfg.base_url(), "https://es-host:443");
}
#[test]
fn test_base_url_defaults() {
let cfg =
OpenSearchEndpointConfig::from_uri("opensearch:///myindex?operation=SEARCH").unwrap();
assert_eq!(cfg.host, None);
assert_eq!(cfg.port, None);
assert_eq!(cfg.base_url(), "http://localhost:9200");
}
#[test]
fn test_config_defaults() {
let cfg = OpenSearchConfig::default();
assert_eq!(cfg.host, "localhost");
assert_eq!(cfg.port, 9200);
assert!(cfg.username.is_none());
assert!(cfg.password.is_none());
assert!(cfg.default_operation.is_none());
assert!(cfg.index_name.is_none());
assert!(cfg.timeout_ms.is_none());
assert!(cfg.max_bulk_bytes.is_none());
assert!(cfg.size.is_none());
assert!(cfg.from.is_none());
}
#[test]
fn test_config_builder() {
let cfg = OpenSearchConfig::default()
.with_host("es-prod")
.with_port(9200)
.with_default_operation(OpenSearchOperation::BULK)
.with_username("admin")
.with_password("secret")
.with_index_name("idx")
.with_timeout_ms(5000)
.with_max_bulk_bytes(32_000)
.with_size(10)
.with_from(20);
assert_eq!(cfg.host, "es-prod");
assert_eq!(cfg.port, 9200);
assert_eq!(cfg.default_operation, Some(OpenSearchOperation::BULK));
assert_eq!(cfg.username, Some("admin".to_string()));
assert_eq!(cfg.password, Some("secret".to_string()));
assert_eq!(cfg.index_name, Some("idx".to_string()));
assert_eq!(cfg.timeout_ms, Some(5000));
assert_eq!(cfg.max_bulk_bytes, Some(32_000));
assert_eq!(cfg.size, Some(10));
assert_eq!(cfg.from, Some(20));
}
#[test]
fn test_opensearch_config_debug_redacts_password() {
let cfg = OpenSearchConfig::default()
.with_host("es-prod")
.with_password("hunter2");
let debug_output = format!("{:?}", cfg);
assert!(
!debug_output.contains("hunter2"),
"debug output must not contain the real password: {}",
debug_output
);
assert!(
debug_output.contains("<redacted>"),
"debug output must contain <redacted>: {}",
debug_output
);
}
#[test]
fn test_opensearch_endpoint_config_debug_redacts_password() {
let cfg = OpenSearchEndpointConfig::from_uri(
"opensearch://localhost:9200/myindex?operation=GET&username=admin&password=hunter2",
)
.unwrap();
let debug_output = format!("{:?}", cfg);
assert!(
!debug_output.contains("hunter2"),
"debug output must not contain the real password: {}",
debug_output
);
assert!(
debug_output.contains("<redacted>"),
"debug output must contain <redacted>: {}",
debug_output
);
}
#[test]
fn test_opensearch_config_debug_no_password_shows_none() {
let cfg = OpenSearchConfig::default();
let debug_output = format!("{:?}", cfg);
assert!(
!debug_output.contains("<redacted>"),
"debug output must not contain <redacted> when password is None: {}",
debug_output
);
}
#[test]
fn test_opensearch_endpoint_config_debug_no_password_shows_none() {
let cfg =
OpenSearchEndpointConfig::from_uri("opensearch://localhost:9200/myindex?operation=GET")
.unwrap();
let debug_output = format!("{:?}", cfg);
assert!(
!debug_output.contains("<redacted>"),
"debug output must not contain <redacted> when password is None: {}",
debug_output
);
}
#[test]
fn test_index_name_null_bytes_rejected() {
let result = OpenSearchEndpointConfig::from_uri(
"opensearch://localhost:9200/my%00index?operation=SEARCH",
);
assert!(result.is_err());
}
#[test]
fn test_index_name_dotdot_rejected() {
let result = OpenSearchEndpointConfig::from_uri(
"opensearch://localhost:9200/my..index?operation=SEARCH",
);
assert!(result.is_err());
}
#[test]
fn test_index_name_uppercase_rejected() {
let result = OpenSearchEndpointConfig::from_uri(
"opensearch://localhost:9200/MyIndex?operation=SEARCH",
);
assert!(result.is_err());
}
#[test]
fn test_index_name_special_chars_rejected() {
let result = OpenSearchEndpointConfig::from_uri(
"opensearch://localhost:9200/my@index?operation=SEARCH",
);
assert!(result.is_err());
}
#[test]
fn test_index_name_valid_lowercase_with_digits_hyphens_underscores() {
let cfg = OpenSearchEndpointConfig::from_uri(
"opensearch://localhost:9200/my-index_01?operation=SEARCH",
)
.unwrap();
assert_eq!(cfg.index_name, "my-index_01");
}
#[test]
fn test_unknown_operation_rejected_at_config_time() {
let result = OpenSearchEndpointConfig::from_uri(
"opensearch://localhost:9200/myindex?operation=UNKNOWN",
);
assert!(result.is_err(), "expected Err for unknown operation");
let err = result.unwrap_err();
assert!(
err.to_string().contains("unknown OpenSearch operation"),
"expected unknown operation error, got: {}",
err
);
}
#[test]
fn retry_policy_parses_from_uri() {
let cfg = OpenSearchEndpointConfig::from_uri(
"opensearch://localhost:9200/index?retryMaxAttempts=3&retryInitialDelayMs=500",
)
.expect("parse");
assert_eq!(cfg.retry.max_attempts, 3);
assert_eq!(
cfg.retry.initial_delay,
std::time::Duration::from_millis(500)
);
assert!(cfg.retry.enabled);
}
#[test]
fn retry_policy_defaults_when_uri_has_no_retry_params() {
let cfg =
OpenSearchEndpointConfig::from_uri("opensearch://localhost:9200/index").expect("parse");
assert!(cfg.retry.enabled);
}
#[test]
fn retry_policy_overrides_via_global_config() {
let ep =
OpenSearchEndpointConfig::from_uri("opensearch://localhost:9200/index").expect("parse");
let global = OpenSearchConfig::default().with_retry(NetworkRetryPolicy {
max_attempts: 5,
initial_delay: std::time::Duration::from_millis(2000),
..NetworkRetryPolicy::default()
});
let merged = ep.merge_with_global(&global);
assert_eq!(merged.retry.max_attempts, 5);
assert_eq!(
merged.retry.initial_delay,
std::time::Duration::from_millis(2000)
);
}
#[test]
fn retry_policy_parse_full_uri_params() {
let cfg = OpenSearchEndpointConfig::from_uri(
"opensearch://localhost:9200/index?retryEnabled=false&retryMaxAttempts=7&retryInitialDelayMs=1000&retryMultiplier=3.0&retryMaxDelayMs=60000&retryJitter=0.5",
)
.expect("parse");
assert!(!cfg.retry.enabled);
assert_eq!(cfg.retry.max_attempts, 7);
assert_eq!(
cfg.retry.initial_delay,
std::time::Duration::from_millis(1000)
);
assert!((cfg.retry.multiplier - 3.0).abs() < f64::EPSILON);
assert_eq!(cfg.retry.max_delay, std::time::Duration::from_millis(60000));
assert!((cfg.retry.jitter_factor - 0.5).abs() < f64::EPSILON);
}
#[test]
fn global_config_has_retry_default() {
let cfg = OpenSearchConfig::default();
assert!(cfg.retry.enabled);
}
#[test]
fn retry_policy_from_uri_survives_merge_with_global_default() {
let ep = OpenSearchEndpointConfig::from_uri(
"opensearch://localhost:9200/i?retryMaxAttempts=10&retryInitialDelayMs=500",
)
.expect("parse");
let merged = ep.merge_with_global(&OpenSearchConfig::default());
assert_eq!(merged.retry.max_attempts, 10);
assert_eq!(
merged.retry.initial_delay,
std::time::Duration::from_millis(500)
);
}
#[test]
fn retry_policy_falls_back_to_global_when_uri_has_no_retry_params() {
let mut global = OpenSearchConfig::default();
global.retry.max_attempts = 7;
let ep =
OpenSearchEndpointConfig::from_uri("opensearch://localhost:9200/i").expect("parse");
let merged = ep.merge_with_global(&global);
assert_eq!(merged.retry.max_attempts, 7);
}
}