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