clawspec_core/client/parameters/
cookies.rs

1use indexmap::IndexMap;
2use utoipa::openapi::Required;
3use utoipa::openapi::path::{Parameter, ParameterBuilder, ParameterIn};
4
5use super::param::{ParamValue, ParameterValue, ResolvedParamValue};
6use crate::client::error::ApiClientError;
7use crate::client::openapi::schema::Schemas;
8
9/// Represents HTTP cookies for an API call.
10///
11/// This struct manages HTTP cookies using the same ParamValue pattern as query, path, and header parameters,
12/// allowing for type-safe cookie handling with automatic OpenAPI schema generation.
13///
14/// # Examples
15///
16/// ```rust
17/// use clawspec_core::CallCookies;
18///
19/// let cookies = CallCookies::new()
20///     .add_cookie("session_id", "abc123")
21///     .add_cookie("user_id", 12345)
22///     .add_cookie("preferences", "dark_mode=true");
23/// ```
24///
25/// # OpenAPI Integration
26///
27/// Cookies are automatically documented in the OpenAPI specification with `in: cookie` parameter type.
28/// This follows the OpenAPI 3.1.0 specification for cookie parameters.
29#[derive(Debug, Clone, Default)]
30pub struct CallCookies {
31    cookies: IndexMap<String, ResolvedParamValue>,
32    pub(in crate::client) schemas: Schemas,
33}
34
35impl CallCookies {
36    /// Creates a new empty CallCookies instance.
37    ///
38    /// # Examples
39    ///
40    /// ```rust
41    /// use clawspec_core::CallCookies;
42    ///
43    /// let cookies = CallCookies::new();
44    /// assert!(cookies.is_empty());
45    /// ```
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// Adds a cookie parameter to the collection.
51    ///
52    /// This method follows the same pattern as `CallHeaders::add_header` and `CallQuery::add_param`,
53    /// providing a consistent API across all parameter types.
54    ///
55    /// # Type Parameters
56    ///
57    /// * `T` - The cookie value type, must implement `Serialize`, `ToSchema`, `Debug`, `Send`, `Sync`, and `Clone`
58    ///
59    /// # Arguments
60    ///
61    /// * `name` - The cookie name (e.g., "session_id", "user_preferences")
62    /// * `value` - The cookie value, either a direct value or wrapped in ParamValue
63    ///
64    /// # Examples
65    ///
66    /// ```rust
67    /// use clawspec_core::CallCookies;
68    ///
69    /// let cookies = CallCookies::new()
70    ///     .add_cookie("session_id", "abc123")
71    ///     .add_cookie("user_id", 12345)
72    ///     .add_cookie("is_admin", true);
73    /// ```
74    pub fn add_cookie<T: ParameterValue>(
75        mut self,
76        name: impl Into<String>,
77        value: impl Into<ParamValue<T>>,
78    ) -> Self {
79        let name = name.into();
80        let param_value = value.into();
81
82        // Generate schema for the cookie value
83        let schema = self.schemas.add::<T>();
84
85        // Convert to resolved param value
86        let resolved = ResolvedParamValue {
87            value: param_value
88                .as_query_value()
89                .expect("Cookie serialization should not fail"),
90            schema,
91            style: param_value.query_style(), // Cookies use simple string serialization like query params
92        };
93
94        self.cookies.insert(name, resolved);
95        self
96    }
97
98    /// Merges another CallCookies instance into this one.
99    ///
100    /// Cookies from the other instance will override cookies with the same name in this instance.
101    ///
102    /// # Examples
103    ///
104    /// ```rust
105    /// use clawspec_core::CallCookies;
106    ///
107    /// let cookies1 = CallCookies::new()
108    ///     .add_cookie("session_id", "abc123");
109    ///
110    /// let cookies2 = CallCookies::new()
111    ///     .add_cookie("user_id", 456);
112    ///
113    /// let merged = cookies1.merge(cookies2);
114    /// assert_eq!(merged.len(), 2);
115    /// ```
116    pub fn merge(mut self, other: Self) -> Self {
117        // Merge schemas first
118        self.schemas.merge(other.schemas);
119
120        // Merge cookies (other takes precedence)
121        for (name, value) in other.cookies {
122            self.cookies.insert(name, value);
123        }
124
125        self
126    }
127
128    /// Checks if the cookies collection is empty.
129    ///
130    /// # Examples
131    ///
132    /// ```rust
133    /// use clawspec_core::CallCookies;
134    ///
135    /// let cookies = CallCookies::new();
136    /// assert!(cookies.is_empty());
137    ///
138    /// let cookies = cookies.add_cookie("session_id", "abc123");
139    /// assert!(!cookies.is_empty());
140    /// ```
141    pub fn is_empty(&self) -> bool {
142        self.cookies.is_empty()
143    }
144
145    /// Returns the number of cookies.
146    ///
147    /// # Examples
148    ///
149    /// ```rust
150    /// use clawspec_core::CallCookies;
151    ///
152    /// let cookies = CallCookies::new()
153    ///     .add_cookie("session_id", "abc123")
154    ///     .add_cookie("user_id", 456);
155    /// assert_eq!(cookies.len(), 2);
156    /// ```
157    pub fn len(&self) -> usize {
158        self.cookies.len()
159    }
160
161    /// Converts cookies to OpenAPI Parameter objects.
162    ///
163    /// According to the OpenAPI 3.1.0 specification, cookies are represented as parameters
164    /// with `in: cookie`. This method generates the appropriate Parameter objects for
165    /// inclusion in the OpenAPI specification.
166    ///
167    /// # OpenAPI Specification
168    ///
169    /// From the OpenAPI 3.1.0 specification:
170    /// - Parameter location: `in: cookie`
171    /// - Cookies are typically optional parameters
172    /// - Cookie values are serialized as simple strings
173    pub(in crate::client) fn to_parameters(&self) -> impl Iterator<Item = Parameter> + '_ {
174        self.cookies.iter().map(|(name, resolved)| {
175            ParameterBuilder::new()
176                .name(name)
177                .parameter_in(ParameterIn::Cookie)
178                .required(Required::False) // Cookies are typically optional
179                .schema(Some(resolved.schema.clone()))
180                .build()
181        })
182    }
183
184    /// Converts cookies to HTTP Cookie header format.
185    ///
186    /// This method serializes all cookies into a single "Cookie" header value
187    /// following the format: `name1=value1; name2=value2; name3=value3`
188    ///
189    /// # Returns
190    ///
191    /// Returns a Result containing the formatted cookie header value, or an error
192    /// if any cookie value cannot be serialized.
193    pub(in crate::client) fn to_cookie_header(&self) -> Result<String, ApiClientError> {
194        if self.cookies.is_empty() {
195            return Ok(String::new());
196        }
197
198        let mut cookie_parts = Vec::new();
199
200        for (name, resolved) in &self.cookies {
201            let value = resolved.to_string_value()?;
202            cookie_parts.push(format!("{name}={value}"));
203        }
204
205        Ok(cookie_parts.join("; "))
206    }
207
208    /// Returns a reference to the schemas collected from cookie values.
209    ///
210    /// This method provides access to the internal schema collection for integration
211    /// with the broader OpenAPI schema system.
212    pub(in crate::client) fn schemas(&self) -> &Schemas {
213        &self.schemas
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::client::ParamStyle;
221    use serde::{Deserialize, Serialize};
222    use utoipa::ToSchema;
223
224    #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
225    struct UserId(u64);
226
227    #[test]
228    fn test_new_empty_cookies() {
229        let cookies = CallCookies::new();
230
231        assert!(cookies.is_empty());
232        assert_eq!(cookies.len(), 0);
233    }
234
235    #[test]
236    fn test_add_string_cookie() {
237        let cookies = CallCookies::new().add_cookie("session_id", "abc123");
238
239        assert!(!cookies.is_empty());
240        assert_eq!(cookies.len(), 1);
241
242        let cookie_header = cookies
243            .to_cookie_header()
244            .expect("Should convert to cookie header");
245        assert_eq!(cookie_header, "session_id=abc123");
246    }
247
248    #[test]
249    fn test_add_multiple_cookies() {
250        let cookies = CallCookies::new()
251            .add_cookie("session_id", "abc123")
252            .add_cookie("user_id", 456)
253            .add_cookie("is_admin", true);
254
255        assert_eq!(cookies.len(), 3);
256
257        let cookie_header = cookies
258            .to_cookie_header()
259            .expect("Should convert to cookie header");
260
261        // Check that all cookies are present
262        assert!(cookie_header.contains("session_id=abc123"));
263        assert!(cookie_header.contains("user_id=456"));
264        assert!(cookie_header.contains("is_admin=true"));
265
266        // Check format (separated by "; ")
267        let parts: Vec<&str> = cookie_header.split("; ").collect();
268        assert_eq!(parts.len(), 3);
269    }
270
271    #[test]
272    fn test_add_custom_type_cookie() {
273        let cookies = CallCookies::new().add_cookie("user_id", UserId(42));
274
275        let cookie_header = cookies
276            .to_cookie_header()
277            .expect("Should convert to cookie header");
278        assert_eq!(cookie_header, "user_id=42");
279    }
280
281    #[test]
282    fn test_cookie_merge() {
283        let cookies1 = CallCookies::new()
284            .add_cookie("session_id", "abc123")
285            .add_cookie("user_id", 456);
286
287        let cookies2 = CallCookies::new()
288            .add_cookie("theme", "dark")
289            .add_cookie("user_id", 789); // Override
290
291        let merged = cookies1.merge(cookies2);
292
293        assert_eq!(merged.len(), 3);
294
295        let cookie_header = merged
296            .to_cookie_header()
297            .expect("Should convert to cookie header");
298
299        assert!(cookie_header.contains("session_id=abc123"));
300        assert!(cookie_header.contains("user_id=789")); // Should be overridden
301        assert!(cookie_header.contains("theme=dark"));
302    }
303
304    #[test]
305    fn test_cookies_to_parameters() {
306        let cookies = CallCookies::new()
307            .add_cookie("session_id", "abc123")
308            .add_cookie("user_id", 456);
309
310        let parameters: Vec<Parameter> = cookies.to_parameters().collect();
311
312        assert_eq!(parameters.len(), 2);
313
314        // Check parameter properties
315        for param in &parameters {
316            assert_eq!(param.parameter_in, ParameterIn::Cookie);
317            assert_eq!(param.required, Required::False);
318            assert!(param.schema.is_some());
319            assert!(param.name == "session_id" || param.name == "user_id");
320        }
321    }
322
323    #[test]
324    fn test_empty_cookies_header() {
325        let cookies = CallCookies::new();
326        let cookie_header = cookies
327            .to_cookie_header()
328            .expect("Should handle empty cookies");
329        assert_eq!(cookie_header, "");
330    }
331
332    #[test]
333    fn test_cookie_insertion_order_preserved() {
334        let cookies = CallCookies::new()
335            .add_cookie("first", "value1")
336            .add_cookie("second", "value2")
337            .add_cookie("third", "value3");
338
339        let cookie_header = cookies
340            .to_cookie_header()
341            .expect("Should convert to cookie header");
342
343        // Check that order is preserved in the header
344        let parts: Vec<&str> = cookie_header.split("; ").collect();
345        assert_eq!(parts, vec!["first=value1", "second=value2", "third=value3"]);
346    }
347
348    #[test]
349    fn test_cookie_with_special_characters() {
350        let cookies = CallCookies::new()
351            .add_cookie("encoded_data", "hello world")
352            .add_cookie("special", "test@example.com");
353
354        let cookie_header = cookies
355            .to_cookie_header()
356            .expect("Should handle special characters");
357
358        // Note: Cookie values are typically URL-encoded in real scenarios,
359        // but our implementation treats them as simple strings
360        assert!(cookie_header.contains("encoded_data=hello world"));
361        assert!(cookie_header.contains("special=test@example.com"));
362    }
363
364    #[test]
365    fn test_cookie_with_numeric_values() {
366        let cookies = CallCookies::new()
367            .add_cookie("count", 42)
368            .add_cookie("rate", 2.5);
369
370        let cookie_header = cookies
371            .to_cookie_header()
372            .expect("Should handle numeric values");
373
374        assert!(cookie_header.contains("count=42"));
375        assert!(cookie_header.contains("rate=2.5"));
376    }
377
378    #[test]
379    fn test_cookie_with_boolean_values() {
380        let cookies = CallCookies::new()
381            .add_cookie("is_logged_in", true)
382            .add_cookie("is_admin", false);
383
384        let cookie_header = cookies
385            .to_cookie_header()
386            .expect("Should handle boolean values");
387
388        assert!(cookie_header.contains("is_logged_in=true"));
389        assert!(cookie_header.contains("is_admin=false"));
390    }
391
392    #[test]
393    fn test_cookie_schemas_collection() {
394        let cookies = CallCookies::new()
395            .add_cookie("session_id", "abc123")
396            .add_cookie("user_id", UserId(42));
397
398        let schemas = cookies.schemas();
399
400        // Should have collected schemas for String and UserId
401        assert!(!schemas.schema_vec().is_empty());
402    }
403
404    #[test]
405    fn test_cookie_override_in_merge() {
406        let cookies1 = CallCookies::new()
407            .add_cookie("same_cookie", "original_value")
408            .add_cookie("unique_1", "value1");
409
410        let cookies2 = CallCookies::new()
411            .add_cookie("same_cookie", "new_value")
412            .add_cookie("unique_2", "value2");
413
414        let merged = cookies1.merge(cookies2);
415
416        let cookie_header = merged
417            .to_cookie_header()
418            .expect("Should convert merged cookies");
419
420        // The second cookies collection should override the first
421        assert!(cookie_header.contains("same_cookie=new_value"));
422        assert!(cookie_header.contains("unique_1=value1"));
423        assert!(cookie_header.contains("unique_2=value2"));
424        assert_eq!(merged.len(), 3);
425    }
426
427    #[test]
428    fn test_cookie_with_array_values() {
429        let cookies = CallCookies::new()
430            .add_cookie("tags", vec!["rust", "web", "api"])
431            .add_cookie("ids", vec![1, 2, 3]);
432
433        let cookie_header = cookies
434            .to_cookie_header()
435            .expect("Should handle array values");
436
437        // Arrays should be serialized as comma-separated values (Simple style)
438        assert!(cookie_header.contains("tags=rust,web,api"));
439        assert!(cookie_header.contains("ids=1,2,3"));
440    }
441
442    #[test]
443    fn test_cookie_with_null_value() {
444        let cookies = CallCookies::new().add_cookie("optional", serde_json::Value::Null);
445
446        let cookie_header = cookies
447            .to_cookie_header()
448            .expect("Should handle null values");
449
450        // Null values should serialize to empty string
451        assert!(cookie_header.contains("optional="));
452    }
453
454    #[test]
455    fn test_cookie_error_with_complex_object() {
456        use serde_json::json;
457
458        // Create a cookies collection with a complex nested object
459        let mut cookies = CallCookies::new();
460
461        // Add a complex object that should cause an error during to_string_value conversion
462        let complex_value = json!({
463            "nested": {
464                "object": "not supported in cookies"
465            }
466        });
467
468        let resolved = ResolvedParamValue {
469            value: complex_value,
470            schema: cookies.schemas.add::<serde_json::Value>(),
471            style: ParamStyle::Simple,
472        };
473
474        cookies
475            .cookies
476            .insert("complex_cookie".to_string(), resolved);
477
478        // Now test that to_cookie_header fails for the complex object
479        let result = cookies.to_cookie_header();
480        assert!(
481            result.is_err(),
482            "Complex objects should cause error in cookies"
483        );
484
485        match result {
486            Err(ApiClientError::UnsupportedParameterValue { .. }) => {
487                // Expected error type
488            }
489            _ => panic!("Expected UnsupportedParameterValue error for complex object in cookie"),
490        }
491    }
492
493    #[test]
494    fn test_single_cookie_no_semicolon() {
495        let cookies = CallCookies::new().add_cookie("single", "value");
496
497        let cookie_header = cookies
498            .to_cookie_header()
499            .expect("Should convert single cookie");
500
501        // Single cookie should not have trailing semicolon
502        assert_eq!(cookie_header, "single=value");
503    }
504
505    #[test]
506    fn test_cookie_with_empty_string_value() {
507        let cookies = CallCookies::new().add_cookie("empty", "");
508
509        let cookie_header = cookies
510            .to_cookie_header()
511            .expect("Should handle empty string values");
512
513        assert_eq!(cookie_header, "empty=");
514    }
515}