use super::{BackendResponse, SearchBackend, SearchResult};
use crate::config::SearxngConfig;
#[derive(Debug)]
pub struct SearxngBackend {
base_url: String,
client: reqwest::Client,
}
impl SearxngBackend {
pub fn new(config: &SearxngConfig) -> Self {
Self {
base_url: config.url.trim_end_matches('/').to_string(),
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.expect("failed to build HTTP client"),
}
}
}
fn jstr<'a>(val: &'a serde_json::Value, key: &str) -> &'a str {
val.get(key).and_then(serde_json::Value::as_str).unwrap_or("")
}
fn collect_string_msgs(data: &serde_json::Value, key: &str, label: &str) -> Vec<String> {
data.get(key)
.and_then(serde_json::Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(|s| format!("{label}: {s}"))
.collect()
})
.unwrap_or_default()
}
fn collect_tuple_msgs(data: &serde_json::Value, key: &str, label: &str) -> Vec<String> {
data.get(key)
.and_then(serde_json::Value::as_array)
.map(|arr| {
arr.iter()
.map(|entry| match entry {
serde_json::Value::Array(parts) => {
let strs: Vec<&str> =
parts.iter().filter_map(|p| p.as_str()).collect();
match strs.as_slice() {
[name, reason] => format!("{label}: {name}: {reason}"),
[single] => format!("{label}: {single}"),
_ => format!("{label}: {entry}"),
}
}
serde_json::Value::String(s) => format!("{label}: {s}"),
other => format!("{label}: {other}"),
})
.collect()
})
.unwrap_or_default()
}
#[async_trait::async_trait]
impl SearchBackend for SearxngBackend {
async fn search(
&self,
query: &str,
num_results: usize,
lang: Option<&str>,
) -> Result<BackendResponse, crate::WebshiftError> {
let mut params = vec![
("q", query.to_string()),
("format", "json".to_string()),
("pageno", "1".to_string()),
];
if let Some(lang) = lang {
params.push(("language", lang.to_string()));
}
let url = format!("{}/search", self.base_url);
let resp = self
.client
.get(&url)
.query(¶ms)
.send()
.await
.map_err(|e| crate::WebshiftError::Backend(format!("searxng request failed: {e}")))?;
let status = resp.status();
if !status.is_success() {
return Err(crate::WebshiftError::Backend(format!(
"searxng HTTP {status}"
)));
}
let data: serde_json::Value = resp
.json::<serde_json::Value>()
.await
.map_err(|e| crate::WebshiftError::Backend(format!("searxng parse error: {e}")))?;
let empty = vec![];
let items = data
.get("results")
.and_then(serde_json::Value::as_array)
.unwrap_or(&empty);
let mut results = Vec::new();
for item in items {
if results.len() >= num_results {
break;
}
results.push(SearchResult {
title: jstr(item, "title").to_string(),
url: jstr(item, "url").to_string(),
snippet: jstr(item, "content").to_string(),
});
}
let mut warnings = collect_tuple_msgs(&data, "unresponsive_engines", "searxng unresponsive");
warnings.extend(collect_tuple_msgs(&data, "errors", "searxng engine error"));
warnings.extend(collect_string_msgs(&data, "error_msgs", "searxng engine error"));
warnings.extend(collect_string_msgs(&data, "unresponsive_msgs", "searxng unresponsive"));
Ok(BackendResponse { results, warnings })
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn searxng_parses_results() {
let mock_server = MockServer::start().await;
let body = serde_json::json!({
"results": [
{"title": "Rust Lang", "url": "https://rust-lang.org", "content": "Systems programming"},
{"title": "Tokio", "url": "https://tokio.rs", "content": "Async runtime for Rust"},
{"title": "Serde", "url": "https://serde.rs", "content": "Serialization framework"},
]
});
Mock::given(method("GET"))
.and(path("/search"))
.and(query_param("q", "rust"))
.and(query_param("format", "json"))
.respond_with(ResponseTemplate::new(200).set_body_json(&body))
.mount(&mock_server)
.await;
let config = crate::config::SearxngConfig {
url: mock_server.uri(),
};
let backend = SearxngBackend::new(&config);
let response = backend.search("rust", 2, None).await.unwrap();
assert_eq!(response.results.len(), 2);
assert_eq!(response.results[0].title, "Rust Lang");
assert_eq!(response.results[0].url, "https://rust-lang.org");
assert_eq!(response.results[0].snippet, "Systems programming");
assert_eq!(response.results[1].title, "Tokio");
assert!(response.warnings.is_empty());
}
#[tokio::test]
async fn searxng_with_lang_param() {
let mock_server = MockServer::start().await;
let body = serde_json::json!({
"results": [
{"title": "Rust IT", "url": "https://rust-lang.org/it", "content": "Linguaggio di sistema"},
]
});
Mock::given(method("GET"))
.and(path("/search"))
.and(query_param("language", "it"))
.respond_with(ResponseTemplate::new(200).set_body_json(&body))
.mount(&mock_server)
.await;
let config = crate::config::SearxngConfig {
url: mock_server.uri(),
};
let backend = SearxngBackend::new(&config);
let response = backend.search("rust", 10, Some("it")).await.unwrap();
assert_eq!(response.results.len(), 1);
assert_eq!(response.results[0].title, "Rust IT");
}
#[tokio::test]
async fn searxng_handles_http_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/search"))
.respond_with(ResponseTemplate::new(500))
.mount(&mock_server)
.await;
let config = crate::config::SearxngConfig {
url: mock_server.uri(),
};
let backend = SearxngBackend::new(&config);
let result = backend.search("test", 5, None).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("500"));
}
#[tokio::test]
async fn searxng_handles_empty_results() {
let mock_server = MockServer::start().await;
let body = serde_json::json!({"results": []});
Mock::given(method("GET"))
.and(path("/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(&body))
.mount(&mock_server)
.await;
let config = crate::config::SearxngConfig {
url: mock_server.uri(),
};
let backend = SearxngBackend::new(&config);
let response = backend.search("noresults", 5, None).await.unwrap();
assert!(response.results.is_empty());
assert!(response.warnings.is_empty());
}
#[tokio::test]
async fn searxng_surfaces_legacy_string_msg_format() {
let mock_server = MockServer::start().await;
let body = serde_json::json!({
"results": [],
"error_msgs": [
"startpage: SearxEngineCaptchaException: redirected to captcha (suspended_time=3600)",
"karmasearch: SearxEngineAccessDeniedException: HTTP 403 (suspended_time=180)"
],
"unresponsive_msgs": [
"duckduckgo: SearxEngineNetworkError: connection reset"
]
});
Mock::given(method("GET"))
.and(path("/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(&body))
.mount(&mock_server)
.await;
let config = crate::config::SearxngConfig {
url: mock_server.uri(),
};
let backend = SearxngBackend::new(&config);
let response = backend.search("blocked", 5, None).await.unwrap();
assert!(response.results.is_empty());
assert_eq!(response.warnings.len(), 3);
assert!(response.warnings[0].contains("startpage"));
assert!(response.warnings[0].contains("CaptchaException"));
assert!(response.warnings[1].contains("karmasearch"));
assert!(response.warnings[2].contains("duckduckgo"));
assert!(response.warnings[2].contains("unresponsive"));
}
#[tokio::test]
async fn searxng_surfaces_unresponsive_engines_tuple_format() {
let mock_server = MockServer::start().await;
let body = serde_json::json!({
"results": [],
"unresponsive_engines": [
["brave", "Suspended: too many requests"],
["startpage", "SearxEngineCaptchaException: redirected to captcha"]
],
"errors": [
["karmasearch", "HTTP error 403"]
]
});
Mock::given(method("GET"))
.and(path("/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(&body))
.mount(&mock_server)
.await;
let config = crate::config::SearxngConfig {
url: mock_server.uri(),
};
let backend = SearxngBackend::new(&config);
let response = backend.search("blocked", 5, None).await.unwrap();
assert!(response.results.is_empty());
assert_eq!(response.warnings.len(), 3);
assert!(response.warnings.iter().any(|w| w.contains("brave") && w.contains("Suspended")));
assert!(response.warnings.iter().any(|w| w.contains("startpage") && w.contains("CaptchaException")));
assert!(response.warnings.iter().any(|w| w.contains("karmasearch") && w.contains("403")));
}
#[tokio::test]
async fn searxng_results_with_partial_engine_failures() {
let mock_server = MockServer::start().await;
let body = serde_json::json!({
"results": [
{"title": "Rust", "url": "https://rust-lang.org", "content": "Systems lang"}
],
"unresponsive_engines": [
["brave", "Suspended: too many requests"]
]
});
Mock::given(method("GET"))
.and(path("/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(&body))
.mount(&mock_server)
.await;
let config = crate::config::SearxngConfig {
url: mock_server.uri(),
};
let backend = SearxngBackend::new(&config);
let response = backend.search("rust", 5, None).await.unwrap();
assert_eq!(response.results.len(), 1);
assert_eq!(response.warnings.len(), 1);
assert!(response.warnings[0].contains("brave"));
}
}