feat: add Brave Search API client module

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
oabrivard 3 months ago
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");
}
}

@ -1,4 +1,5 @@
pub mod auth;
pub mod brave_search;
pub mod csv;
pub mod email;
pub mod encryption;

Loading…
Cancel
Save