serper_sdk/search/
response.rs

1/// Search response parsing and handling module
2///
3/// This module provides data structures and utilities for handling search responses
4/// from the Serper API, including organic results, answer boxes, and knowledge graphs.
5use serde::Deserialize;
6use std::collections::HashMap;
7
8/// Complete search response from the Serper API
9///
10/// This struct represents the full response structure that can be returned
11/// by the Serper search API, with all possible fields as optional.
12#[derive(Debug, Deserialize, PartialEq)]
13pub struct SearchResponse {
14    /// Metadata about the search request and response
15    pub search_metadata: Option<SearchMetadata>,
16
17    /// Organic search results
18    pub organic: Option<Vec<OrganicResult>>,
19
20    /// Answer box information (direct answers)
21    pub answer_box: Option<AnswerBox>,
22
23    /// Knowledge graph information
24    pub knowledge_graph: Option<KnowledgeGraph>,
25
26    /// Related questions/searches
27    pub related_questions: Option<Vec<RelatedQuestion>>,
28
29    /// Shopping results (if applicable)
30    pub shopping: Option<Vec<ShoppingResult>>,
31
32    /// News results (if applicable)
33    pub news: Option<Vec<NewsResult>>,
34}
35
36impl SearchResponse {
37    /// Creates a new empty search response
38    pub fn new() -> Self {
39        Self {
40            search_metadata: None,
41            organic: None,
42            answer_box: None,
43            knowledge_graph: None,
44            related_questions: None,
45            shopping: None,
46            news: None,
47        }
48    }
49
50    /// Checks if the response has any results
51    pub fn has_results(&self) -> bool {
52        self.organic.as_ref().is_some_and(|o| !o.is_empty())
53            || self.answer_box.is_some()
54            || self.knowledge_graph.is_some()
55            || self.shopping.as_ref().is_some_and(|s| !s.is_empty())
56            || self.news.as_ref().is_some_and(|n| !n.is_empty())
57    }
58
59    /// Gets the number of organic results
60    pub fn organic_count(&self) -> usize {
61        self.organic.as_ref().map_or(0, |o| o.len())
62    }
63
64    /// Gets organic results as a slice
65    pub fn organic_results(&self) -> &[OrganicResult] {
66        self.organic.as_deref().unwrap_or(&[])
67    }
68
69    /// Gets the first organic result if available
70    pub fn first_result(&self) -> Option<&OrganicResult> {
71        self.organic.as_ref()?.first()
72    }
73
74    /// Extracts all URLs from organic results
75    pub fn extract_urls(&self) -> Vec<&str> {
76        self.organic_results()
77            .iter()
78            .map(|result| result.link.as_str())
79            .collect()
80    }
81}
82
83impl Default for SearchResponse {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89/// Metadata about the search request and response
90#[derive(Debug, Deserialize, PartialEq)]
91pub struct SearchMetadata {
92    /// Unique identifier for this search
93    pub id: String,
94
95    /// Status of the search request
96    pub status: String,
97
98    /// Timestamp when the search was created
99    pub created_at: String,
100
101    /// Time taken to process the request (seconds)
102    pub request_time_taken: f64,
103
104    /// Total time taken including network overhead (seconds)
105    pub total_time_taken: f64,
106}
107
108/// Individual organic search result
109#[derive(Debug, Deserialize, PartialEq, Clone)]
110pub struct OrganicResult {
111    /// Title of the search result
112    pub title: String,
113
114    /// URL of the search result
115    pub link: String,
116
117    /// Text snippet from the page (optional)
118    pub snippet: Option<String>,
119
120    /// Position in search results (1-based)
121    pub position: u32,
122
123    /// Additional metadata (optional)
124    #[serde(flatten)]
125    pub extra: HashMap<String, serde_json::Value>,
126}
127
128impl OrganicResult {
129    /// Creates a new organic result
130    pub fn new(title: String, link: String, position: u32) -> Self {
131        Self {
132            title,
133            link,
134            snippet: None,
135            position,
136            extra: HashMap::new(),
137        }
138    }
139
140    /// Checks if the result has a snippet
141    pub fn has_snippet(&self) -> bool {
142        self.snippet.is_some()
143    }
144
145    /// Gets the snippet text or a default message
146    pub fn snippet_or_default(&self) -> &str {
147        self.snippet
148            .as_deref()
149            .unwrap_or("No description available")
150    }
151
152    /// Gets the domain from the URL
153    pub fn domain(&self) -> Option<String> {
154        url::Url::parse(&self.link)
155            .ok()?
156            .host_str()
157            .map(|host| host.to_string())
158    }
159}
160
161/// Answer box with direct answers to queries
162#[derive(Debug, Deserialize, PartialEq)]
163pub struct AnswerBox {
164    /// Direct answer text (optional)
165    pub answer: Option<String>,
166
167    /// Snippet providing context for the answer (optional)
168    pub snippet: Option<String>,
169
170    /// Source title (optional)
171    pub title: Option<String>,
172
173    /// Source link (optional)
174    pub link: Option<String>,
175}
176
177impl AnswerBox {
178    /// Checks if the answer box has a direct answer
179    pub fn has_answer(&self) -> bool {
180        self.answer.is_some()
181    }
182
183    /// Gets the best available text (answer or snippet)
184    pub fn best_text(&self) -> Option<&str> {
185        self.answer.as_deref().or(self.snippet.as_deref())
186    }
187}
188
189/// Knowledge graph information
190#[derive(Debug, Deserialize, PartialEq)]
191pub struct KnowledgeGraph {
192    /// Title of the entity
193    pub title: Option<String>,
194
195    /// Description of the entity
196    pub description: Option<String>,
197
198    /// Entity type (person, organization, etc.)
199    #[serde(rename = "type")]
200    pub entity_type: Option<String>,
201
202    /// Website URL (optional)
203    pub website: Option<String>,
204
205    /// Additional attributes
206    #[serde(flatten)]
207    pub attributes: HashMap<String, serde_json::Value>,
208}
209
210/// Related question from "People also ask"
211#[derive(Debug, Deserialize, PartialEq)]
212pub struct RelatedQuestion {
213    /// The question text
214    pub question: String,
215
216    /// Snippet answering the question (optional)
217    pub snippet: Option<String>,
218
219    /// Source title (optional)
220    pub title: Option<String>,
221
222    /// Source link (optional)
223    pub link: Option<String>,
224}
225
226/// Shopping result for product searches
227#[derive(Debug, Deserialize, PartialEq)]
228pub struct ShoppingResult {
229    /// Product title
230    pub title: String,
231
232    /// Product link
233    pub link: String,
234
235    /// Product price (optional)
236    pub price: Option<String>,
237
238    /// Product source/merchant (optional)
239    pub source: Option<String>,
240
241    /// Product image URL (optional)
242    pub image: Option<String>,
243
244    /// Position in shopping results
245    pub position: u32,
246}
247
248/// News result for news searches
249#[derive(Debug, Deserialize, PartialEq)]
250pub struct NewsResult {
251    /// News article title
252    pub title: String,
253
254    /// News article link
255    pub link: String,
256
257    /// Article snippet (optional)
258    pub snippet: Option<String>,
259
260    /// News source (optional)
261    pub source: Option<String>,
262
263    /// Publication date (optional)
264    pub date: Option<String>,
265
266    /// Position in news results
267    pub position: u32,
268}
269
270/// Response parser for handling different response formats
271pub struct ResponseParser;
272
273impl ResponseParser {
274    /// Parses a JSON response into a SearchResponse
275    ///
276    /// # Arguments
277    ///
278    /// * `json_str` - The JSON response string
279    ///
280    /// # Returns
281    ///
282    /// Result containing the parsed SearchResponse or an error
283    pub fn parse_response(json_str: &str) -> crate::core::Result<SearchResponse> {
284        serde_json::from_str(json_str).map_err(crate::core::error::SerperError::Json)
285    }
286
287    /// Validates that a response has the expected structure
288    pub fn validate_response(response: &SearchResponse) -> crate::core::Result<()> {
289        // Basic validation - could be extended with more checks
290        if let Some(metadata) = &response.search_metadata
291            && metadata.id.is_empty()
292        {
293            return Err(crate::core::error::SerperError::validation_error(
294                "Response metadata has empty ID",
295            ));
296        }
297
298        // Validate organic results
299        if let Some(organic) = &response.organic {
300            for (idx, result) in organic.iter().enumerate() {
301                if result.title.is_empty() {
302                    return Err(crate::core::error::SerperError::validation_error(format!(
303                        "Organic result {} has empty title",
304                        idx
305                    )));
306                }
307                if result.link.is_empty() {
308                    return Err(crate::core::error::SerperError::validation_error(format!(
309                        "Organic result {} has empty link",
310                        idx
311                    )));
312                }
313            }
314        }
315
316        Ok(())
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use serde_json::json;
324
325    #[test]
326    fn test_search_response_creation() {
327        let response = SearchResponse::new();
328        assert!(!response.has_results());
329        assert_eq!(response.organic_count(), 0);
330    }
331
332    #[test]
333    fn test_organic_result() {
334        let result = OrganicResult::new(
335            "Test Title".to_string(),
336            "https://example.com".to_string(),
337            1,
338        );
339
340        assert_eq!(result.title, "Test Title");
341        assert_eq!(result.position, 1);
342        assert!(!result.has_snippet());
343        assert_eq!(result.snippet_or_default(), "No description available");
344    }
345
346    #[test]
347    fn test_response_parsing() {
348        let json_data = json!({
349            "search_metadata": {
350                "id": "test-123",
351                "status": "Success",
352                "created_at": "2023-01-01T00:00:00Z",
353                "request_time_taken": 0.5,
354                "total_time_taken": 1.0
355            },
356            "organic": [
357                {
358                    "title": "Test Result",
359                    "link": "https://example.com",
360                    "snippet": "Test snippet",
361                    "position": 1
362                }
363            ]
364        });
365
366        let response: SearchResponse = serde_json::from_value(json_data).unwrap();
367        assert!(response.has_results());
368        assert_eq!(response.organic_count(), 1);
369
370        let first = response.first_result().unwrap();
371        assert_eq!(first.title, "Test Result");
372    }
373
374    #[test]
375    fn test_answer_box() {
376        let answer_box = AnswerBox {
377            answer: Some("42".to_string()),
378            snippet: Some("The answer to everything".to_string()),
379            title: None,
380            link: None,
381        };
382
383        assert!(answer_box.has_answer());
384        assert_eq!(answer_box.best_text(), Some("42"));
385    }
386
387    #[test]
388    fn test_response_validation() {
389        let mut response = SearchResponse::new();
390
391        // Valid response should pass
392        assert!(ResponseParser::validate_response(&response).is_ok());
393
394        // Response with empty organic result title should fail
395        response.organic = Some(vec![OrganicResult {
396            title: "".to_string(),
397            link: "https://example.com".to_string(),
398            snippet: None,
399            position: 1,
400            extra: HashMap::new(),
401        }]);
402
403        assert!(ResponseParser::validate_response(&response).is_err());
404    }
405}