Skip to main content

voltaria_sdk/core/
query_parameter_builder.rs

1use chrono::{DateTime, TimeZone};
2use serde::Serialize;
3
4/// Modern query builder with type-safe method chaining
5/// Provides a clean, Swift-like API for building HTTP query parameters
6#[derive(Debug, Default)]
7pub struct QueryBuilder {
8    params: Vec<(String, String)>,
9}
10
11impl QueryBuilder {
12    /// Create a new query parameter builder
13    pub fn new() -> Self {
14        Self::default()
15    }
16
17    /// Add a string parameter (accept both required/optional)
18    pub fn string(mut self, key: &str, value: impl Into<Option<String>>) -> Self {
19        if let Some(v) = value.into() {
20            self.params.push((key.to_string(), v));
21        }
22        self
23    }
24
25    /// Add multiple string parameters with the same key (for allow-multiple query params)
26    /// Accepts both Vec<String> and Vec<Option<String>>, adding each non-None value as a separate query parameter
27    pub fn string_array<I, T>(mut self, key: &str, values: I) -> Self
28    where
29        I: IntoIterator<Item = T>,
30        T: Into<Option<String>>,
31    {
32        for value in values {
33            if let Some(v) = value.into() {
34                self.params.push((key.to_string(), v));
35            }
36        }
37        self
38    }
39
40    /// Add an integer parameter (accept both required/optional)
41    pub fn int(mut self, key: &str, value: impl Into<Option<i64>>) -> Self {
42        if let Some(v) = value.into() {
43            self.params.push((key.to_string(), v.to_string()));
44        }
45        self
46    }
47
48    /// Add multiple integer parameters with the same key (for allow-multiple query params)
49    /// Accepts both Vec<i64> and Vec<Option<i64>>, adding each non-None value as a separate query parameter
50    pub fn int_array<I, T>(mut self, key: &str, values: I) -> Self
51    where
52        I: IntoIterator<Item = T>,
53        T: Into<Option<i64>>,
54    {
55        for value in values {
56            if let Some(v) = value.into() {
57                self.params.push((key.to_string(), v.to_string()));
58            }
59        }
60        self
61    }
62
63    /// Add a float parameter
64    pub fn float(mut self, key: &str, value: impl Into<Option<f64>>) -> Self {
65        if let Some(v) = value.into() {
66            self.params.push((key.to_string(), v.to_string()));
67        }
68        self
69    }
70
71    /// Add multiple float parameters with the same key (for allow-multiple query params)
72    /// Accepts both Vec<f64> and Vec<Option<f64>>, adding each non-None value as a separate query parameter
73    pub fn float_array<I, T>(mut self, key: &str, values: I) -> Self
74    where
75        I: IntoIterator<Item = T>,
76        T: Into<Option<f64>>,
77    {
78        for value in values {
79            if let Some(v) = value.into() {
80                self.params.push((key.to_string(), v.to_string()));
81            }
82        }
83        self
84    }
85
86    /// Add a boolean parameter
87    pub fn bool(mut self, key: &str, value: impl Into<Option<bool>>) -> Self {
88        if let Some(v) = value.into() {
89            self.params.push((key.to_string(), v.to_string()));
90        }
91        self
92    }
93
94    /// Add multiple boolean parameters with the same key (for allow-multiple query params)
95    /// Accepts both Vec<bool> and Vec<Option<bool>>, adding each non-None value as a separate query parameter
96    pub fn bool_array<I, T>(mut self, key: &str, values: I) -> Self
97    where
98        I: IntoIterator<Item = T>,
99        T: Into<Option<bool>>,
100    {
101        for value in values {
102            if let Some(v) = value.into() {
103                self.params.push((key.to_string(), v.to_string()));
104            }
105        }
106        self
107    }
108
109    /// Add a datetime parameter (any DateTime timezone)
110    pub fn datetime<Tz: TimeZone>(
111        mut self,
112        key: &str,
113        value: impl Into<Option<DateTime<Tz>>>,
114    ) -> Self
115    where
116        Tz::Offset: std::fmt::Display,
117    {
118        if let Some(v) = value.into() {
119            self.params.push((
120                key.to_string(),
121                v.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
122            ));
123        }
124        self
125    }
126
127    /// Add a date parameter (converts NaiveDate to DateTime<Utc>)
128    pub fn date(mut self, key: &str, value: impl Into<Option<chrono::NaiveDate>>) -> Self {
129        if let Some(v) = value.into() {
130            // Convert NaiveDate to DateTime<Utc> at start of day
131            let datetime = v.and_hms_opt(0, 0, 0).unwrap().and_utc();
132            self.params.push((
133                key.to_string(),
134                datetime.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
135            ));
136        }
137        self
138    }
139
140    /// Add any serializable parameter (for enums and complex types)
141    pub fn serialize<T: Serialize>(mut self, key: &str, value: Option<T>) -> Self {
142        if let Some(v) = value {
143            // For enums that implement Display, use the Display implementation
144            // to avoid JSON quotes in query parameters
145            if let Ok(serialized) = serde_json::to_string(&v) {
146                // Remove JSON quotes if the value is a simple string
147                let cleaned = if serialized.starts_with('"') && serialized.ends_with('"') {
148                    serialized.trim_matches('"').to_string()
149                } else {
150                    serialized
151                };
152                self.params.push((key.to_string(), cleaned));
153            }
154        }
155        self
156    }
157
158    /// Add multiple serializable parameters with the same key (for allow-multiple query params with enums)
159    /// Accepts both Vec<T> and Vec<Option<T>>, adding each non-None value as a separate query parameter
160    pub fn serialize_array<T: Serialize>(
161        mut self,
162        key: &str,
163        values: impl IntoIterator<Item = T>,
164    ) -> Self {
165        for value in values {
166            if let Ok(serialized) = serde_json::to_string(&value) {
167                // Skip null values (from Option::None)
168                if serialized == "null" {
169                    continue;
170                }
171                // Remove JSON quotes if the value is a simple string
172                let cleaned = if serialized.starts_with('"') && serialized.ends_with('"') {
173                    serialized.trim_matches('"').to_string()
174                } else {
175                    serialized
176                };
177                self.params.push((key.to_string(), cleaned));
178            }
179        }
180        self
181    }
182
183    /// Parse and add a structured query string
184    /// Handles complex query patterns like:
185    /// - "key:value" patterns
186    /// - "key:value1,value2" (comma-separated values)
187    /// - Quoted values: "key:\"value with spaces\""
188    /// - Space-separated terms (treated as AND logic)
189    pub fn structured_query(mut self, key: &str, value: impl Into<Option<String>>) -> Self {
190        if let Some(query_str) = value.into() {
191            if let Ok(parsed_params) = parse_structured_query(&query_str) {
192                self.params.extend(parsed_params);
193            } else {
194                // Fall back to simple query parameter if parsing fails
195                self.params.push((key.to_string(), query_str));
196            }
197        }
198        self
199    }
200
201    /// Build the final query parameters
202    pub fn build(self) -> Option<Vec<(String, String)>> {
203        if self.params.is_empty() {
204            None
205        } else {
206            Some(self.params)
207        }
208    }
209}
210
211/// Errors that can occur during structured query parsing
212#[derive(Debug)]
213pub enum QueryBuilderError {
214    InvalidQuerySyntax(String),
215}
216
217impl std::fmt::Display for QueryBuilderError {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        match self {
220            QueryBuilderError::InvalidQuerySyntax(msg) => {
221                write!(f, "Invalid query syntax: {}", msg)
222            }
223        }
224    }
225}
226
227impl std::error::Error for QueryBuilderError {}
228
229/// Parse structured query strings like "key:value key2:value1,value2"
230/// Used for complex filtering patterns in APIs like Foxglove
231///
232/// Supported patterns:
233/// - Simple: "status:active"
234/// - Multiple values: "type:sensor,camera"
235/// - Quoted values: "location:\"New York\""
236/// - Complex: "status:active type:sensor location:\"San Francisco\""
237pub fn parse_structured_query(query: &str) -> Result<Vec<(String, String)>, QueryBuilderError> {
238    let mut params = Vec::new();
239    let terms = tokenize_query(query);
240
241    for term in terms {
242        if let Some((key, values)) = term.split_once(':') {
243            // Handle comma-separated values
244            for value in values.split(',') {
245                let clean_value = value.trim_matches('"'); // Remove quotes
246                params.push((key.to_string(), clean_value.to_string()));
247            }
248        } else {
249            // For terms without colons, return error to be explicit about expected format
250            return Err(QueryBuilderError::InvalidQuerySyntax(format!(
251                "Cannot parse term '{}' - expected 'key:value' format for structured queries",
252                term
253            )));
254        }
255    }
256
257    Ok(params)
258}
259
260/// Tokenize a query string, properly handling quoted strings
261fn tokenize_query(input: &str) -> Vec<String> {
262    let mut tokens = Vec::new();
263    let mut current_token = String::new();
264    let mut in_quotes = false;
265    let mut chars = input.chars().peekable();
266
267    while let Some(c) = chars.next() {
268        match c {
269            '"' => {
270                // Toggle quote state and include the quote in the token
271                in_quotes = !in_quotes;
272                current_token.push(c);
273            }
274            ' ' if !in_quotes => {
275                // Space outside quotes - end current token
276                if !current_token.is_empty() {
277                    tokens.push(current_token.trim().to_string());
278                    current_token.clear();
279                }
280            }
281            _ => {
282                // Any other character (including spaces inside quotes)
283                current_token.push(c);
284            }
285        }
286    }
287
288    // Add the last token if there is one
289    if !current_token.is_empty() {
290        tokens.push(current_token.trim().to_string());
291    }
292
293    tokens
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use chrono::{NaiveDate, TimeZone, Utc};
300
301    // ===========================
302    // QueryBuilder tests
303    // ===========================
304
305    #[test]
306    fn test_empty_builder_returns_none() {
307        let result = QueryBuilder::new().build();
308        assert!(result.is_none());
309    }
310
311    #[test]
312    fn test_string_param_some() {
313        let result = QueryBuilder::new()
314            .string("name", Some("alice".to_string()))
315            .build();
316        assert_eq!(
317            result,
318            Some(vec![("name".to_string(), "alice".to_string())])
319        );
320    }
321
322    #[test]
323    fn test_string_param_none_skipped() {
324        let result = QueryBuilder::new().string("name", None::<String>).build();
325        assert!(result.is_none());
326    }
327
328    #[test]
329    fn test_int_param() {
330        let result = QueryBuilder::new().int("page", Some(42i64)).build();
331        assert_eq!(result, Some(vec![("page".to_string(), "42".to_string())]));
332    }
333
334    #[test]
335    fn test_int_param_none_skipped() {
336        let result = QueryBuilder::new().int("page", None::<i64>).build();
337        assert!(result.is_none());
338    }
339
340    #[test]
341    fn test_float_param() {
342        let result = QueryBuilder::new().float("score", Some(3.14f64)).build();
343        assert_eq!(
344            result,
345            Some(vec![("score".to_string(), "3.14".to_string())])
346        );
347    }
348
349    #[test]
350    fn test_bool_param() {
351        let result = QueryBuilder::new().bool("active", Some(true)).build();
352        assert_eq!(
353            result,
354            Some(vec![("active".to_string(), "true".to_string())])
355        );
356    }
357
358    #[test]
359    fn test_datetime_param_formats_rfc3339() {
360        let dt = Utc.with_ymd_and_hms(2024, 1, 15, 9, 30, 0).unwrap();
361        let result = QueryBuilder::new().datetime("since", Some(dt)).build();
362        assert_eq!(
363            result,
364            Some(vec![(
365                "since".to_string(),
366                "2024-01-15T09:30:00Z".to_string()
367            )])
368        );
369    }
370
371    #[test]
372    fn test_date_param_converts_to_midnight_utc() {
373        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
374        let result = QueryBuilder::new().date("on", Some(date)).build();
375        assert_eq!(
376            result,
377            Some(vec![("on".to_string(), "2024-01-15T00:00:00Z".to_string())])
378        );
379    }
380
381    #[test]
382    fn test_string_array_multiple_entries() {
383        let result = QueryBuilder::new()
384            .string_array(
385                "tag",
386                vec!["a".to_string(), "b".to_string(), "c".to_string()],
387            )
388            .build();
389        assert_eq!(
390            result,
391            Some(vec![
392                ("tag".to_string(), "a".to_string()),
393                ("tag".to_string(), "b".to_string()),
394                ("tag".to_string(), "c".to_string()),
395            ])
396        );
397    }
398
399    #[test]
400    fn test_int_array() {
401        let result = QueryBuilder::new()
402            .int_array("ids", vec![1i64, 2, 3])
403            .build();
404        assert_eq!(
405            result,
406            Some(vec![
407                ("ids".to_string(), "1".to_string()),
408                ("ids".to_string(), "2".to_string()),
409                ("ids".to_string(), "3".to_string()),
410            ])
411        );
412    }
413
414    #[test]
415    fn test_float_array() {
416        let result = QueryBuilder::new()
417            .float_array("scores", vec![1.1f64, 2.2])
418            .build();
419        assert_eq!(
420            result,
421            Some(vec![
422                ("scores".to_string(), "1.1".to_string()),
423                ("scores".to_string(), "2.2".to_string()),
424            ])
425        );
426    }
427
428    #[test]
429    fn test_bool_array() {
430        let result = QueryBuilder::new()
431            .bool_array("flags", vec![true, false])
432            .build();
433        assert_eq!(
434            result,
435            Some(vec![
436                ("flags".to_string(), "true".to_string()),
437                ("flags".to_string(), "false".to_string()),
438            ])
439        );
440    }
441
442    #[test]
443    fn test_serialize_strips_json_quotes() {
444        let result = QueryBuilder::new()
445            .serialize("status", Some("active"))
446            .build();
447        assert_eq!(
448            result,
449            Some(vec![("status".to_string(), "active".to_string())])
450        );
451    }
452
453    #[test]
454    fn test_serialize_none_skipped() {
455        let result = QueryBuilder::new()
456            .serialize::<String>("status", None)
457            .build();
458        assert!(result.is_none());
459    }
460
461    #[test]
462    fn test_serialize_numeric_no_quotes() {
463        let result = QueryBuilder::new().serialize("count", Some(42)).build();
464        assert_eq!(result, Some(vec![("count".to_string(), "42".to_string())]));
465    }
466
467    #[test]
468    fn test_serialize_array_skips_null() {
469        let values: Vec<Option<&str>> = vec![Some("a"), None, Some("b")];
470        let result = QueryBuilder::new().serialize_array("items", values).build();
471        assert_eq!(
472            result,
473            Some(vec![
474                ("items".to_string(), "a".to_string()),
475                ("items".to_string(), "b".to_string()),
476            ])
477        );
478    }
479
480    #[test]
481    fn test_method_chaining() {
482        let result = QueryBuilder::new()
483            .string("name", Some("alice".to_string()))
484            .int("page", Some(1i64))
485            .bool("active", Some(true))
486            .build();
487        assert_eq!(
488            result,
489            Some(vec![
490                ("name".to_string(), "alice".to_string()),
491                ("page".to_string(), "1".to_string()),
492                ("active".to_string(), "true".to_string()),
493            ])
494        );
495    }
496
497    // ===========================
498    // parse_structured_query tests
499    // ===========================
500
501    #[test]
502    fn test_parse_simple_key_value() {
503        let result = parse_structured_query("status:active").unwrap();
504        assert_eq!(result, vec![("status".to_string(), "active".to_string())]);
505    }
506
507    #[test]
508    fn test_parse_comma_separated_values() {
509        let result = parse_structured_query("type:sensor,camera").unwrap();
510        assert_eq!(
511            result,
512            vec![
513                ("type".to_string(), "sensor".to_string()),
514                ("type".to_string(), "camera".to_string()),
515            ]
516        );
517    }
518
519    #[test]
520    fn test_parse_multiple_terms() {
521        let result = parse_structured_query("status:active type:sensor").unwrap();
522        assert_eq!(
523            result,
524            vec![
525                ("status".to_string(), "active".to_string()),
526                ("type".to_string(), "sensor".to_string()),
527            ]
528        );
529    }
530
531    #[test]
532    fn test_parse_quoted_value() {
533        let result = parse_structured_query("location:\"New York\"").unwrap();
534        assert_eq!(
535            result,
536            vec![("location".to_string(), "New York".to_string())]
537        );
538    }
539
540    #[test]
541    fn test_parse_bare_word_returns_error() {
542        let result = parse_structured_query("bareword");
543        assert!(result.is_err());
544    }
545
546    #[test]
547    fn test_structured_query_builder_fallback() {
548        // When parsing fails, structured_query falls back to simple param
549        let result = QueryBuilder::new()
550            .structured_query("q", Some("bareword".to_string()))
551            .build();
552        assert_eq!(
553            result,
554            Some(vec![("q".to_string(), "bareword".to_string())])
555        );
556    }
557
558    #[test]
559    fn test_structured_query_builder_parses() {
560        let result = QueryBuilder::new()
561            .structured_query("q", Some("status:active".to_string()))
562            .build();
563        assert_eq!(
564            result,
565            Some(vec![("status".to_string(), "active".to_string())])
566        );
567    }
568
569    #[test]
570    fn test_structured_query_none_skipped() {
571        let result = QueryBuilder::new()
572            .structured_query("q", None::<String>)
573            .build();
574        assert!(result.is_none());
575    }
576}