clawspec_core/client/parameters/
query.rs

1use indexmap::IndexMap;
2use utoipa::openapi::Required;
3use utoipa::openapi::path::{Parameter, ParameterIn};
4
5use super::param::{ParameterValue, ResolvedParamValue};
6use super::{ParamStyle, ParamValue};
7use crate::client::error::ApiClientError;
8use crate::client::openapi::schema::Schemas;
9
10/// A collection of query parameters for HTTP requests with OpenAPI 3.1 support.
11///
12/// `CallQuery` provides a type-safe way to build and serialize query parameters
13/// for HTTP requests. It supports different parameter styles as defined by the
14/// OpenAPI 3.1 specification and automatically generates OpenAPI parameter schemas.
15///
16/// # Examples
17///
18/// ## Basic Usage
19///
20/// ```rust
21/// use clawspec_core::{CallQuery, ParamValue, ParamStyle};
22///
23/// let query = CallQuery::new()
24///     .add_param("search", ParamValue::new("hello world"))
25///     .add_param("limit", ParamValue::new(10))
26///     .add_param("active", ParamValue::new(true));
27///
28/// // This would generate: ?search=hello+world&limit=10&active=true
29/// ```
30///
31/// ## Array Parameters with Different Styles
32///
33/// ```rust
34/// use clawspec_core::{CallQuery, ParamValue, ParamStyle};
35/// let query = CallQuery::new()
36///     // Form style (default): ?tags=rust&tags=web&tags=api
37///     .add_param("tags", ParamValue::new(vec!["rust", "web", "api"]))
38///     // Space delimited: ?categories=tech%20programming
39///     .add_param("categories", ParamValue::with_style(
40///         vec!["tech", "programming"],
41///         ParamStyle::SpaceDelimited
42///     ))
43///     // Pipe delimited: ?ids=1|2|3
44///     .add_param("ids", ParamValue::with_style(
45///         vec![1, 2, 3],
46///         ParamStyle::PipeDelimited
47///     ));
48/// ```
49///
50/// # Type Safety
51///
52/// The query system is type-safe and will prevent invalid parameter types:
53/// - Objects are not supported as query parameters (will return an error)
54/// - All parameters must implement `Serialize` and `ToSchema` traits
55/// - Parameters are automatically converted to appropriate string representations
56#[derive(Debug, Default, Clone)]
57pub struct CallQuery {
58    params: IndexMap<String, ResolvedParamValue>,
59    pub(in crate::client) schemas: Schemas,
60}
61
62impl CallQuery {
63    /// Creates a new empty query parameter collection.
64    ///
65    /// # Examples
66    ///
67    /// ```rust
68    /// use clawspec_core::CallQuery;
69    ///
70    /// let query = CallQuery::new();
71    /// // Query is initially empty
72    /// ```
73    pub fn new() -> Self {
74        Self::default()
75    }
76
77    /// Adds a query parameter with the given name and value using the builder pattern.
78    ///
79    /// This method consumes `self` and returns a new `CallQuery` with the parameter added,
80    /// allowing for method chaining. The parameter must implement both `Serialize` and
81    /// `ToSchema` traits for proper serialization and OpenAPI schema generation.
82    ///
83    /// # Parameters
84    ///
85    /// - `name`: The parameter name (will be converted to `String`)
86    /// - `param`: The parameter value that can be converted into `ParamValue<T>`
87    ///
88    /// # Examples
89    ///
90    /// ```rust
91    /// use clawspec_core::{CallQuery, ParamValue, ParamStyle};
92    ///
93    /// // Ergonomic usage - pass values directly
94    /// let query = CallQuery::new()
95    ///     .add_param("search", "hello")
96    ///     .add_param("limit", 10)
97    ///     .add_param("active", true);
98    ///
99    /// // Explicit ParamValue usage for custom styles
100    /// let query = CallQuery::new()
101    ///     .add_param("tags", ParamValue::with_style(
102    ///         vec!["rust", "web"],
103    ///         ParamStyle::SpaceDelimited
104    ///     ));
105    /// ```
106    pub fn add_param<T: ParameterValue>(
107        mut self,
108        name: impl Into<String>,
109        param: impl Into<ParamValue<T>>,
110    ) -> Self {
111        let name = name.into();
112        let param = param.into();
113        if let Some(resolved) = param.resolve(|value| self.schemas.add_example::<T>(value)) {
114            self.params.insert(name, resolved);
115        }
116        self
117    }
118
119    /// Check if the query is empty
120    pub(in crate::client) fn is_empty(&self) -> bool {
121        self.params.is_empty()
122    }
123
124    /// Convert query parameters to OpenAPI Parameters
125    pub(in crate::client) fn to_parameters(&self) -> impl Iterator<Item = Parameter> + '_ {
126        // For query parameters, we need to create a schema for each parameter
127        self.params.iter().map(|(name, resolved)| {
128            Parameter::builder()
129                .name(name)
130                .parameter_in(ParameterIn::Query)
131                .required(Required::False) // Query parameters are typically optional
132                .schema(Some(resolved.schema.clone()))
133                .style(resolved.style.into())
134                .build()
135        })
136    }
137
138    /// Serialize query parameters to URL-encoded string
139    pub(in crate::client) fn to_query_string(&self) -> Result<String, ApiClientError> {
140        let mut pairs = Vec::new();
141
142        for (name, resolved) in &self.params {
143            match resolved.style {
144                ParamStyle::Default | ParamStyle::Form => {
145                    self.encode_form_style(name, resolved, &mut pairs)?;
146                }
147                ParamStyle::SpaceDelimited | ParamStyle::PipeDelimited | ParamStyle::Simple => {
148                    self.encode_delimited_style(name, resolved, &mut pairs)?;
149                }
150                ParamStyle::DeepObject => {
151                    self.encode_deep_object_style(name, resolved, &mut pairs)?;
152                }
153                ParamStyle::Label | ParamStyle::Matrix => {
154                    return Err(ApiClientError::UnsupportedParameterValue {
155                        message: format!(
156                            "Parameter style {:?} is not supported for query parameters",
157                            resolved.style
158                        ),
159                        value: resolved.value.clone(),
160                    });
161                }
162            }
163        }
164
165        serde_urlencoded::to_string(&pairs).map_err(ApiClientError::from)
166    }
167
168    /// Encode a parameter using form style (default)
169    fn encode_form_style(
170        &self,
171        name: &str,
172        resolved: &ResolvedParamValue,
173        pairs: &mut Vec<(String, String)>,
174    ) -> Result<(), ApiClientError> {
175        match resolved.to_query_values() {
176            Ok(values) => {
177                for value in values {
178                    pairs.push((name.to_string(), value));
179                }
180                Ok(())
181            }
182            Err(err) => Err(err),
183        }
184    }
185
186    /// Encode a parameter using delimited style (space or pipe)
187    fn encode_delimited_style(
188        &self,
189        name: &str,
190        resolved: &ResolvedParamValue,
191        pairs: &mut Vec<(String, String)>,
192    ) -> Result<(), ApiClientError> {
193        match resolved.to_string_value() {
194            Ok(value) => {
195                pairs.push((name.to_string(), value));
196                Ok(())
197            }
198            Err(err) => Err(err),
199        }
200    }
201
202    /// Encode a parameter using deep object style (?obj[key]=value)
203    fn encode_deep_object_style(
204        &self,
205        name: &str,
206        resolved: &ResolvedParamValue,
207        pairs: &mut Vec<(String, String)>,
208    ) -> Result<(), ApiClientError> {
209        match &resolved.value {
210            serde_json::Value::Object(obj) => {
211                // Deep object style: param[key]=value
212                for (key, value) in obj {
213                    let param_name = format!("{name}[{key}]");
214                    let param_value = match value {
215                        serde_json::Value::String(s) => s.clone(),
216                        serde_json::Value::Number(n) => n.to_string(),
217                        serde_json::Value::Bool(b) => b.to_string(),
218                        serde_json::Value::Null => String::new(),
219                        serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
220                            return Err(ApiClientError::UnsupportedParameterValue {
221                                message:
222                                    "nested arrays and objects not supported in DeepObject style"
223                                        .to_string(),
224                                value: value.clone(),
225                            });
226                        }
227                    };
228                    pairs.push((param_name, param_value));
229                }
230                Ok(())
231            }
232            _ => Err(ApiClientError::UnsupportedParameterValue {
233                message: "DeepObject style requires object values".to_string(),
234                value: resolved.value.clone(),
235            }),
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_call_query_basic_usage() {
246        let query = CallQuery::new();
247
248        assert!(query.is_empty());
249
250        let query = query
251            .add_param("name", ParamValue::new("test"))
252            .add_param("age", ParamValue::new(25));
253
254        assert!(!query.is_empty());
255    }
256
257    #[test]
258    fn test_ergonomic_api_with_direct_values() {
259        // Test that we can pass values directly without wrapping in ParamValue::new()
260        let query = CallQuery::new()
261            .add_param("name", "test")
262            .add_param("age", 25)
263            .add_param("active", true)
264            .add_param("tags", vec!["rust", "web"]);
265
266        assert!(!query.is_empty());
267
268        let query_string = query.to_query_string().expect("should serialize");
269        insta::assert_debug_snapshot!(query_string, @r#""name=test&age=25&active=true&tags=rust&tags=web""#);
270    }
271
272    #[test]
273    fn test_mixed_ergonomic_and_explicit_api() {
274        // Test mixing direct values with explicit ParamValue wrappers
275        let query = CallQuery::new()
276            .add_param("name", "test") // Direct value
277            .add_param("limit", 10) // Direct value
278            .add_param(
279                "tags",
280                ParamValue::with_style(
281                    // Explicit ParamValue with custom style
282                    vec!["rust", "web"],
283                    ParamStyle::SpaceDelimited,
284                ),
285            );
286
287        let query_string = query.to_query_string().expect("should serialize");
288        insta::assert_debug_snapshot!(query_string, @r#""name=test&limit=10&tags=rust+web""#);
289    }
290
291    #[test]
292    fn test_query_param_as_query_value() {
293        let query = ParamValue::new("hello world");
294        let value = query.as_query_value().expect("should have value");
295
296        insta::assert_debug_snapshot!(value, @r#"String("hello world")"#);
297    }
298
299    #[test]
300    fn test_query_param_with_different_styles() {
301        let default_query = ParamValue::new("test");
302        assert_eq!(default_query.style, ParamStyle::Default);
303
304        let form_query = ParamValue::with_style("test", ParamStyle::Form);
305        assert_eq!(form_query.style, ParamStyle::Form);
306
307        let space_query = ParamValue::with_style("test", ParamStyle::SpaceDelimited);
308        assert_eq!(space_query.style, ParamStyle::SpaceDelimited);
309
310        let pipe_query = ParamValue::with_style("test", ParamStyle::PipeDelimited);
311        assert_eq!(pipe_query.style, ParamStyle::PipeDelimited);
312    }
313
314    #[test]
315    fn test_query_string_serialization_form_style() {
316        let query = CallQuery::new()
317            .add_param("name", ParamValue::new("john"))
318            .add_param("age", ParamValue::new(25));
319
320        let query_string = query.to_query_string().expect("should serialize");
321        insta::assert_debug_snapshot!(query_string, @r#""name=john&age=25""#);
322    }
323
324    #[test]
325    fn test_query_string_serialization_with_arrays() {
326        let query = CallQuery::new().add_param("tags", ParamValue::new(vec!["rust", "web", "api"]));
327
328        let query_string = query.to_query_string().expect("should serialize");
329        insta::assert_debug_snapshot!(query_string, @r#""tags=rust&tags=web&tags=api""#);
330    }
331
332    #[test]
333    fn test_query_string_serialization_space_delimited() {
334        let query = CallQuery::new().add_param(
335            "tags",
336            ParamValue::with_style(vec!["rust", "web", "api"], ParamStyle::SpaceDelimited),
337        );
338
339        let query_string = query.to_query_string().expect("should serialize");
340        insta::assert_debug_snapshot!(query_string, @r#""tags=rust+web+api""#);
341    }
342
343    #[test]
344    fn test_query_string_serialization_pipe_delimited() {
345        let query = CallQuery::new().add_param(
346            "tags",
347            ParamValue::with_style(vec!["rust", "web", "api"], ParamStyle::PipeDelimited),
348        );
349
350        let query_string = query.to_query_string().expect("should serialize");
351        insta::assert_debug_snapshot!(query_string, @r#""tags=rust%7Cweb%7Capi""#);
352    }
353
354    #[test]
355    fn test_empty_query_serialization() {
356        let query = CallQuery::new();
357        let query_string = query.to_query_string().expect("should serialize");
358        insta::assert_debug_snapshot!(query_string, @r#""""#);
359    }
360
361    #[test]
362    fn test_mixed_parameter_types() {
363        let query = CallQuery::new()
364            .add_param("name", ParamValue::new("john"))
365            .add_param("active", ParamValue::new(true))
366            .add_param("scores", ParamValue::new(vec![10, 20, 30]));
367
368        let query_string = query.to_query_string().expect("should serialize");
369        insta::assert_debug_snapshot!(query_string, @r#""name=john&active=true&scores=10&scores=20&scores=30""#);
370    }
371
372    #[test]
373    fn test_object_query_parameter_error() {
374        use serde_json::json;
375
376        let query = CallQuery::new().add_param("config", ParamValue::new(json!({"key": "value"})));
377
378        let result = query.to_query_string();
379        assert!(matches!(
380            result,
381            Err(ApiClientError::UnsupportedParameterValue { .. })
382        ));
383    }
384
385    #[test]
386    fn test_nested_object_in_array_error() {
387        use serde_json::json;
388
389        let query = CallQuery::new().add_param(
390            "items",
391            ParamValue::new(json!(["valid", {"nested": "object"}])),
392        );
393
394        let result = query.to_query_string();
395        assert!(matches!(
396            result,
397            Err(ApiClientError::UnsupportedParameterValue { .. })
398        ));
399    }
400
401    #[test]
402    fn test_to_parameters_generates_correct_openapi_parameters() {
403        let query = CallQuery::new()
404            .add_param("name", ParamValue::new("test"))
405            .add_param(
406                "tags",
407                ParamValue::with_style(vec!["a", "b"], ParamStyle::SpaceDelimited),
408            )
409            .add_param(
410                "limit",
411                ParamValue::with_style(10, ParamStyle::PipeDelimited),
412            );
413
414        let parameters: Vec<_> = query.to_parameters().collect();
415
416        assert_eq!(parameters.len(), 3);
417
418        // Check that all parameters are query parameters and not required
419        for param in &parameters {
420            assert_eq!(param.parameter_in, ParameterIn::Query);
421            assert_eq!(param.required, Required::False);
422            assert!(param.schema.is_some());
423            // Style can be None for default parameters
424        }
425
426        // Check parameter names
427        let param_names: std::collections::HashSet<_> =
428            parameters.iter().map(|p| p.name.as_str()).collect();
429        assert!(param_names.contains("name"));
430        assert!(param_names.contains("tags"));
431        assert!(param_names.contains("limit"));
432    }
433
434    #[test]
435    fn test_comprehensive_query_serialization_snapshot() {
436        // Test various data types with different styles
437        let query = CallQuery::new()
438            .add_param("search", ParamValue::new("hello world"))
439            .add_param("active", ParamValue::new(true))
440            .add_param("count", ParamValue::new(42))
441            .add_param("tags", ParamValue::new(vec!["rust", "api", "web"]))
442            .add_param(
443                "categories",
444                ParamValue::with_style(vec!["tech", "programming"], ParamStyle::SpaceDelimited),
445            )
446            .add_param(
447                "ids",
448                ParamValue::with_style(vec![1, 2, 3], ParamStyle::PipeDelimited),
449            );
450
451        let query_string = query
452            .to_query_string()
453            .expect("serialization should succeed");
454        insta::assert_debug_snapshot!(query_string, @r#""search=hello+world&active=true&count=42&tags=rust&tags=api&tags=web&categories=tech+programming&ids=1%7C2%7C3""#);
455    }
456
457    #[test]
458    fn test_query_parameters_openapi_generation_snapshot() {
459        let query = CallQuery::new()
460            .add_param("q", ParamValue::new("search term"))
461            .add_param(
462                "filters",
463                ParamValue::with_style(vec!["active", "verified"], ParamStyle::SpaceDelimited),
464            )
465            .add_param(
466                "sort",
467                ParamValue::with_style(vec!["name", "date"], ParamStyle::PipeDelimited),
468            );
469
470        let parameters: Vec<_> = query.to_parameters().collect();
471        let debug_params: Vec<_> = parameters
472            .iter()
473            .map(|p| {
474                format!(
475                    "{}({:?})",
476                    p.name,
477                    p.style
478                        .as_ref()
479                        .unwrap_or(&utoipa::openapi::path::ParameterStyle::Form)
480                )
481            })
482            .collect();
483
484        insta::assert_debug_snapshot!(debug_params, @r#"
485        [
486            "q(Form)",
487            "filters(SpaceDelimited)",
488            "sort(PipeDelimited)",
489        ]
490        "#);
491    }
492
493    #[test]
494    fn test_empty_and_null_values_snapshot() {
495        let query = CallQuery::new()
496            .add_param("empty", ParamValue::new(""))
497            .add_param("nullable", ParamValue::new(serde_json::Value::Null));
498
499        let query_string = query
500            .to_query_string()
501            .expect("serialization should succeed");
502        insta::assert_debug_snapshot!(query_string, @r#""empty=&nullable=""#);
503    }
504
505    #[test]
506    fn test_special_characters_encoding_snapshot() {
507        let query = CallQuery::new()
508            .add_param("special", ParamValue::new("hello & goodbye"))
509            .add_param("unicode", ParamValue::new("café résumé"))
510            .add_param("symbols", ParamValue::new("100% guaranteed!"));
511
512        let query_string = query
513            .to_query_string()
514            .expect("serialization should succeed");
515        insta::assert_debug_snapshot!(query_string, @r#""special=hello+%26+goodbye&unicode=caf%C3%A9+r%C3%A9sum%C3%A9&symbols=100%25+guaranteed%21""#);
516    }
517
518    #[test]
519    fn test_deep_object_style_with_object() {
520        use serde_json::json;
521
522        let query = CallQuery::new().add_param(
523            "user",
524            ParamValue::with_style(
525                json!({"name": "john", "age": 30, "active": true}),
526                ParamStyle::DeepObject,
527            ),
528        );
529
530        let query_string = query
531            .to_query_string()
532            .expect("serialization should succeed");
533
534        // The order of parameters may vary, so check that it contains the expected parts
535        assert!(query_string.contains("user%5Bname%5D=john"));
536        assert!(query_string.contains("user%5Bage%5D=30"));
537        assert!(query_string.contains("user%5Bactive%5D=true"));
538    }
539
540    #[test]
541    fn test_deep_object_style_with_nested_object_error() {
542        use serde_json::json;
543
544        let query = CallQuery::new().add_param(
545            "user",
546            ParamValue::with_style(
547                json!({"name": "john", "address": {"street": "123 Main St"}}),
548                ParamStyle::DeepObject,
549            ),
550        );
551
552        let result = query.to_query_string();
553        assert!(matches!(
554            result,
555            Err(ApiClientError::UnsupportedParameterValue { .. })
556        ));
557    }
558
559    #[test]
560    fn test_deep_object_style_with_array_error() {
561        let query = CallQuery::new().add_param(
562            "tags",
563            ParamValue::with_style(vec!["rust", "web"], ParamStyle::DeepObject),
564        );
565
566        let result = query.to_query_string();
567        assert!(matches!(
568            result,
569            Err(ApiClientError::UnsupportedParameterValue { .. })
570        ));
571    }
572
573    #[test]
574    fn test_label_and_matrix_styles_error_in_query() {
575        let query =
576            CallQuery::new().add_param("test", ParamValue::with_style("value", ParamStyle::Label));
577
578        let result = query.to_query_string();
579        assert!(matches!(
580            result,
581            Err(ApiClientError::UnsupportedParameterValue { .. })
582        ));
583
584        let query =
585            CallQuery::new().add_param("test", ParamValue::with_style("value", ParamStyle::Matrix));
586
587        let result = query.to_query_string();
588        assert!(matches!(
589            result,
590            Err(ApiClientError::UnsupportedParameterValue { .. })
591        ));
592    }
593}