Skip to main content

google_search_console_api/search_analytics/
query.rs

1//! Search Analytics query types and builder.
2
3use crate::types::{Dimension, DimensionFilterGroup};
4use serde::{Deserialize, Serialize};
5
6/// Search type for filtering results.
7///
8/// Specifies the type of search to query data for.
9#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
10#[serde(rename_all = "camelCase")]
11pub enum SearchType {
12    /// Web search results (default).
13    #[default]
14    Web,
15    /// Image search results.
16    Image,
17    /// Video search results.
18    Video,
19    /// News search results.
20    News,
21    /// Discover feed results.
22    Discover,
23    /// Google News app results.
24    GoogleNews,
25}
26
27/// Aggregation type for grouping results.
28///
29/// Controls how data is aggregated in the response.
30#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
31#[serde(rename_all = "camelCase")]
32pub enum AggregationType {
33    /// Auto aggregation based on dimensions (default).
34    #[default]
35    Auto,
36    /// Aggregate by page URL.
37    ByPage,
38    /// Aggregate by property (site-level).
39    ByProperty,
40}
41
42/// Data freshness state.
43///
44/// Controls whether to include preliminary or only finalized data.
45#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
46#[serde(rename_all = "camelCase")]
47pub enum DataState {
48    /// Include all available data, including preliminary data (default).
49    #[default]
50    All,
51    /// Only include finalized data.
52    Final,
53}
54
55/// Request parameters for the Search Analytics query API.
56///
57/// Use the builder pattern for easier construction:
58///
59/// # Example
60///
61/// ```rust
62/// use google_search_console_api::search_analytics::query::SearchAnalyticsQueryRequest;
63/// use google_search_console_api::types::Dimension;
64///
65/// let request = SearchAnalyticsQueryRequest::builder("2024-01-01", "2024-01-31")
66///     .dimensions(vec![Dimension::Query, Dimension::Page])
67///     .row_limit(1000)
68///     .build();
69/// ```
70#[derive(Default, Debug, Serialize, Deserialize, Clone)]
71pub struct SearchAnalyticsQueryRequest {
72    /// Start date of the query (YYYY-MM-DD format).
73    #[serde(rename = "startDate")]
74    pub start_date: String,
75    /// End date of the query (YYYY-MM-DD format).
76    #[serde(rename = "endDate")]
77    pub end_date: String,
78    /// Dimensions to group results by.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub dimensions: Option<Vec<Dimension>>,
81    /// Type of search to filter by.
82    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
83    pub search_type: Option<SearchType>,
84    /// Filter groups to apply to the query.
85    #[serde(
86        rename = "dimensionFilterGroups",
87        skip_serializing_if = "Option::is_none"
88    )]
89    pub dimension_filter_groups: Option<Vec<DimensionFilterGroup>>,
90    /// How to aggregate the results.
91    #[serde(rename = "aggregationType", skip_serializing_if = "Option::is_none")]
92    pub aggregation_type: Option<AggregationType>,
93    /// Maximum number of rows to return (1-25000).
94    #[serde(rename = "rowLimit", skip_serializing_if = "Option::is_none")]
95    pub row_limit: Option<usize>,
96    /// Zero-based index of the first row to return (for pagination).
97    #[serde(rename = "startRow", skip_serializing_if = "Option::is_none")]
98    pub start_row: Option<usize>,
99    /// Data freshness preference.
100    #[serde(rename = "dataState", skip_serializing_if = "Option::is_none")]
101    pub data_state: Option<DataState>,
102}
103
104impl SearchAnalyticsQueryRequest {
105    /// Create a new builder for SearchAnalyticsQueryRequest.
106    ///
107    /// # Arguments
108    ///
109    /// * `start_date` - Start date in YYYY-MM-DD format
110    /// * `end_date` - End date in YYYY-MM-DD format
111    ///
112    /// # Example
113    ///
114    /// ```rust
115    /// use google_search_console_api::search_analytics::query::SearchAnalyticsQueryRequest;
116    ///
117    /// let request = SearchAnalyticsQueryRequest::builder("2024-01-01", "2024-01-31")
118    ///     .row_limit(100)
119    ///     .build();
120    /// ```
121    pub fn builder(start_date: &str, end_date: &str) -> SearchAnalyticsQueryRequestBuilder {
122        SearchAnalyticsQueryRequestBuilder {
123            start_date: start_date.to_string(),
124            end_date: end_date.to_string(),
125            ..Default::default()
126        }
127    }
128}
129
130/// Builder for [`SearchAnalyticsQueryRequest`].
131///
132/// Use [`SearchAnalyticsQueryRequest::builder`] to create an instance.
133#[derive(Default, Debug, Clone)]
134pub struct SearchAnalyticsQueryRequestBuilder {
135    start_date: String,
136    end_date: String,
137    dimensions: Option<Vec<Dimension>>,
138    search_type: Option<SearchType>,
139    dimension_filter_groups: Option<Vec<DimensionFilterGroup>>,
140    aggregation_type: Option<AggregationType>,
141    row_limit: Option<usize>,
142    start_row: Option<usize>,
143    data_state: Option<DataState>,
144}
145
146impl SearchAnalyticsQueryRequestBuilder {
147    /// Set dimensions for grouping results.
148    ///
149    /// Results will be grouped by the specified dimensions.
150    pub fn dimensions(mut self, dimensions: Vec<Dimension>) -> Self {
151        self.dimensions = Some(dimensions);
152        self
153    }
154
155    /// Set the search type filter.
156    ///
157    /// Filters results to only include data from the specified search type.
158    pub fn search_type(mut self, search_type: SearchType) -> Self {
159        self.search_type = Some(search_type);
160        self
161    }
162
163    /// Set dimension filter groups.
164    ///
165    /// Filters results based on dimension values.
166    pub fn dimension_filter_groups(mut self, groups: Vec<DimensionFilterGroup>) -> Self {
167        self.dimension_filter_groups = Some(groups);
168        self
169    }
170
171    /// Set the aggregation type.
172    ///
173    /// Controls how data is aggregated in the response.
174    pub fn aggregation_type(mut self, aggregation_type: AggregationType) -> Self {
175        self.aggregation_type = Some(aggregation_type);
176        self
177    }
178
179    /// Set the maximum number of rows to return.
180    ///
181    /// Maximum value is 25000. Values greater than 25000 will be clamped.
182    pub fn row_limit(mut self, limit: usize) -> Self {
183        self.row_limit = Some(limit.min(25000));
184        self
185    }
186
187    /// Set the starting row for pagination.
188    ///
189    /// Zero-based index of the first row to return.
190    pub fn start_row(mut self, start: usize) -> Self {
191        self.start_row = Some(start);
192        self
193    }
194
195    /// Set the data state filter.
196    ///
197    /// Controls whether to include preliminary data.
198    pub fn data_state(mut self, data_state: DataState) -> Self {
199        self.data_state = Some(data_state);
200        self
201    }
202
203    /// Build the request.
204    ///
205    /// Consumes the builder and returns the constructed request.
206    pub fn build(self) -> SearchAnalyticsQueryRequest {
207        SearchAnalyticsQueryRequest {
208            start_date: self.start_date,
209            end_date: self.end_date,
210            dimensions: self.dimensions,
211            search_type: self.search_type,
212            dimension_filter_groups: self.dimension_filter_groups,
213            aggregation_type: self.aggregation_type,
214            row_limit: self.row_limit,
215            start_row: self.start_row,
216            data_state: self.data_state,
217        }
218    }
219}
220
221/// Response from a Search Analytics query.
222#[derive(Default, Debug, Serialize, Deserialize, Clone)]
223pub struct SearchAnalyticsQueryResponse {
224    /// List of result rows.
225    pub rows: Option<Vec<SearchAnalyticsQueryResponseRow>>,
226    /// The aggregation type used for the response.
227    #[serde(rename = "responseAggregationType")]
228    pub response_aggregation_type: Option<String>,
229}
230
231/// A single row in the Search Analytics query response.
232#[derive(Default, Debug, Serialize, Deserialize, Clone)]
233pub struct SearchAnalyticsQueryResponseRow {
234    /// Values for the requested dimensions, in the order specified.
235    pub keys: Option<Vec<String>>,
236    /// Number of clicks.
237    pub clicks: Option<f32>,
238    /// Number of impressions.
239    pub impressions: Option<f32>,
240    /// Click-through rate (clicks / impressions).
241    pub ctr: Option<f32>,
242    /// Average position in search results.
243    pub position: Option<f32>,
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn test_builder_basic() {
252        let request = SearchAnalyticsQueryRequest::builder("2024-01-01", "2024-01-31").build();
253
254        assert_eq!(request.start_date, "2024-01-01");
255        assert_eq!(request.end_date, "2024-01-31");
256        assert!(request.dimensions.is_none());
257        assert!(request.row_limit.is_none());
258    }
259
260    #[test]
261    fn test_builder_with_dimensions() {
262        let request = SearchAnalyticsQueryRequest::builder("2024-01-01", "2024-01-31")
263            .dimensions(vec![Dimension::Query, Dimension::Page])
264            .build();
265
266        let dims = request.dimensions.unwrap();
267        assert_eq!(dims.len(), 2);
268        assert_eq!(dims[0], Dimension::Query);
269        assert_eq!(dims[1], Dimension::Page);
270    }
271
272    #[test]
273    fn test_builder_row_limit_clamped() {
274        let request = SearchAnalyticsQueryRequest::builder("2024-01-01", "2024-01-31")
275            .row_limit(50000)
276            .build();
277
278        assert_eq!(request.row_limit, Some(25000));
279    }
280
281    #[test]
282    fn test_builder_row_limit_normal() {
283        let request = SearchAnalyticsQueryRequest::builder("2024-01-01", "2024-01-31")
284            .row_limit(100)
285            .build();
286
287        assert_eq!(request.row_limit, Some(100));
288    }
289
290    #[test]
291    fn test_builder_all_options() {
292        let request = SearchAnalyticsQueryRequest::builder("2024-01-01", "2024-01-31")
293            .dimensions(vec![Dimension::Query])
294            .search_type(SearchType::Web)
295            .aggregation_type(AggregationType::ByPage)
296            .data_state(DataState::Final)
297            .row_limit(1000)
298            .start_row(100)
299            .build();
300
301        assert_eq!(request.start_date, "2024-01-01");
302        assert_eq!(request.end_date, "2024-01-31");
303        assert_eq!(request.dimensions.unwrap().len(), 1);
304        assert_eq!(request.search_type, Some(SearchType::Web));
305        assert_eq!(request.aggregation_type, Some(AggregationType::ByPage));
306        assert_eq!(request.data_state, Some(DataState::Final));
307        assert_eq!(request.row_limit, Some(1000));
308        assert_eq!(request.start_row, Some(100));
309    }
310
311    #[test]
312    fn test_search_type_serialize() {
313        assert_eq!(serde_json::to_string(&SearchType::Web).unwrap(), "\"web\"");
314        assert_eq!(
315            serde_json::to_string(&SearchType::Image).unwrap(),
316            "\"image\""
317        );
318        assert_eq!(
319            serde_json::to_string(&SearchType::Discover).unwrap(),
320            "\"discover\""
321        );
322        assert_eq!(
323            serde_json::to_string(&SearchType::GoogleNews).unwrap(),
324            "\"googleNews\""
325        );
326    }
327
328    #[test]
329    fn test_aggregation_type_serialize() {
330        assert_eq!(
331            serde_json::to_string(&AggregationType::Auto).unwrap(),
332            "\"auto\""
333        );
334        assert_eq!(
335            serde_json::to_string(&AggregationType::ByPage).unwrap(),
336            "\"byPage\""
337        );
338        assert_eq!(
339            serde_json::to_string(&AggregationType::ByProperty).unwrap(),
340            "\"byProperty\""
341        );
342    }
343
344    #[test]
345    fn test_data_state_serialize() {
346        assert_eq!(serde_json::to_string(&DataState::All).unwrap(), "\"all\"");
347        assert_eq!(
348            serde_json::to_string(&DataState::Final).unwrap(),
349            "\"final\""
350        );
351    }
352
353    #[test]
354    fn test_request_serialize() {
355        let request = SearchAnalyticsQueryRequest::builder("2024-01-01", "2024-01-31")
356            .dimensions(vec![Dimension::Query])
357            .row_limit(100)
358            .build();
359
360        let json = serde_json::to_string(&request).unwrap();
361        assert!(json.contains("\"startDate\":\"2024-01-01\""));
362        assert!(json.contains("\"endDate\":\"2024-01-31\""));
363        assert!(json.contains("\"dimensions\":[\"query\"]"));
364        assert!(json.contains("\"rowLimit\":100"));
365    }
366
367    #[test]
368    fn test_request_skip_none_fields() {
369        let request = SearchAnalyticsQueryRequest::builder("2024-01-01", "2024-01-31").build();
370
371        let json = serde_json::to_string(&request).unwrap();
372        assert!(!json.contains("dimensions"));
373        assert!(!json.contains("rowLimit"));
374        assert!(!json.contains("type"));
375    }
376
377    #[test]
378    fn test_response_deserialize() {
379        let json = r#"{
380            "rows": [
381                {
382                    "keys": ["test query"],
383                    "clicks": 100.0,
384                    "impressions": 1000.0,
385                    "ctr": 0.1,
386                    "position": 5.5
387                }
388            ],
389            "responseAggregationType": "auto"
390        }"#;
391
392        let response: SearchAnalyticsQueryResponse = serde_json::from_str(json).unwrap();
393        assert!(response.rows.is_some());
394
395        let rows = response.rows.unwrap();
396        assert_eq!(rows.len(), 1);
397        assert_eq!(rows[0].clicks, Some(100.0));
398        assert_eq!(rows[0].impressions, Some(1000.0));
399        assert_eq!(rows[0].ctr, Some(0.1));
400        assert_eq!(rows[0].position, Some(5.5));
401    }
402
403    #[test]
404    fn test_response_empty_rows() {
405        let json = r#"{}"#;
406
407        let response: SearchAnalyticsQueryResponse = serde_json::from_str(json).unwrap();
408        assert!(response.rows.is_none());
409    }
410}