From fa03c6033941083e7cb6eed0a54f8adfacde5bfd Mon Sep 17 00:00:00 2001 From: oabrivard Date: Wed, 25 Mar 2026 22:47:39 +0100 Subject: [PATCH] feat: add Brave Search API client module Co-Authored-By: Claude Sonnet 4.6 --- backend/src/services/brave_search.rs | 148 +++++++++++++++++++++++++++ backend/src/services/mod.rs | 1 + 2 files changed, 149 insertions(+) create mode 100644 backend/src/services/brave_search.rs diff --git a/backend/src/services/brave_search.rs b/backend/src/services/brave_search.rs new file mode 100644 index 0000000..7aa2f45 --- /dev/null +++ b/backend/src/services/brave_search.rs @@ -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, +} + +#[derive(Deserialize)] +struct BraveWebResults { + results: Option>, +} + +#[derive(Deserialize)] +struct BraveWebResult { + title: Option, + url: Option, + description: Option, +} + +/// 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, 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"); + } +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 4432534..36aee0a 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod brave_search; pub mod csv; pub mod email; pub mod encryption;