use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Deserialize, PartialEq)]
pub struct SearchResponse {
pub search_metadata: Option<SearchMetadata>,
pub organic: Option<Vec<OrganicResult>>,
pub answer_box: Option<AnswerBox>,
pub knowledge_graph: Option<KnowledgeGraph>,
pub related_questions: Option<Vec<RelatedQuestion>>,
pub shopping: Option<Vec<ShoppingResult>>,
pub news: Option<Vec<NewsResult>>,
}
impl SearchResponse {
pub fn new() -> Self {
Self {
search_metadata: None,
organic: None,
answer_box: None,
knowledge_graph: None,
related_questions: None,
shopping: None,
news: None,
}
}
pub fn has_results(&self) -> bool {
self.organic.as_ref().is_some_and(|o| !o.is_empty())
|| self.answer_box.is_some()
|| self.knowledge_graph.is_some()
|| self.shopping.as_ref().is_some_and(|s| !s.is_empty())
|| self.news.as_ref().is_some_and(|n| !n.is_empty())
}
pub fn organic_count(&self) -> usize {
self.organic.as_ref().map_or(0, |o| o.len())
}
pub fn organic_results(&self) -> &[OrganicResult] {
self.organic.as_deref().unwrap_or(&[])
}
pub fn first_result(&self) -> Option<&OrganicResult> {
self.organic.as_ref()?.first()
}
pub fn extract_urls(&self) -> Vec<&str> {
self.organic_results()
.iter()
.map(|result| result.link.as_str())
.collect()
}
}
impl Default for SearchResponse {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct SearchMetadata {
pub id: String,
pub status: String,
pub created_at: String,
pub request_time_taken: f64,
pub total_time_taken: f64,
}
#[derive(Debug, Deserialize, PartialEq, Clone)]
pub struct OrganicResult {
pub title: String,
pub link: String,
pub snippet: Option<String>,
pub position: u32,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
impl OrganicResult {
pub fn new(title: String, link: String, position: u32) -> Self {
Self {
title,
link,
snippet: None,
position,
extra: HashMap::new(),
}
}
pub fn has_snippet(&self) -> bool {
self.snippet.is_some()
}
pub fn snippet_or_default(&self) -> &str {
self.snippet
.as_deref()
.unwrap_or("No description available")
}
pub fn domain(&self) -> Option<String> {
url::Url::parse(&self.link)
.ok()?
.host_str()
.map(|host| host.to_string())
}
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct AnswerBox {
pub answer: Option<String>,
pub snippet: Option<String>,
pub title: Option<String>,
pub link: Option<String>,
}
impl AnswerBox {
pub fn has_answer(&self) -> bool {
self.answer.is_some()
}
pub fn best_text(&self) -> Option<&str> {
self.answer.as_deref().or(self.snippet.as_deref())
}
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct KnowledgeGraph {
pub title: Option<String>,
pub description: Option<String>,
#[serde(rename = "type")]
pub entity_type: Option<String>,
pub website: Option<String>,
#[serde(flatten)]
pub attributes: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct RelatedQuestion {
pub question: String,
pub snippet: Option<String>,
pub title: Option<String>,
pub link: Option<String>,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct ShoppingResult {
pub title: String,
pub link: String,
pub price: Option<String>,
pub source: Option<String>,
pub image: Option<String>,
pub position: u32,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct NewsResult {
pub title: String,
pub link: String,
pub snippet: Option<String>,
pub source: Option<String>,
pub date: Option<String>,
pub position: u32,
}
pub struct ResponseParser;
impl ResponseParser {
pub fn parse_response(json_str: &str) -> crate::core::Result<SearchResponse> {
serde_json::from_str(json_str).map_err(crate::core::error::SerperError::Json)
}
pub fn validate_response(response: &SearchResponse) -> crate::core::Result<()> {
if let Some(metadata) = &response.search_metadata
&& metadata.id.is_empty()
{
return Err(crate::core::error::SerperError::validation_error(
"Response metadata has empty ID",
));
}
if let Some(organic) = &response.organic {
for (idx, result) in organic.iter().enumerate() {
if result.title.is_empty() {
return Err(crate::core::error::SerperError::validation_error(format!(
"Organic result {} has empty title",
idx
)));
}
if result.link.is_empty() {
return Err(crate::core::error::SerperError::validation_error(format!(
"Organic result {} has empty link",
idx
)));
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_search_response_creation() {
let response = SearchResponse::new();
assert!(!response.has_results());
assert_eq!(response.organic_count(), 0);
}
#[test]
fn test_organic_result() {
let result = OrganicResult::new(
"Test Title".to_string(),
"https://example.com".to_string(),
1,
);
assert_eq!(result.title, "Test Title");
assert_eq!(result.position, 1);
assert!(!result.has_snippet());
assert_eq!(result.snippet_or_default(), "No description available");
}
#[test]
fn test_response_parsing() {
let json_data = json!({
"search_metadata": {
"id": "test-123",
"status": "Success",
"created_at": "2023-01-01T00:00:00Z",
"request_time_taken": 0.5,
"total_time_taken": 1.0
},
"organic": [
{
"title": "Test Result",
"link": "https://example.com",
"snippet": "Test snippet",
"position": 1
}
]
});
let response: SearchResponse = serde_json::from_value(json_data).unwrap();
assert!(response.has_results());
assert_eq!(response.organic_count(), 1);
let first = response.first_result().unwrap();
assert_eq!(first.title, "Test Result");
}
#[test]
fn test_answer_box() {
let answer_box = AnswerBox {
answer: Some("42".to_string()),
snippet: Some("The answer to everything".to_string()),
title: None,
link: None,
};
assert!(answer_box.has_answer());
assert_eq!(answer_box.best_text(), Some("42"));
}
#[test]
fn test_response_validation() {
let mut response = SearchResponse::new();
assert!(ResponseParser::validate_response(&response).is_ok());
response.organic = Some(vec![OrganicResult {
title: "".to_string(),
link: "https://example.com".to_string(),
snippet: None,
position: 1,
extra: HashMap::new(),
}]);
assert!(ResponseParser::validate_response(&response).is_err());
}
}