feat: add Brave Search API client module
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>master
parent
37bb6b4361
commit
fa03c60339
@ -0,0 +1,148 @@
|
||||
//! Brave Search API client.
|
||||
//!
|
||||
//! Calls the Brave Web Search API and returns structured results.
|
||||
//! Used as an alternative to LLM web search in Phase 2.
|
||||
|
||||
use crate::errors::AppError;
|
||||
use serde::Deserialize;
|
||||
|
||||
/// A single result from the Brave Search API.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BraveResult {
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Map `max_age_days` to Brave's `freshness` parameter.
|
||||
fn freshness_from_days(max_age_days: i32) -> &'static str {
|
||||
match max_age_days {
|
||||
d if d <= 1 => "pd",
|
||||
d if d <= 7 => "pw",
|
||||
d if d <= 30 => "pm",
|
||||
_ => "py",
|
||||
}
|
||||
}
|
||||
|
||||
/// Brave API response structures (only the fields we need).
|
||||
#[derive(Deserialize)]
|
||||
struct BraveSearchResponse {
|
||||
web: Option<BraveWebResults>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BraveWebResults {
|
||||
results: Option<Vec<BraveWebResult>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BraveWebResult {
|
||||
title: Option<String>,
|
||||
url: Option<String>,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
/// Search the Brave Web Search API.
|
||||
pub async fn search(
|
||||
http_client: &reqwest::Client,
|
||||
api_key: &str,
|
||||
query: &str,
|
||||
count: u32,
|
||||
max_age_days: i32,
|
||||
) -> Result<Vec<BraveResult>, AppError> {
|
||||
let freshness = freshness_from_days(max_age_days);
|
||||
|
||||
let response = http_client
|
||||
.get("https://api.search.brave.com/res/v1/web/search")
|
||||
.header("X-Subscription-Token", api_key)
|
||||
.header("Accept", "application/json")
|
||||
.query(&[
|
||||
("q", query),
|
||||
("count", &count.to_string()),
|
||||
("freshness", freshness),
|
||||
("search_lang", "fr"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, "Brave Search API request failed");
|
||||
AppError::Internal(anyhow::anyhow!("Brave Search API request failed: {}", e))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!(status = %status, body = %body, "Brave Search API error");
|
||||
return Err(AppError::Internal(anyhow::anyhow!(
|
||||
"Brave Search API returned status {}: {}",
|
||||
status,
|
||||
body
|
||||
)));
|
||||
}
|
||||
|
||||
let api_response: BraveSearchResponse = response.json().await.map_err(|e| {
|
||||
AppError::Internal(anyhow::anyhow!("Failed to parse Brave Search response: {}", e))
|
||||
})?;
|
||||
|
||||
let results = api_response
|
||||
.web
|
||||
.and_then(|w| w.results)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let url = r.url?;
|
||||
if url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(BraveResult {
|
||||
title: r.title.unwrap_or_default(),
|
||||
url,
|
||||
description: r.description.unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Test the Brave Search API key with a simple query.
|
||||
pub async fn test_api_key(
|
||||
http_client: &reqwest::Client,
|
||||
api_key: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let response = http_client
|
||||
.get("https://api.search.brave.com/res/v1/web/search")
|
||||
.header("X-Subscription-Token", api_key)
|
||||
.header("Accept", "application/json")
|
||||
.query(&[("q", "test"), ("count", "1")])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Brave API request failed: {}", e)))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let status = response.status();
|
||||
Err(AppError::BadRequest(format!(
|
||||
"Brave Search API returned status {}. Check your API key.",
|
||||
status
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn freshness_mapping() {
|
||||
assert_eq!(freshness_from_days(1), "pd");
|
||||
assert_eq!(freshness_from_days(0), "pd");
|
||||
assert_eq!(freshness_from_days(7), "pw");
|
||||
assert_eq!(freshness_from_days(3), "pw");
|
||||
assert_eq!(freshness_from_days(30), "pm");
|
||||
assert_eq!(freshness_from_days(14), "pm");
|
||||
assert_eq!(freshness_from_days(31), "py");
|
||||
assert_eq!(freshness_from_days(365), "py");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue