use std::time::Duration;
use reqwest::Client;
use serde::Deserialize;
use super::error::EngineError;
use super::models::SearchResult;
const ENGINE: &str = "brave_api";
const DEFAULT_BASE_URL: &str = "https://api.search.brave.com/res/v1/web/search";
const MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
#[derive(Debug, Deserialize)]
struct BraveApiResponse {
#[serde(default)]
web: Option<BraveWebResults>,
}
#[derive(Debug, Deserialize)]
struct BraveWebResults {
#[serde(default)]
results: Vec<BraveResult>,
}
#[derive(Debug, Deserialize)]
struct BraveResult {
title: Option<String>,
url: Option<String>,
description: Option<String>,
#[allow(dead_code)]
age: Option<String>,
}
pub async fn search(
client: &Client,
api_key: &str,
base_url: Option<&str>,
query: &str,
max_results: usize,
timeout: Duration,
) -> Result<Vec<SearchResult>, EngineError> {
let url = base_url.unwrap_or(DEFAULT_BASE_URL);
let response = tokio::time::timeout(
timeout,
client
.get(url)
.query(&[("q", query), ("count", &max_results.to_string())])
.header("Accept", "application/json")
.header("Accept", "application/x-ndjson")
.header("X-Subscription-Token", api_key)
.send(),
)
.await
.map_err(|_| EngineError::Timeout { engine: ENGINE })?
.map_err(|e| EngineError::Http {
engine: ENGINE,
source: e,
})?;
let status = response.status();
if !status.is_success() {
return Err(EngineError::BadStatus {
engine: ENGINE,
status: status.as_u16(),
});
}
let bytes = response.bytes().await.map_err(|e| EngineError::Http {
engine: ENGINE,
source: e,
})?;
if bytes.len() > MAX_BODY_BYTES {
return Err(EngineError::ParseFailed {
engine: ENGINE,
reason: format!("response body too large: {} bytes", bytes.len()),
});
}
let parsed: BraveApiResponse =
serde_json::from_slice(&bytes).map_err(|e| EngineError::ParseFailed {
engine: ENGINE,
reason: format!("invalid JSON: {e}"),
})?;
let raw = parsed.web.map(|w| w.results).unwrap_or_default();
Ok(convert(raw, max_results))
}
fn convert(raw: Vec<BraveResult>, max_results: usize) -> Vec<SearchResult> {
let mut out = Vec::with_capacity(max_results);
for r in raw {
if out.len() >= max_results {
break;
}
let Some(url) = r.url else { continue };
if url.is_empty() || !url.starts_with("http") {
continue;
}
let title = r
.title
.map(|t| t.split_whitespace().collect::<Vec<_>>().join(" "))
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty());
let Some(title) = title else { continue };
let snippet = r
.description
.map(|s| s.split_whitespace().collect::<Vec<_>>().join(" "))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
out.push(SearchResult {
title,
url,
snippet,
source_engine: ENGINE.to_string(),
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_convert_extracts_results() {
let raw = vec![
BraveResult {
title: Some("Example Site".to_string()),
url: Some("https://example.com".to_string()),
description: Some("An example website for testing.".to_string()),
age: Some("2024-01-15".to_string()),
},
BraveResult {
title: Some("Rust Language".to_string()),
url: Some("https://rust-lang.org".to_string()),
description: Some("Systems programming language.".to_string()),
age: None,
},
];
let out = convert(raw, 10);
assert_eq!(out.len(), 2);
assert_eq!(out[0].title, "Example Site");
assert_eq!(out[0].url, "https://example.com");
assert_eq!(
out[0].snippet.as_deref(),
Some("An example website for testing.")
);
assert_eq!(out[0].source_engine, "brave_api");
}
#[test]
fn test_convert_respects_max_results() {
let raw: Vec<BraveResult> = (0..5)
.map(|i| BraveResult {
title: Some(format!("T{i}")),
url: Some(format!("https://example.com/{i}")),
description: None,
age: None,
})
.collect();
let out = convert(raw, 2);
assert_eq!(out.len(), 2);
}
#[test]
fn test_convert_skips_missing_url() {
let raw = vec![BraveResult {
title: Some("No URL".to_string()),
url: None,
description: None,
age: None,
}];
let out = convert(raw, 10);
assert!(out.is_empty());
}
#[test]
fn test_convert_skips_empty_url() {
let raw = vec![BraveResult {
title: Some("Empty".to_string()),
url: Some(String::new()),
description: None,
age: None,
}];
let out = convert(raw, 10);
assert!(out.is_empty());
}
#[test]
fn test_convert_skips_non_http_urls() {
let raw = vec![
BraveResult {
title: Some("Relative".to_string()),
url: Some("/relative".to_string()),
description: None,
age: None,
},
BraveResult {
title: Some("Valid".to_string()),
url: Some("https://valid.com".to_string()),
description: None,
age: None,
},
];
let out = convert(raw, 10);
assert_eq!(out.len(), 1);
assert_eq!(out[0].url, "https://valid.com");
}
#[test]
fn test_convert_skips_missing_title() {
let raw = vec![BraveResult {
title: None,
url: Some("https://example.com".to_string()),
description: None,
age: None,
}];
let out = convert(raw, 10);
assert!(out.is_empty());
}
#[test]
fn test_convert_drops_empty_snippet() {
let raw = vec![BraveResult {
title: Some("Title".to_string()),
url: Some("https://example.com".to_string()),
description: Some(String::new()),
age: None,
}];
let out = convert(raw, 10);
assert_eq!(out.len(), 1);
assert!(out[0].snippet.is_none());
}
#[test]
fn test_parse_json_response_full() {
let body = r#"{
"web": {
"results": [
{"title": "Rust Lang", "url": "https://rust-lang.org", "description": "A language", "age": "2024-01-15"},
{"title": "Wikipedia", "url": "https://en.wikipedia.org/wiki/Rust", "description": "Article"}
]
}
}"#;
let parsed: BraveApiResponse = serde_json::from_str(body).unwrap();
let web = parsed.web.expect("web results");
assert_eq!(web.results.len(), 2);
}
#[test]
fn test_parse_json_response_empty_web() {
let body = r#"{}"#;
let parsed: BraveApiResponse = serde_json::from_str(body).unwrap();
assert!(parsed.web.is_none());
}
use crate::meta::engines::error::EngineError;
#[tokio::test]
async fn test_successful_response_with_multiple_results() {
use httpmock::prelude::*;
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET)
.path("/search")
.header("X-Subscription-Token", "test-api-key");
then.status(200)
.header("content-type", "application/json")
.body(r#"{
"web": {
"results": [
{"title": "Rust Lang", "url": "https://rust-lang.org", "description": "A language"},
{"title": "Wikipedia", "url": "https://en.wikipedia.org/wiki/Rust", "description": "Article"},
{"title": "Docs", "url": "https://docs.rs", "description": "Documentation"}
]
}
}"#);
});
let client = reqwest::Client::new();
let results = search(
&client,
"test-api-key",
Some(&server.url("/search")),
"rust",
10,
Duration::from_secs(5),
)
.await
.expect("search should succeed");
assert_eq!(results.len(), 3);
assert_eq!(results[0].title, "Rust Lang");
assert_eq!(results[0].url, "https://rust-lang.org");
assert_eq!(results[0].source_engine, "brave_api");
assert_eq!(results[1].title, "Wikipedia");
assert_eq!(results[2].title, "Docs");
}
#[tokio::test]
async fn test_empty_result_set() {
use httpmock::prelude::*;
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/search");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"web": {"results": []}}"#);
});
let client = reqwest::Client::new();
let results = search(
&client,
"test-api-key",
Some(&server.url("/search")),
"xyznonexistent",
10,
Duration::from_secs(5),
)
.await
.expect("search should succeed");
assert!(results.is_empty());
}
#[tokio::test]
async fn test_empty_web_object() {
use httpmock::prelude::*;
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/search");
then.status(200)
.header("content-type", "application/json")
.body(r#"{}"#);
});
let client = reqwest::Client::new();
let results = search(
&client,
"test-api-key",
Some(&server.url("/search")),
"rust",
10,
Duration::from_secs(5),
)
.await
.expect("search should succeed");
assert!(results.is_empty());
}
#[tokio::test]
async fn test_invalid_api_key_401() {
use httpmock::prelude::*;
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/search");
then.status(401).body("Unauthorized");
});
let client = reqwest::Client::new();
let err = search(
&client,
"bad-key",
Some(&server.url("/search")),
"rust",
10,
Duration::from_secs(5),
)
.await
.expect_err("should fail with 401");
match err {
EngineError::BadStatus { engine, status } => {
assert_eq!(engine, "brave_api");
assert_eq!(status, 401);
}
other => panic!("expected BadStatus(401), got: {other:?}"),
}
}
#[tokio::test]
async fn test_invalid_api_key_403() {
use httpmock::prelude::*;
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/search");
then.status(403).body("Forbidden");
});
let client = reqwest::Client::new();
let err = search(
&client,
"bad-key",
Some(&server.url("/search")),
"rust",
10,
Duration::from_secs(5),
)
.await
.expect_err("should fail with 403");
match err {
EngineError::BadStatus { engine, status } => {
assert_eq!(engine, "brave_api");
assert_eq!(status, 403);
}
other => panic!("expected BadStatus(403), got: {other:?}"),
}
}
#[tokio::test]
async fn test_rate_limited_429() {
use httpmock::prelude::*;
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/search");
then.status(429).body("Too Many Requests");
});
let client = reqwest::Client::new();
let err = search(
&client,
"test-api-key",
Some(&server.url("/search")),
"rust",
10,
Duration::from_secs(5),
)
.await
.expect_err("should fail with 429");
match err {
EngineError::BadStatus { engine, status } => {
assert_eq!(engine, "brave_api");
assert_eq!(status, 429);
}
other => panic!("expected BadStatus(429), got: {other:?}"),
}
}
#[tokio::test]
async fn test_malformed_json() {
use httpmock::prelude::*;
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/search");
then.status(200)
.header("content-type", "application/json")
.body("this is not json");
});
let client = reqwest::Client::new();
let err = search(
&client,
"test-api-key",
Some(&server.url("/search")),
"rust",
10,
Duration::from_secs(5),
)
.await
.expect_err("should fail with parse error");
match err {
EngineError::ParseFailed { engine, reason } => {
assert_eq!(engine, "brave_api");
assert!(reason.contains("invalid JSON"), "reason: {reason}");
}
other => panic!("expected ParseFailed, got: {other:?}"),
}
}
#[tokio::test]
async fn test_server_error_500() {
use httpmock::prelude::*;
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/search");
then.status(500).body("Internal Server Error");
});
let client = reqwest::Client::new();
let err = search(
&client,
"test-api-key",
Some(&server.url("/search")),
"rust",
10,
Duration::from_secs(5),
)
.await
.expect_err("should fail with 500");
match err {
EngineError::BadStatus { engine, status } => {
assert_eq!(engine, "brave_api");
assert_eq!(status, 500);
}
other => panic!("expected BadStatus(500), got: {other:?}"),
}
}
#[tokio::test]
async fn test_respects_max_results() {
use httpmock::prelude::*;
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/search").query_param("count", "2");
then.status(200)
.header("content-type", "application/json")
.body(
r#"{
"web": {
"results": [
{"title": "A", "url": "https://a.com", "description": "a"},
{"title": "B", "url": "https://b.com", "description": "b"},
{"title": "C", "url": "https://c.com", "description": "c"}
]
}
}"#,
);
});
let client = reqwest::Client::new();
let results = search(
&client,
"test-api-key",
Some(&server.url("/search")),
"rust",
2,
Duration::from_secs(5),
)
.await
.expect("search should succeed");
assert_eq!(results.len(), 2);
assert_eq!(results[0].title, "A");
assert_eq!(results[1].title, "B");
}
#[tokio::test]
async fn test_api_key_sent_in_header() {
use httpmock::prelude::*;
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET)
.path("/search")
.header("X-Subscription-Token", "my-secret-key");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"web": {"results": []}}"#);
});
let client = reqwest::Client::new();
search(
&client,
"my-secret-key",
Some(&server.url("/search")),
"rust",
10,
Duration::from_secs(5),
)
.await
.expect("search should succeed with correct API key header");
}
#[test]
fn test_provider_descriptor_for_brave_api() {
use crate::core::provider::built_in_provider_descriptor;
let desc = built_in_provider_descriptor("brave_api", true, false, true).unwrap();
assert_eq!(desc.id, "brave_api");
assert_eq!(desc.display_name, "Brave Search API");
assert_eq!(desc.kind, crate::core::provider::ProviderKind::ApiKey);
assert!(desc.requires_api_key);
assert!(desc.configured);
assert!(desc.enabled);
assert!(!desc.default);
assert!(desc.capabilities.supports_safe_search);
assert!(desc.capabilities.supports_freshness);
assert!(desc.capabilities.supports_language);
assert!(desc.capabilities.supports_region);
}
#[test]
fn test_provider_descriptor_brave_api_unconfigured_when_disabled() {
use crate::core::provider::built_in_provider_descriptor;
let desc = built_in_provider_descriptor("brave_api", false, false, true).unwrap();
assert!(!desc.configured);
assert!(!desc.enabled);
}
}