clawspec_core/client/parameters/
headers.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 headers for an API call.
10///
11/// This struct manages HTTP headers using the same ParamValue pattern as query and path parameters,
12/// allowing for type-safe header handling with automatic OpenAPI schema generation.
13#[derive(Debug, Clone, Default)]
14pub struct CallHeaders {
15    headers: IndexMap<String, ResolvedParamValue>,
16    pub(in crate::client) schemas: Schemas,
17}
18
19impl CallHeaders {
20    /// Creates a new empty CallHeaders instance.
21    pub fn new() -> Self {
22        Self::default()
23    }
24
25    /// Adds a header parameter to the collection.
26    ///
27    /// # Type Parameters
28    ///
29    /// * `T` - The header value type, must implement `Serialize`, `ToSchema`, `Debug`, `Send`, `Sync`, and `Clone`
30    ///
31    /// # Arguments
32    ///
33    /// * `name` - The header name (e.g., "Authorization", "Content-Type")
34    /// * `value` - The header value, either a direct value or wrapped in ParamValue
35    ///
36    /// # Example
37    ///
38    /// ```rust
39    /// use clawspec_core::CallHeaders;
40    ///
41    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
42    /// let headers = CallHeaders::new()
43    ///     .add_header("Authorization", "Bearer token123")
44    ///     .add_header("X-Request-ID", "abc-123-def");
45    /// # Ok(())
46    /// # }
47    /// ```
48    pub fn add_header<T: ParameterValue>(
49        mut self,
50        name: impl Into<String>,
51        value: impl Into<ParamValue<T>>,
52    ) -> Self {
53        let name = name.into();
54        let param_value = value.into();
55
56        // Generate schema for the header value
57        let schema = self.schemas.add::<T>();
58
59        // Convert to resolved param value
60        let resolved = ResolvedParamValue {
61            value: param_value
62                .as_header_value()
63                .expect("Header serialization should not fail"),
64            schema,
65            style: param_value.header_style(),
66        };
67
68        self.headers.insert(name, resolved);
69        self
70    }
71
72    /// Merges another CallHeaders instance into this one.
73    ///
74    /// Headers from the other instance will override headers with the same name in this instance.
75    pub fn merge(mut self, other: Self) -> Self {
76        // Merge schemas first
77        self.schemas.merge(other.schemas);
78
79        // Merge headers (other takes precedence)
80        for (name, value) in other.headers {
81            self.headers.insert(name, value);
82        }
83
84        self
85    }
86
87    /// Checks if the headers collection is empty.
88    pub fn is_empty(&self) -> bool {
89        self.headers.is_empty()
90    }
91
92    /// Returns the number of headers.
93    pub fn len(&self) -> usize {
94        self.headers.len()
95    }
96
97    /// Converts headers to OpenAPI Parameter objects.
98    pub(in crate::client) fn to_parameters(&self) -> impl Iterator<Item = Parameter> + '_ {
99        self.headers.iter().map(|(name, resolved)| {
100            ParameterBuilder::new()
101                .name(name)
102                .parameter_in(ParameterIn::Header)
103                .required(Required::False) // Headers are typically optional
104                .schema(Some(resolved.schema.clone()))
105                .build()
106        })
107    }
108
109    /// Converts headers to HTTP header format for reqwest.
110    pub(in crate::client) fn to_http_headers(
111        &self,
112    ) -> Result<Vec<(String, String)>, ApiClientError> {
113        let mut result = Vec::new();
114
115        for (name, resolved) in &self.headers {
116            let value = resolved.to_string_value()?;
117            result.push((name.clone(), value));
118        }
119
120        Ok(result)
121    }
122
123    /// Returns a reference to the schemas collected from header values.
124    pub(in crate::client) fn schemas(&self) -> &Schemas {
125        &self.schemas
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::client::ParamStyle;
133    use indexmap::IndexMap;
134    use serde::{Deserialize, Serialize};
135    use utoipa::ToSchema;
136
137    #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
138    struct TestId(u64);
139
140    #[test]
141    fn test_new_empty_headers() {
142        let headers = CallHeaders::new();
143
144        assert!(headers.is_empty());
145        assert_eq!(headers.len(), 0);
146    }
147
148    #[test]
149    fn test_add_string_header() {
150        let headers = CallHeaders::new().add_header("Authorization", "Bearer token123");
151
152        assert!(!headers.is_empty());
153        assert_eq!(headers.len(), 1);
154
155        let http_headers = headers
156            .to_http_headers()
157            .expect("Should convert to HTTP headers");
158        assert_eq!(
159            http_headers,
160            vec![("Authorization".to_string(), "Bearer token123".to_string())]
161        );
162    }
163
164    #[test]
165    fn test_add_multiple_headers() {
166        let headers = CallHeaders::new()
167            .add_header("Authorization", "Bearer token123")
168            .add_header("X-Request-ID", "abc-123-def")
169            .add_header("Content-Type", "application/json");
170
171        assert_eq!(headers.len(), 3);
172
173        let http_headers = headers
174            .to_http_headers()
175            .expect("Should convert to HTTP headers");
176        assert_eq!(http_headers.len(), 3);
177
178        // Check that all headers are present and verify order preservation
179        let header_map: IndexMap<String, String> = http_headers.into_iter().collect();
180        assert_eq!(
181            header_map.get("Authorization"),
182            Some(&"Bearer token123".to_string())
183        );
184        assert_eq!(
185            header_map.get("X-Request-ID"),
186            Some(&"abc-123-def".to_string())
187        );
188        assert_eq!(
189            header_map.get("Content-Type"),
190            Some(&"application/json".to_string())
191        );
192
193        // Verify insertion order is preserved
194        let keys: Vec<_> = header_map.keys().cloned().collect();
195        assert_eq!(keys, vec!["Authorization", "X-Request-ID", "Content-Type"]);
196    }
197
198    #[test]
199    fn test_add_numeric_header() {
200        let headers = CallHeaders::new().add_header("X-Rate-Limit", 1000u32);
201
202        let http_headers = headers
203            .to_http_headers()
204            .expect("Should convert to HTTP headers");
205        assert_eq!(
206            http_headers,
207            vec![("X-Rate-Limit".to_string(), "1000".to_string())]
208        );
209    }
210
211    #[test]
212    fn test_add_custom_type_header() {
213        let headers = CallHeaders::new().add_header("X-User-ID", TestId(42));
214
215        let http_headers = headers
216            .to_http_headers()
217            .expect("Should convert to HTTP headers");
218        assert_eq!(
219            http_headers,
220            vec![("X-User-ID".to_string(), "42".to_string())]
221        );
222    }
223
224    #[test]
225    fn test_add_header_with_param_style() {
226        let headers = CallHeaders::new().add_header(
227            "X-Tags",
228            ParamValue::with_style(vec!["rust", "web", "api"], ParamStyle::Simple),
229        );
230
231        let http_headers = headers
232            .to_http_headers()
233            .expect("Should convert to HTTP headers");
234        assert_eq!(
235            http_headers,
236            vec![("X-Tags".to_string(), "rust,web,api".to_string())]
237        );
238    }
239
240    #[test]
241    fn test_header_merge() {
242        let headers1 = CallHeaders::new()
243            .add_header("Authorization", "Bearer token123")
244            .add_header("X-Request-ID", "abc-123-def");
245
246        let headers2 = CallHeaders::new()
247            .add_header("Content-Type", "application/json")
248            .add_header("X-Request-ID", "xyz-789-ghi"); // Override
249
250        let merged = headers1.merge(headers2);
251
252        assert_eq!(merged.len(), 3);
253
254        let http_headers = merged
255            .to_http_headers()
256            .expect("Should convert to HTTP headers");
257        let header_map: IndexMap<String, String> = http_headers.into_iter().collect();
258
259        assert_eq!(
260            header_map.get("Authorization"),
261            Some(&"Bearer token123".to_string())
262        );
263        assert_eq!(
264            header_map.get("X-Request-ID"),
265            Some(&"xyz-789-ghi".to_string())
266        ); // Should be overridden
267        assert_eq!(
268            header_map.get("Content-Type"),
269            Some(&"application/json".to_string())
270        );
271
272        // Verify merge order: original headers first, then new headers
273        let keys: Vec<_> = header_map.keys().cloned().collect();
274        assert_eq!(keys, vec!["Authorization", "X-Request-ID", "Content-Type"]);
275    }
276
277    #[test]
278    fn test_headers_to_parameters() {
279        let headers = CallHeaders::new()
280            .add_header("Authorization", "Bearer token123")
281            .add_header("X-Rate-Limit", 1000u32);
282
283        let parameters: Vec<Parameter> = headers.to_parameters().collect();
284
285        assert_eq!(parameters.len(), 2);
286
287        // Check parameter properties
288        for param in &parameters {
289            assert_eq!(param.parameter_in, ParameterIn::Header);
290            assert_eq!(param.required, Required::False);
291            assert!(param.schema.is_some());
292            assert!(param.name == "Authorization" || param.name == "X-Rate-Limit");
293        }
294    }
295
296    #[test]
297    fn test_empty_headers_merge() {
298        let headers1 = CallHeaders::new().add_header("Authorization", "Bearer token123");
299
300        let headers2 = CallHeaders::new();
301
302        let merged = headers1.merge(headers2);
303        assert_eq!(merged.len(), 1);
304
305        let http_headers = merged
306            .to_http_headers()
307            .expect("Should convert to HTTP headers");
308        assert_eq!(
309            http_headers,
310            vec![("Authorization".to_string(), "Bearer token123".to_string())]
311        );
312    }
313
314    #[test]
315    fn test_headers_schema_collection() {
316        let headers = CallHeaders::new()
317            .add_header("Authorization", "Bearer token123")
318            .add_header("X-User-ID", TestId(42));
319
320        let schemas = headers.schemas();
321
322        // Should have collected schemas for String and TestId
323        assert!(!schemas.schema_vec().is_empty());
324    }
325
326    #[test]
327    fn test_header_insertion_order_preserved() {
328        let headers = CallHeaders::new()
329            .add_header("First", "value1")
330            .add_header("Second", "value2")
331            .add_header("Third", "value3")
332            .add_header("Fourth", "value4");
333
334        let http_headers = headers
335            .to_http_headers()
336            .expect("Should convert to HTTP headers");
337
338        // Verify that headers maintain insertion order
339        let actual_order: Vec<String> = http_headers.iter().map(|(name, _)| name.clone()).collect();
340        let expected_order = vec!["First", "Second", "Third", "Fourth"];
341
342        assert_eq!(actual_order, expected_order);
343    }
344
345    #[test]
346    fn test_header_with_array_values() {
347        let headers = CallHeaders::new()
348            .add_header("X-Tags", vec!["rust", "web", "api"])
349            .add_header("X-Numbers", vec![1, 2, 3]);
350
351        let http_headers = headers
352            .to_http_headers()
353            .expect("Should convert to HTTP headers");
354
355        let header_map: IndexMap<String, String> = http_headers.into_iter().collect();
356
357        // Headers use Simple style by default for arrays (comma-separated)
358        assert_eq!(header_map.get("X-Tags"), Some(&"rust,web,api".to_string()));
359        assert_eq!(header_map.get("X-Numbers"), Some(&"1,2,3".to_string()));
360    }
361
362    #[test]
363    fn test_header_with_boolean_values() {
364        let headers = CallHeaders::new()
365            .add_header("X-Debug", true)
366            .add_header("X-Enabled", false);
367
368        let http_headers = headers
369            .to_http_headers()
370            .expect("Should convert to HTTP headers");
371
372        let header_map: IndexMap<String, String> = http_headers.into_iter().collect();
373
374        assert_eq!(header_map.get("X-Debug"), Some(&"true".to_string()));
375        assert_eq!(header_map.get("X-Enabled"), Some(&"false".to_string()));
376    }
377
378    #[test]
379    fn test_header_with_null_value() {
380        let headers = CallHeaders::new().add_header("X-Optional", serde_json::Value::Null);
381
382        let http_headers = headers
383            .to_http_headers()
384            .expect("Should convert to HTTP headers");
385
386        let header_map: IndexMap<String, String> = http_headers.into_iter().collect();
387
388        // Null values should serialize to empty string
389        assert_eq!(header_map.get("X-Optional"), Some(&String::new()));
390    }
391
392    #[test]
393    fn test_header_error_with_complex_object() {
394        use serde_json::json;
395
396        // Create a headers collection with a complex nested object
397        // Note: We need to bypass the normal add_header method since it expects()
398        // the header value to serialize correctly. Instead, we'll create a
399        // ResolvedParamValue manually with an unsupported object type.
400        let mut headers = CallHeaders::new();
401
402        // Add a complex object that should cause an error during to_string_value conversion
403        let complex_value = json!({
404            "nested": {
405                "object": "not supported in headers"
406            }
407        });
408
409        let resolved = ResolvedParamValue {
410            value: complex_value,
411            schema: headers.schemas.add::<serde_json::Value>(),
412            style: ParamStyle::Simple,
413        };
414
415        headers.headers.insert("X-Complex".to_string(), resolved);
416
417        // Now test that to_http_headers fails for the complex object
418        let result = headers.to_http_headers();
419        assert!(
420            result.is_err(),
421            "Complex objects should cause error in headers"
422        );
423
424        match result {
425            Err(ApiClientError::UnsupportedParameterValue { .. }) => {
426                // Expected error type
427            }
428            _ => panic!("Expected UnsupportedParameterValue error for complex object in header"),
429        }
430    }
431
432    #[test]
433    fn test_header_error_with_array_containing_objects() {
434        use serde_json::json;
435
436        // Similar to above, test arrays containing objects
437        let mut headers = CallHeaders::new();
438
439        let array_with_objects = json!([
440            "simple_string",
441            {"nested": "object"}
442        ]);
443
444        let resolved = ResolvedParamValue {
445            value: array_with_objects,
446            schema: headers.schemas.add::<serde_json::Value>(),
447            style: ParamStyle::Simple,
448        };
449
450        headers
451            .headers
452            .insert("X-Invalid-Array".to_string(), resolved);
453
454        let result = headers.to_http_headers();
455        assert!(
456            result.is_err(),
457            "Arrays containing objects should cause error"
458        );
459
460        match result {
461            Err(ApiClientError::UnsupportedParameterValue { .. }) => {
462                // Expected error type
463            }
464            _ => panic!("Expected UnsupportedParameterValue error for array with objects"),
465        }
466    }
467
468    #[test]
469    fn test_header_with_empty_array() {
470        let headers = CallHeaders::new().add_header("X-Empty-List", Vec::<String>::new());
471
472        let http_headers = headers
473            .to_http_headers()
474            .expect("Should handle empty arrays");
475
476        let header_map: IndexMap<String, String> = http_headers.into_iter().collect();
477
478        // Empty arrays should serialize to empty string
479        assert_eq!(header_map.get("X-Empty-List"), Some(&String::new()));
480    }
481
482    #[test]
483    fn test_header_override_in_merge() {
484        let headers1 = CallHeaders::new()
485            .add_header("Same-Header", "original-value")
486            .add_header("Unique-1", "value1");
487
488        let headers2 = CallHeaders::new()
489            .add_header("Same-Header", "new-value")
490            .add_header("Unique-2", "value2");
491
492        let merged = headers1.merge(headers2);
493
494        let http_headers = merged
495            .to_http_headers()
496            .expect("Should convert merged headers");
497
498        let header_map: IndexMap<String, String> = http_headers.into_iter().collect();
499
500        // The second headers collection should override the first
501        assert_eq!(
502            header_map.get("Same-Header"),
503            Some(&"new-value".to_string())
504        );
505        assert_eq!(header_map.get("Unique-1"), Some(&"value1".to_string()));
506        assert_eq!(header_map.get("Unique-2"), Some(&"value2".to_string()));
507        assert_eq!(header_map.len(), 3);
508    }
509}