clawspec_core/client/
headers.rs

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