clawspec_core/client/
query.rs

1use indexmap::IndexMap;
2use utoipa::openapi::Required;
3use utoipa::openapi::path::{Parameter, ParameterIn};
4
5use super::param::ParameterValue;
6use super::param::ResolvedParamValue;
7use super::schema::Schemas;
8use super::{ApiClientError, ParamStyle, ParamValue};
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(super) 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(super) fn is_empty(&self) -> bool {
121        self.params.is_empty()
122    }
123
124    /// Convert query parameters to OpenAPI Parameters
125    pub(super) 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(super) 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            }
151        }
152
153        serde_urlencoded::to_string(&pairs).map_err(ApiClientError::from)
154    }
155
156    /// Encode a parameter using form style (default)
157    fn encode_form_style(
158        &self,
159        name: &str,
160        resolved: &ResolvedParamValue,
161        pairs: &mut Vec<(String, String)>,
162    ) -> Result<(), ApiClientError> {
163        match resolved.to_query_values() {
164            Ok(values) => {
165                for value in values {
166                    pairs.push((name.to_string(), value));
167                }
168                Ok(())
169            }
170            Err(err) => Err(err),
171        }
172    }
173
174    /// Encode a parameter using delimited style (space or pipe)
175    fn encode_delimited_style(
176        &self,
177        name: &str,
178        resolved: &ResolvedParamValue,
179        pairs: &mut Vec<(String, String)>,
180    ) -> Result<(), ApiClientError> {
181        match resolved.to_string_value() {
182            Ok(value) => {
183                pairs.push((name.to_string(), value));
184                Ok(())
185            }
186            Err(err) => Err(err),
187        }
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn test_call_query_basic_usage() {
197        let query = CallQuery::new();
198
199        assert!(query.is_empty());
200
201        let query = query
202            .add_param("name", ParamValue::new("test"))
203            .add_param("age", ParamValue::new(25));
204
205        assert!(!query.is_empty());
206    }
207
208    #[test]
209    fn test_ergonomic_api_with_direct_values() {
210        // Test that we can pass values directly without wrapping in ParamValue::new()
211        let query = CallQuery::new()
212            .add_param("name", "test")
213            .add_param("age", 25)
214            .add_param("active", true)
215            .add_param("tags", vec!["rust", "web"]);
216
217        assert!(!query.is_empty());
218
219        let query_string = query.to_query_string().expect("should serialize");
220        insta::assert_debug_snapshot!(query_string, @r#""name=test&age=25&active=true&tags=rust&tags=web""#);
221    }
222
223    #[test]
224    fn test_mixed_ergonomic_and_explicit_api() {
225        // Test mixing direct values with explicit ParamValue wrappers
226        let query = CallQuery::new()
227            .add_param("name", "test") // Direct value
228            .add_param("limit", 10) // Direct value
229            .add_param(
230                "tags",
231                ParamValue::with_style(
232                    // Explicit ParamValue with custom style
233                    vec!["rust", "web"],
234                    ParamStyle::SpaceDelimited,
235                ),
236            );
237
238        let query_string = query.to_query_string().expect("should serialize");
239        insta::assert_debug_snapshot!(query_string, @r#""name=test&limit=10&tags=rust+web""#);
240    }
241
242    #[test]
243    fn test_query_param_as_query_value() {
244        let query = ParamValue::new("hello world");
245        let value = query.as_query_value().expect("should have value");
246
247        insta::assert_debug_snapshot!(value, @r#"String("hello world")"#);
248    }
249
250    #[test]
251    fn test_query_param_with_different_styles() {
252        let default_query = ParamValue::new("test");
253        assert_eq!(default_query.style, ParamStyle::Default);
254
255        let form_query = ParamValue::with_style("test", ParamStyle::Form);
256        assert_eq!(form_query.style, ParamStyle::Form);
257
258        let space_query = ParamValue::with_style("test", ParamStyle::SpaceDelimited);
259        assert_eq!(space_query.style, ParamStyle::SpaceDelimited);
260
261        let pipe_query = ParamValue::with_style("test", ParamStyle::PipeDelimited);
262        assert_eq!(pipe_query.style, ParamStyle::PipeDelimited);
263    }
264
265    #[test]
266    fn test_query_string_serialization_form_style() {
267        let query = CallQuery::new()
268            .add_param("name", ParamValue::new("john"))
269            .add_param("age", ParamValue::new(25));
270
271        let query_string = query.to_query_string().expect("should serialize");
272        insta::assert_debug_snapshot!(query_string, @r#""name=john&age=25""#);
273    }
274
275    #[test]
276    fn test_query_string_serialization_with_arrays() {
277        let query = CallQuery::new().add_param("tags", ParamValue::new(vec!["rust", "web", "api"]));
278
279        let query_string = query.to_query_string().expect("should serialize");
280        insta::assert_debug_snapshot!(query_string, @r#""tags=rust&tags=web&tags=api""#);
281    }
282
283    #[test]
284    fn test_query_string_serialization_space_delimited() {
285        let query = CallQuery::new().add_param(
286            "tags",
287            ParamValue::with_style(vec!["rust", "web", "api"], ParamStyle::SpaceDelimited),
288        );
289
290        let query_string = query.to_query_string().expect("should serialize");
291        insta::assert_debug_snapshot!(query_string, @r#""tags=rust+web+api""#);
292    }
293
294    #[test]
295    fn test_query_string_serialization_pipe_delimited() {
296        let query = CallQuery::new().add_param(
297            "tags",
298            ParamValue::with_style(vec!["rust", "web", "api"], ParamStyle::PipeDelimited),
299        );
300
301        let query_string = query.to_query_string().expect("should serialize");
302        insta::assert_debug_snapshot!(query_string, @r#""tags=rust%7Cweb%7Capi""#);
303    }
304
305    #[test]
306    fn test_empty_query_serialization() {
307        let query = CallQuery::new();
308        let query_string = query.to_query_string().expect("should serialize");
309        insta::assert_debug_snapshot!(query_string, @r#""""#);
310    }
311
312    #[test]
313    fn test_mixed_parameter_types() {
314        let query = CallQuery::new()
315            .add_param("name", ParamValue::new("john"))
316            .add_param("active", ParamValue::new(true))
317            .add_param("scores", ParamValue::new(vec![10, 20, 30]));
318
319        let query_string = query.to_query_string().expect("should serialize");
320        insta::assert_debug_snapshot!(query_string, @r#""name=john&active=true&scores=10&scores=20&scores=30""#);
321    }
322
323    #[test]
324    fn test_object_query_parameter_error() {
325        use serde_json::json;
326
327        let query = CallQuery::new().add_param("config", ParamValue::new(json!({"key": "value"})));
328
329        let result = query.to_query_string();
330        assert!(matches!(
331            result,
332            Err(ApiClientError::UnsupportedParameterValue { .. })
333        ));
334    }
335
336    #[test]
337    fn test_nested_object_in_array_error() {
338        use serde_json::json;
339
340        let query = CallQuery::new().add_param(
341            "items",
342            ParamValue::new(json!(["valid", {"nested": "object"}])),
343        );
344
345        let result = query.to_query_string();
346        assert!(matches!(
347            result,
348            Err(ApiClientError::UnsupportedParameterValue { .. })
349        ));
350    }
351
352    #[test]
353    fn test_to_parameters_generates_correct_openapi_parameters() {
354        let query = CallQuery::new()
355            .add_param("name", ParamValue::new("test"))
356            .add_param(
357                "tags",
358                ParamValue::with_style(vec!["a", "b"], ParamStyle::SpaceDelimited),
359            )
360            .add_param(
361                "limit",
362                ParamValue::with_style(10, ParamStyle::PipeDelimited),
363            );
364
365        let parameters: Vec<_> = query.to_parameters().collect();
366
367        assert_eq!(parameters.len(), 3);
368
369        // Check that all parameters are query parameters and not required
370        for param in &parameters {
371            assert_eq!(param.parameter_in, ParameterIn::Query);
372            assert_eq!(param.required, Required::False);
373            assert!(param.schema.is_some());
374            // Style can be None for default parameters
375        }
376
377        // Check parameter names
378        let param_names: std::collections::HashSet<_> =
379            parameters.iter().map(|p| p.name.as_str()).collect();
380        assert!(param_names.contains("name"));
381        assert!(param_names.contains("tags"));
382        assert!(param_names.contains("limit"));
383    }
384
385    #[test]
386    fn test_comprehensive_query_serialization_snapshot() {
387        // Test various data types with different styles
388        let query = CallQuery::new()
389            .add_param("search", ParamValue::new("hello world"))
390            .add_param("active", ParamValue::new(true))
391            .add_param("count", ParamValue::new(42))
392            .add_param("tags", ParamValue::new(vec!["rust", "api", "web"]))
393            .add_param(
394                "categories",
395                ParamValue::with_style(vec!["tech", "programming"], ParamStyle::SpaceDelimited),
396            )
397            .add_param(
398                "ids",
399                ParamValue::with_style(vec![1, 2, 3], ParamStyle::PipeDelimited),
400            );
401
402        let query_string = query
403            .to_query_string()
404            .expect("serialization should succeed");
405        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""#);
406    }
407
408    #[test]
409    fn test_query_parameters_openapi_generation_snapshot() {
410        let query = CallQuery::new()
411            .add_param("q", ParamValue::new("search term"))
412            .add_param(
413                "filters",
414                ParamValue::with_style(vec!["active", "verified"], ParamStyle::SpaceDelimited),
415            )
416            .add_param(
417                "sort",
418                ParamValue::with_style(vec!["name", "date"], ParamStyle::PipeDelimited),
419            );
420
421        let parameters: Vec<_> = query.to_parameters().collect();
422        let debug_params: Vec<_> = parameters
423            .iter()
424            .map(|p| {
425                format!(
426                    "{}({:?})",
427                    p.name,
428                    p.style
429                        .as_ref()
430                        .unwrap_or(&utoipa::openapi::path::ParameterStyle::Form)
431                )
432            })
433            .collect();
434
435        insta::assert_debug_snapshot!(debug_params, @r#"
436        [
437            "q(Form)",
438            "filters(SpaceDelimited)",
439            "sort(PipeDelimited)",
440        ]
441        "#);
442    }
443
444    #[test]
445    fn test_empty_and_null_values_snapshot() {
446        let query = CallQuery::new()
447            .add_param("empty", ParamValue::new(""))
448            .add_param("nullable", ParamValue::new(serde_json::Value::Null));
449
450        let query_string = query
451            .to_query_string()
452            .expect("serialization should succeed");
453        insta::assert_debug_snapshot!(query_string, @r#""empty=&nullable=""#);
454    }
455
456    #[test]
457    fn test_special_characters_encoding_snapshot() {
458        let query = CallQuery::new()
459            .add_param("special", ParamValue::new("hello & goodbye"))
460            .add_param("unicode", ParamValue::new("café résumé"))
461            .add_param("symbols", ParamValue::new("100% guaranteed!"));
462
463        let query_string = query
464            .to_query_string()
465            .expect("serialization should succeed");
466        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""#);
467    }
468}