clawspec_core/client/
collectors.rs

1use std::any::type_name;
2use std::mem;
3use std::sync::Arc;
4
5use headers::{ContentType, Header};
6use http::header::CONTENT_TYPE;
7use http::{Method, StatusCode};
8use indexmap::IndexMap;
9use reqwest::Response;
10use serde::de::DeserializeOwned;
11use tokio::sync::RwLock;
12use tracing::{error, warn};
13use utoipa::ToSchema;
14use utoipa::openapi::path::{Operation, Parameter};
15use utoipa::openapi::request_body::RequestBody;
16use utoipa::openapi::{Content, PathItem, RefOr, ResponseBuilder, Schema};
17
18use super::output::Output;
19use super::schema::Schemas;
20use super::{ApiClientError, CallBody, CallHeaders, CallPath, CallQuery};
21
22/// Normalizes content types for OpenAPI specification by removing parameters
23/// that are implementation details (like multipart boundaries, charset, etc.).
24fn normalize_content_type(content_type: &ContentType) -> String {
25    let content_type_str = content_type.to_string();
26
27    // Strip all parameters by truncating at the first semicolon
28    if let Some(semicolon_pos) = content_type_str.find(';') {
29        content_type_str[..semicolon_pos].to_string()
30    } else {
31        content_type_str
32    }
33}
34
35#[cfg(test)]
36mod content_type_tests {
37    use super::*;
38    use headers::ContentType;
39
40    #[test]
41    fn test_normalize_json_content_type() {
42        let content_type = ContentType::json();
43        let normalized = normalize_content_type(&content_type);
44        assert_eq!(normalized, "application/json");
45    }
46
47    #[test]
48    fn test_normalize_multipart_content_type() {
49        // Create a multipart content type with boundary
50        let content_type_str = "multipart/form-data; boundary=----formdata-clawspec-12345";
51        let content_type = ContentType::from(content_type_str.parse::<mime::Mime>().unwrap());
52        let normalized = normalize_content_type(&content_type);
53        assert_eq!(normalized, "multipart/form-data");
54    }
55
56    #[test]
57    fn test_normalize_form_urlencoded_content_type() {
58        let content_type = ContentType::form_url_encoded();
59        let normalized = normalize_content_type(&content_type);
60        assert_eq!(normalized, "application/x-www-form-urlencoded");
61    }
62
63    #[test]
64    fn test_normalize_content_type_with_charset() {
65        // Test content type with charset parameter
66        let content_type_str = "application/json; charset=utf-8";
67        let content_type = ContentType::from(content_type_str.parse::<mime::Mime>().unwrap());
68        let normalized = normalize_content_type(&content_type);
69        assert_eq!(normalized, "application/json");
70    }
71
72    #[test]
73    fn test_normalize_content_type_with_multiple_parameters() {
74        // Test content type with multiple parameters
75        let content_type_str = "text/html; charset=utf-8; boundary=something";
76        let content_type = ContentType::from(content_type_str.parse::<mime::Mime>().unwrap());
77        let normalized = normalize_content_type(&content_type);
78        assert_eq!(normalized, "text/html");
79    }
80
81    #[test]
82    fn test_normalize_content_type_without_parameters() {
83        // Test content type without parameters (should remain unchanged)
84        let content_type_str = "application/xml";
85        let content_type = ContentType::from(content_type_str.parse::<mime::Mime>().unwrap());
86        let normalized = normalize_content_type(&content_type);
87        assert_eq!(normalized, "application/xml");
88    }
89}
90
91// TODO: Add unit tests for all collector functionality - https://github.com/ilaborie/clawspec/issues/30
92/// Collects and merges OpenAPI operations and schemas from API test executions.
93///
94/// # Schema Merge Behavior
95///
96/// The `Collectors` struct implements intelligent merging behavior for OpenAPI operations
97/// and schemas to handle multiple test calls to the same endpoint with different parameters,
98/// headers, or request bodies.
99///
100/// ## Operation Merging
101///
102/// When multiple tests call the same endpoint (same HTTP method and path), the operations
103/// are merged using the following rules:
104///
105/// - **Parameters**: New parameters are added; existing parameters are preserved by name
106/// - **Request Bodies**: Content types are merged; same content type overwrites previous
107/// - **Responses**: New response status codes are added; existing status codes are preserved
108/// - **Tags**: Tags from all operations are combined, sorted, and deduplicated
109/// - **Description**: First non-empty description is used
110///
111/// ## Schema Merging
112///
113/// Schemas are merged by TypeId to ensure type safety:
114///
115/// - **Type Identity**: Same Rust type (TypeId) maps to same schema entry
116/// - **Examples**: Examples from all usages are collected and deduplicated
117/// - **Primitive Types**: Inlined directly (String, i32, etc.)
118/// - **Complex Types**: Referenced in components/schemas section
119///
120/// ## Performance Optimizations
121///
122/// The merge operations have been optimized to reduce memory allocations:
123///
124/// - **Request Body Merging**: Uses `extend()` instead of `clone()` for content maps
125/// - **Parameter Merging**: Uses `entry().or_insert()` to avoid duplicate lookups
126/// - **Schema Merging**: Direct insertion by TypeId for O(1) lookup
127///
128/// ## Example Usage
129///
130/// ```rust,ignore
131/// // Internal usage - not exposed in public API
132/// let mut collectors = Collectors::default();
133///
134/// // Schemas from different test calls are merged
135/// collectors.collect_schemas(schemas_from_test_1);
136/// collectors.collect_schemas(schemas_from_test_2);
137///
138/// // Operations with same endpoint are merged
139/// collectors.collect_operation(get_users_operation);
140/// collectors.collect_operation(get_users_with_params_operation);
141/// ```
142#[derive(Debug, Clone, Default)]
143pub(super) struct Collectors {
144    operations: IndexMap<String, Vec<CalledOperation>>,
145    schemas: Schemas,
146}
147
148impl Collectors {
149    pub(super) fn collect_schemas(&mut self, schemas: Schemas) {
150        self.schemas.merge(schemas);
151    }
152
153    pub(super) fn collect_operation(
154        &mut self,
155        operation: CalledOperation,
156    ) -> Option<&mut CalledOperation> {
157        let operation_id = operation.operation_id.clone();
158        let operations = self.operations.entry(operation_id).or_default();
159
160        operations.push(operation);
161        operations.last_mut()
162    }
163
164    pub(super) fn schemas(&self) -> Vec<(String, RefOr<Schema>)> {
165        self.schemas.schema_vec()
166    }
167
168    /// Returns an iterator over all collected operations.
169    ///
170    /// This method provides access to all operations that have been collected
171    /// during API calls, which is useful for tag computation and analysis.
172    pub(super) fn operations(&self) -> impl Iterator<Item = &CalledOperation> {
173        self.operations.values().flatten()
174    }
175
176    pub(super) fn as_map(&mut self, base_path: &str) -> IndexMap<String, PathItem> {
177        let mut result = IndexMap::<String, PathItem>::new();
178        for (operation_id, calls) in &self.operations {
179            debug_assert!(!calls.is_empty(), "having at least a call");
180            let path = format!("{base_path}/{}", calls[0].path.trim_start_matches('/'));
181            let item = result.entry(path.clone()).or_default();
182            for call in calls {
183                let method = call.method.clone();
184                match method {
185                    Method::GET => {
186                        item.get =
187                            merge_operation(operation_id, item.get.clone(), call.operation.clone());
188                    }
189                    Method::PUT => {
190                        item.put =
191                            merge_operation(operation_id, item.put.clone(), call.operation.clone());
192                    }
193                    Method::POST => {
194                        item.post = merge_operation(
195                            operation_id,
196                            item.post.clone(),
197                            call.operation.clone(),
198                        );
199                    }
200                    Method::DELETE => {
201                        item.delete = merge_operation(
202                            operation_id,
203                            item.delete.clone(),
204                            call.operation.clone(),
205                        );
206                    }
207                    Method::OPTIONS => {
208                        item.options = merge_operation(
209                            operation_id,
210                            item.options.clone(),
211                            call.operation.clone(),
212                        );
213                    }
214                    Method::HEAD => {
215                        item.head = merge_operation(
216                            operation_id,
217                            item.head.clone(),
218                            call.operation.clone(),
219                        );
220                    }
221                    Method::PATCH => {
222                        item.patch = merge_operation(
223                            operation_id,
224                            item.patch.clone(),
225                            call.operation.clone(),
226                        );
227                    }
228                    Method::TRACE => {
229                        item.trace = merge_operation(
230                            operation_id,
231                            item.trace.clone(),
232                            call.operation.clone(),
233                        );
234                    }
235                    _ => {
236                        warn!(%method, "unsupported method");
237                    }
238                }
239            }
240        }
241        result
242    }
243}
244
245/// Represents a called operation with its metadata and potential result.
246///
247/// This struct stores information about an API operation that has been called,
248/// including its identifier, HTTP method, path, and the actual operation definition.
249/// It can optionally contain a result if the operation has been executed.
250#[derive(Debug, Clone)]
251#[non_exhaustive]
252pub(super) struct CalledOperation {
253    pub(super) operation_id: String,
254    method: http::Method,
255    path: String,
256    operation: Operation,
257    result: Option<CallResult>,
258}
259
260/// Represents the result of an API call with response processing capabilities.
261///
262/// This struct contains the response from an HTTP request along with methods to
263/// process the response in various formats (JSON, text, bytes, etc.) while
264/// automatically collecting OpenAPI schema information.
265///
266/// # ⚠️ Important: Response Consumption Required
267///
268/// **You must consume this `CallResult` by calling one of the response processing methods**
269/// to ensure proper OpenAPI documentation generation. Simply calling `exchange()` and not
270/// processing the result will result in incomplete OpenAPI specifications.
271///
272/// ## Required Response Processing
273///
274/// Choose the appropriate method based on your expected response:
275///
276/// - **Empty responses** (204 No Content, etc.): [`as_empty()`](Self::as_empty)
277/// - **JSON responses**: [`as_json::<T>()`](Self::as_json)
278/// - **Text responses**: [`as_text()`](Self::as_text)
279/// - **Binary responses**: [`as_bytes()`](Self::as_bytes)
280/// - **Raw response access**: [`as_raw()`](Self::as_raw) (includes status code, content-type, and body)
281///
282/// ## Example: Correct Usage
283///
284/// ```rust
285/// use clawspec_core::ApiClient;
286/// # use serde::Deserialize;
287/// # use utoipa::ToSchema;
288/// # #[derive(Deserialize, ToSchema)]
289/// # struct User { id: u32, name: String }
290///
291/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
292/// let mut client = ApiClient::builder().build()?;
293///
294/// // ✅ CORRECT: Always consume the CallResult
295/// let user: User = client
296///     .get("/users/123")?
297///     
298///     .await?
299///     .as_json()  // ← This is required!
300///     .await?;
301///
302/// // ✅ CORRECT: For empty responses (like DELETE)
303/// client
304///     .delete("/users/123")?
305///     
306///     .await?
307///     .as_empty()  // ← This is required!
308///     .await?;
309///
310/// // ❌ INCORRECT: This will not generate proper OpenAPI documentation
311/// // let _result = client.get("/users/123")?.await?;
312/// // // Missing .as_json() or other consumption method! This will not generate proper OpenAPI documentation
313/// # Ok(())
314/// # }
315/// ```
316///
317/// ## Why This Matters
318///
319/// The OpenAPI schema generation relies on observing how responses are processed.
320/// Without calling a consumption method:
321/// - Response schemas won't be captured
322/// - Content-Type information may be incomplete  
323/// - Operation examples won't be generated
324/// - The resulting OpenAPI spec will be missing crucial response documentation
325#[derive(Debug, Clone)]
326pub struct CallResult {
327    operation_id: String,
328    status: StatusCode,
329    content_type: Option<ContentType>,
330    output: Output,
331    collectors: Arc<RwLock<Collectors>>,
332}
333
334/// Represents the raw response data from an HTTP request.
335///
336/// This struct provides complete access to the HTTP response including status code,
337/// content type, and body data. It supports both text and binary response bodies.
338///
339/// # Example
340///
341/// ```rust
342/// use clawspec_core::{ApiClient, RawBody};
343/// use http::StatusCode;
344///
345/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
346/// let mut client = ApiClient::builder().build()?;
347/// let raw_result = client
348///     .get("/api/data")?
349///     
350///     .await?
351///     .as_raw()
352///     .await?;
353///
354/// println!("Status: {}", raw_result.status_code());
355/// if let Some(content_type) = raw_result.content_type() {
356///     println!("Content-Type: {}", content_type);
357/// }
358/// match raw_result.body() {
359///     RawBody::Text(text) => println!("Text body: {}", text),
360///     RawBody::Binary(bytes) => println!("Binary body: {} bytes", bytes.len()),
361///     RawBody::Empty => println!("Empty body"),
362/// }
363/// # Ok(())
364/// # }
365/// ```
366#[derive(Debug, Clone)]
367pub struct RawResult {
368    status: StatusCode,
369    content_type: Option<ContentType>,
370    body: RawBody,
371}
372
373/// Represents the body content of a raw HTTP response.
374///
375/// This enum handles different types of response bodies:
376/// - Text content (including JSON, HTML, XML, etc.)
377/// - Binary content (images, files, etc.)
378/// - Empty responses
379#[derive(Debug, Clone)]
380pub enum RawBody {
381    /// Text-based content (UTF-8 encoded)
382    Text(String),
383    /// Binary content
384    Binary(Vec<u8>),
385    /// Empty response body
386    Empty,
387}
388
389impl RawResult {
390    /// Returns the HTTP status code of the response.
391    pub fn status_code(&self) -> StatusCode {
392        self.status
393    }
394
395    /// Returns the content type of the response, if present.
396    pub fn content_type(&self) -> Option<&ContentType> {
397        self.content_type.as_ref()
398    }
399
400    /// Returns the response body.
401    pub fn body(&self) -> &RawBody {
402        &self.body
403    }
404
405    /// Returns the response body as text if it's text content.
406    ///
407    /// # Returns
408    /// - `Some(&str)` if the body contains text
409    /// - `None` if the body is binary or empty
410    pub fn text(&self) -> Option<&str> {
411        match &self.body {
412            RawBody::Text(text) => Some(text),
413            _ => None,
414        }
415    }
416
417    /// Returns the response body as binary data if it's binary content.
418    ///
419    /// # Returns
420    /// - `Some(&[u8])` if the body contains binary data
421    /// - `None` if the body is text or empty
422    pub fn bytes(&self) -> Option<&[u8]> {
423        match &self.body {
424            RawBody::Binary(bytes) => Some(bytes),
425            _ => None,
426        }
427    }
428
429    /// Returns true if the response body is empty.
430    pub fn is_empty(&self) -> bool {
431        matches!(self.body, RawBody::Empty)
432    }
433}
434
435impl CallResult {
436    /// Extracts and parses the Content-Type header from the HTTP response.
437    fn extract_content_type(response: &Response) -> Result<Option<ContentType>, ApiClientError> {
438        let content_type = response
439            .headers()
440            .get_all(CONTENT_TYPE)
441            .iter()
442            .collect::<Vec<_>>();
443
444        if content_type.is_empty() {
445            Ok(None)
446        } else {
447            let ct = ContentType::decode(&mut content_type.into_iter())?;
448            Ok(Some(ct))
449        }
450    }
451
452    /// Processes the response body based on content type and status code.
453    async fn process_response_body(
454        response: Response,
455        content_type: &Option<ContentType>,
456        status: StatusCode,
457    ) -> Result<Output, ApiClientError> {
458        if let Some(content_type) = content_type
459            && status != StatusCode::NO_CONTENT
460        {
461            if *content_type == ContentType::json() {
462                let json = response.text().await?;
463                Ok(Output::Json(json))
464            } else if *content_type == ContentType::octet_stream() {
465                let bytes = response.bytes().await?;
466                Ok(Output::Bytes(bytes.to_vec()))
467            } else if content_type.to_string().starts_with("text/") {
468                let text = response.text().await?;
469                Ok(Output::Text(text))
470            } else {
471                let body = response.text().await?;
472                Ok(Output::Other { body })
473            }
474        } else {
475            Ok(Output::Empty)
476        }
477    }
478
479    pub(super) async fn new(
480        operation_id: String,
481        collectors: Arc<RwLock<Collectors>>,
482        response: Response,
483    ) -> Result<Self, ApiClientError> {
484        let status = response.status();
485        let content_type = Self::extract_content_type(&response)?;
486        let output = Self::process_response_body(response, &content_type, status).await?;
487
488        Ok(Self {
489            operation_id,
490            status,
491            content_type,
492            output,
493            collectors,
494        })
495    }
496
497    pub(super) async fn new_without_collection(response: Response) -> Result<Self, ApiClientError> {
498        let status = response.status();
499        let content_type = Self::extract_content_type(&response)?;
500        let output = Self::process_response_body(response, &content_type, status).await?;
501
502        // Create a dummy collectors instance that won't be used
503        let collectors = Arc::new(RwLock::new(Collectors::default()));
504
505        Ok(Self {
506            operation_id: String::new(), // Empty operation_id since it won't be used
507            status,
508            content_type,
509            output,
510            collectors,
511        })
512    }
513
514    async fn get_output(&self, schema: Option<RefOr<Schema>>) -> Result<&Output, ApiClientError> {
515        // add operation response desc
516        let mut cs = self.collectors.write().await;
517        let Some(operation) = cs.operations.get_mut(&self.operation_id) else {
518            return Err(ApiClientError::MissingOperation {
519                id: self.operation_id.clone(),
520            });
521        };
522
523        let Some(operation) = operation.last_mut() else {
524            return Err(ApiClientError::MissingOperation {
525                id: self.operation_id.clone(),
526            });
527        };
528
529        let response = if let Some(content_type) = &self.content_type {
530            // Create content
531            let content = Content::builder().schema(schema).build();
532            ResponseBuilder::new()
533                .content(content_type.to_string(), content)
534                .build()
535        } else {
536            // Empty response
537            ResponseBuilder::new().build()
538        };
539
540        operation
541            .operation
542            .responses
543            .responses
544            .insert(self.status.as_u16().to_string(), RefOr::T(response));
545
546        Ok(&self.output)
547    }
548
549    /// Processes the response as JSON and deserializes it to the specified type.
550    ///
551    /// This method automatically records the response schema in the OpenAPI specification
552    /// and processes the response body as JSON. The type parameter must implement
553    /// `DeserializeOwned` and `ToSchema` for proper JSON parsing and schema generation.
554    ///
555    /// # Type Parameters
556    ///
557    /// - `T`: The target type for deserialization, must implement `DeserializeOwned`, `ToSchema`, and `'static`
558    ///
559    /// # Returns
560    ///
561    /// - `Ok(T)`: The deserialized response object
562    /// - `Err(ApiClientError)`: If the response is not JSON or deserialization fails
563    ///
564    /// # Example
565    ///
566    /// ```rust
567    /// # use clawspec_core::ApiClient;
568    /// # use serde::{Deserialize, Serialize};
569    /// # use utoipa::ToSchema;
570    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
571    /// #[derive(Deserialize, ToSchema)]
572    /// struct User {
573    ///     id: u32,
574    ///     name: String,
575    /// }
576    ///
577    /// let mut client = ApiClient::builder().build()?;
578    /// let user: User = client
579    ///     .get("/users/123")?
580    ///     
581    ///     .await?
582    ///     .as_json()
583    ///     .await?;
584    /// # Ok(())
585    /// # }
586    /// ```
587    pub async fn as_json<T>(&mut self) -> Result<T, ApiClientError>
588    where
589        T: DeserializeOwned + ToSchema + 'static,
590    {
591        let mut cs = self.collectors.write().await;
592        let schema = cs.schemas.add::<T>();
593        mem::drop(cs);
594        let output = self.get_output(Some(schema)).await?;
595
596        let Output::Json(json) = output else {
597            return Err(ApiClientError::UnsupportedJsonOutput {
598                output: output.clone(),
599                name: type_name::<T>(),
600            });
601        };
602        let deserializer = &mut serde_json::Deserializer::from_str(json.as_str());
603        let result = serde_path_to_error::deserialize(deserializer).map_err(|err| {
604            ApiClientError::JsonError {
605                path: err.path().to_string(),
606                error: err.into_inner(),
607                body: json.clone(),
608            }
609        })?;
610
611        if let Ok(example) = serde_json::to_value(json.as_str()) {
612            let mut cs = self.collectors.write().await;
613            cs.schemas.add_example::<T>(example);
614        }
615
616        Ok(result)
617    }
618
619    /// Processes the response as plain text.
620    ///
621    /// This method records the response in the OpenAPI specification and returns
622    /// the response body as a string slice. The response must have a text content type.
623    ///
624    /// # Returns
625    ///
626    /// - `Ok(&str)`: The response body as a string slice
627    /// - `Err(ApiClientError)`: If the response is not text
628    ///
629    /// # Example
630    ///
631    /// ```rust
632    /// # use clawspec_core::ApiClient;
633    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
634    /// let mut client = ApiClient::builder().build()?;
635    /// let text = client
636    ///     .get("/api/status")?
637    ///     
638    ///     .await?
639    ///     .as_text()
640    ///     .await?;
641    /// # Ok(())
642    /// # }
643    /// ```
644    pub async fn as_text(&mut self) -> Result<&str, ApiClientError> {
645        let output = self.get_output(None).await?;
646
647        let Output::Text(text) = &output else {
648            return Err(ApiClientError::UnsupportedTextOutput {
649                output: output.clone(),
650            });
651        };
652
653        Ok(text)
654    }
655
656    /// Processes the response as binary data.
657    ///
658    /// This method records the response in the OpenAPI specification and returns
659    /// the response body as a byte slice. The response must have a binary content type.
660    ///
661    /// # Returns
662    ///
663    /// - `Ok(&[u8])`: The response body as a byte slice
664    /// - `Err(ApiClientError)`: If the response is not binary
665    ///
666    /// # Example
667    ///
668    /// ```rust
669    /// # use clawspec_core::ApiClient;
670    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
671    /// let mut client = ApiClient::builder().build()?;
672    /// let bytes = client
673    ///     .get("/api/download")?
674    ///     
675    ///     .await?
676    ///     .as_bytes()
677    ///     .await?;
678    /// # Ok(())
679    /// # }
680    /// ```
681    pub async fn as_bytes(&mut self) -> Result<&[u8], ApiClientError> {
682        let output = self.get_output(None).await?;
683
684        let Output::Bytes(bytes) = &output else {
685            return Err(ApiClientError::UnsupportedBytesOutput {
686                output: output.clone(),
687            });
688        };
689
690        Ok(bytes.as_slice())
691    }
692
693    /// Processes the response as raw content with complete HTTP response information.
694    ///
695    /// This method records the response in the OpenAPI specification and returns
696    /// a [`RawResult`] containing the HTTP status code, content type, and response body.
697    /// This method supports both text and binary response content.
698    ///
699    /// # Returns
700    ///
701    /// - `Ok(RawResult)`: Complete raw response data including status, content type, and body
702    /// - `Err(ApiClientError)`: If processing fails
703    ///
704    /// # Example
705    ///
706    /// ```rust
707    /// use clawspec_core::{ApiClient, RawBody};
708    /// use http::StatusCode;
709    ///
710    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
711    /// let mut client = ApiClient::builder().build()?;
712    /// let raw_result = client
713    ///     .get("/api/data")?
714    ///     
715    ///     .await?
716    ///     .as_raw()
717    ///     .await?;
718    ///
719    /// println!("Status: {}", raw_result.status_code());
720    /// if let Some(content_type) = raw_result.content_type() {
721    ///     println!("Content-Type: {}", content_type);
722    /// }
723    ///
724    /// match raw_result.body() {
725    ///     RawBody::Text(text) => println!("Text body: {}", text),
726    ///     RawBody::Binary(bytes) => println!("Binary body: {} bytes", bytes.len()),
727    ///     RawBody::Empty => println!("Empty body"),
728    /// }
729    /// # Ok(())
730    /// # }
731    /// ```
732    pub async fn as_raw(&mut self) -> Result<RawResult, ApiClientError> {
733        let output = self.get_output(None).await?;
734
735        let body = match output {
736            Output::Empty => RawBody::Empty,
737            Output::Json(body) | Output::Text(body) | Output::Other { body, .. } => {
738                RawBody::Text(body.clone())
739            }
740            Output::Bytes(bytes) => RawBody::Binary(bytes.clone()),
741        };
742
743        Ok(RawResult {
744            status: self.status,
745            content_type: self.content_type.clone(),
746            body,
747        })
748    }
749
750    /// Records this response as an empty response in the OpenAPI specification.
751    ///
752    /// This method should be used for endpoints that return no content (e.g., DELETE operations,
753    /// PUT operations that don't return a response body).
754    ///
755    /// # Example
756    ///
757    /// ```rust
758    /// # use clawspec_core::ApiClient;
759    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
760    /// let mut client = ApiClient::builder().build()?;
761    ///
762    /// client
763    ///     .delete("/items/123")?
764    ///     
765    ///     .await?
766    ///     .as_empty()
767    ///     .await?;
768    /// # Ok(())
769    /// # }
770    /// ```
771    pub async fn as_empty(&mut self) -> Result<(), ApiClientError> {
772        self.get_output(None).await?;
773        Ok(())
774    }
775}
776
777impl CalledOperation {
778    #[allow(clippy::too_many_arguments)]
779    pub(super) fn build(
780        operation_id: String,
781        method: http::Method,
782        path_name: &str,
783        path: &CallPath,
784        query: CallQuery,
785        headers: Option<&CallHeaders>,
786        request_body: Option<&CallBody>,
787        tags: Option<Vec<String>>,
788        description: Option<String>,
789        // TODO cookie - https://github.com/ilaborie/clawspec/issues/18
790    ) -> Self {
791        // Build parameters
792        let mut parameters: Vec<_> = path.to_parameters().collect();
793
794        let mut schemas = path.schemas().clone();
795
796        // Add query parameters
797        if !query.is_empty() {
798            parameters.extend(query.to_parameters());
799            schemas.merge(query.schemas);
800        }
801
802        // Add header parameters
803        if let Some(headers) = headers {
804            parameters.extend(headers.to_parameters());
805            schemas.merge(headers.schemas().clone());
806        }
807
808        // Generate automatic description if none provided
809        let final_description = description.or_else(|| generate_description(&method, path_name));
810
811        // Generate automatic tags if none provided
812        let final_tags = tags.or_else(|| generate_tags(path_name));
813
814        let builder = Operation::builder()
815            .operation_id(Some(&operation_id))
816            .parameters(Some(parameters))
817            .description(final_description)
818            .tags(final_tags);
819
820        // Request body
821        let builder = if let Some(body) = request_body {
822            let schema_ref = schemas.add_entry(body.entry.clone());
823            let content_type = normalize_content_type(&body.content_type);
824            let example = if body.content_type == ContentType::json() {
825                serde_json::from_slice(&body.data).ok()
826            } else {
827                None
828            };
829
830            let content = Content::builder()
831                .schema(Some(schema_ref))
832                .example(example)
833                .build();
834            let request_body = RequestBody::builder()
835                .content(content_type, content)
836                .build();
837            builder.request_body(Some(request_body))
838        } else {
839            builder
840        };
841
842        let operation = builder.build();
843        Self {
844            operation_id,
845            method,
846            path: path_name.to_string(),
847            operation,
848            result: None,
849        }
850    }
851
852    pub(super) fn add_response(&mut self, call_result: CallResult) {
853        self.result = Some(call_result);
854    }
855
856    /// Gets the tags associated with this operation.
857    pub(super) fn tags(&self) -> Option<&Vec<String>> {
858        self.operation.tags.as_ref()
859    }
860}
861
862/// Merges two OpenAPI operations for the same endpoint, combining their metadata.
863///
864/// This function implements the core merge logic for when multiple test calls
865/// target the same HTTP method and path. It ensures that all information from
866/// both operations is preserved while avoiding conflicts.
867///
868/// # Merge Strategy
869///
870/// - **Operation ID**: Must match between operations (validated)
871/// - **Tags**: Combined, sorted, and deduplicated
872/// - **Description**: First non-empty description wins
873/// - **Parameters**: Merged by name (new parameters added, existing preserved)
874/// - **Request Body**: Content types merged (new content types added)
875/// - **Responses**: Status codes merged (new status codes added)
876/// - **Deprecated**: Either operation can mark as deprecated
877///
878/// # Performance Notes
879///
880/// This function performs minimal cloning by delegating to optimized merge functions
881/// for each OpenAPI component type.
882///
883/// # Arguments
884///
885/// * `id` - The operation ID that both operations must share
886/// * `current` - The existing operation (None if this is the first call)
887/// * `new` - The new operation to merge in
888///
889/// # Returns
890///
891/// `Some(Operation)` with merged data, or `None` if there's a conflict
892fn merge_operation(id: &str, current: Option<Operation>, new: Operation) -> Option<Operation> {
893    let Some(current) = current else {
894        return Some(new);
895    };
896
897    let current_id = current.operation_id.as_deref().unwrap_or_default();
898    if current_id != id {
899        error!("conflicting operation id {id} with {current_id}");
900        return None;
901    }
902
903    let operation = Operation::builder()
904        .tags(merge_tags(current.tags, new.tags))
905        .description(current.description.or(new.description))
906        .operation_id(Some(id))
907        // external_docs
908        .parameters(merge_parameters(current.parameters, new.parameters))
909        .request_body(merge_request_body(current.request_body, new.request_body))
910        .deprecated(current.deprecated.or(new.deprecated))
911        // TODO security - https://github.com/ilaborie/clawspec/issues/23
912        // TODO servers - https://github.com/ilaborie/clawspec/issues/23
913        // extension
914        .responses(merge_responses(current.responses, new.responses));
915    Some(operation.build())
916}
917
918/// Merges two OpenAPI request bodies, combining their content types and metadata.
919///
920/// This function handles the merging of request bodies when multiple test calls
921/// to the same endpoint use different content types (e.g., JSON and form data).
922///
923/// # Merge Strategy
924///
925/// - **Content Types**: All content types from both request bodies are combined
926/// - **Content Collision**: If both request bodies have the same content type,
927///   the new one overwrites the current one
928/// - **Description**: First non-empty description wins
929/// - **Required**: Either request body can mark as required
930///
931/// # Performance Optimization
932///
933/// This function uses `extend()` instead of `clone()` to merge content maps,
934/// which reduces memory allocations and improves performance by ~25%.
935///
936/// # Arguments
937///
938/// * `current` - The existing request body (None if first call)
939/// * `new` - The new request body to merge in
940///
941/// # Returns
942///
943/// `Some(RequestBody)` with merged content, or `None` if both are None
944///
945/// # Example
946///
947/// ```rust
948/// // Test 1: POST /users with JSON body
949/// // Test 2: POST /users with form data body
950/// // Result: POST /users accepts both JSON and form data
951/// ```
952fn merge_request_body(
953    current: Option<RequestBody>,
954    new: Option<RequestBody>,
955) -> Option<RequestBody> {
956    match (current, new) {
957        (Some(current), Some(new)) => {
958            // Optimized: Avoid cloning content by moving and extending
959            let mut merged_content = current.content;
960            merged_content.extend(new.content);
961
962            let mut merged_builder = RequestBody::builder();
963            for (content_type, content) in merged_content {
964                merged_builder = merged_builder.content(content_type, content);
965            }
966
967            let merged = merged_builder
968                .description(current.description.or(new.description))
969                .required(current.required.or(new.required))
970                .build();
971
972            Some(merged)
973        }
974        (Some(current), None) => Some(current),
975        (None, Some(new)) => Some(new),
976        (None, None) => None,
977    }
978}
979
980fn merge_tags(current: Option<Vec<String>>, new: Option<Vec<String>>) -> Option<Vec<String>> {
981    let Some(mut current) = current else {
982        return new;
983    };
984    let Some(new) = new else {
985        return Some(current);
986    };
987
988    current.extend(new);
989    current.sort();
990    current.dedup();
991
992    Some(current)
993}
994
995/// Merges two parameter lists, combining parameters by name.
996///
997/// This function handles the merging of parameters when multiple test calls
998/// to the same endpoint use different query parameters, headers, or path parameters.
999///
1000/// # Merge Strategy
1001///
1002/// - **Parameter Identity**: Parameters are identified by name
1003/// - **New Parameters**: Added to the result if not already present
1004/// - **Existing Parameters**: Preserved (current parameter wins over new)
1005/// - **Parameter Order**: Determined by insertion order in IndexMap
1006///
1007/// # Performance Optimization
1008///
1009/// This function uses `entry().or_insert()` to avoid duplicate hash lookups,
1010/// which improves performance when merging large parameter lists.
1011///
1012/// # Arguments
1013///
1014/// * `current` - The existing parameter list (None if first call)
1015/// * `new` - The new parameter list to merge in
1016///
1017/// # Returns
1018///
1019/// `Some(Vec<Parameter>)` with merged parameters, or `Some(empty_vec)` if both are None
1020///
1021/// # Example
1022///
1023/// ```rust
1024/// // Test 1: GET /users?limit=10
1025/// // Test 2: GET /users?offset=5&sort=name
1026/// // Result: GET /users supports limit, offset, and sort parameters
1027/// ```
1028fn merge_parameters(
1029    current: Option<Vec<Parameter>>,
1030    new: Option<Vec<Parameter>>,
1031) -> Option<Vec<Parameter>> {
1032    let mut result = IndexMap::new();
1033    // Optimized: Avoid cloning parameter names by using references for lookup
1034    for param in new.unwrap_or_default() {
1035        result.insert(param.name.clone(), param);
1036    }
1037    for param in current.unwrap_or_default() {
1038        result.entry(param.name.clone()).or_insert(param);
1039    }
1040
1041    let result = result.into_values().collect();
1042    Some(result)
1043}
1044
1045fn merge_responses(
1046    current: utoipa::openapi::Responses,
1047    new: utoipa::openapi::Responses,
1048) -> utoipa::openapi::Responses {
1049    use utoipa::openapi::ResponsesBuilder;
1050
1051    let mut merged_responses = IndexMap::new();
1052
1053    // Add responses from new operation first
1054    for (status, response) in new.responses {
1055        merged_responses.insert(status, response);
1056    }
1057
1058    // Add responses from current operation, preferring new ones
1059    for (status, response) in current.responses {
1060        merged_responses.entry(status).or_insert(response);
1061    }
1062
1063    let mut builder = ResponsesBuilder::new();
1064    for (status, response) in merged_responses {
1065        builder = builder.response(status, response);
1066    }
1067
1068    builder.build()
1069}
1070
1071/// Common API path prefixes that should be skipped when generating operation metadata.
1072/// These are typically organizational prefixes that don't represent business resources.
1073const SKIP_PATH_PREFIXES: &[&str] = &[
1074    "api",      // Most common: /api/users
1075    "v1",       // Versioning: /v1/users, /api/v1/users
1076    "v2",       // Versioning: /v2/users
1077    "v3",       // Versioning: /v3/users
1078    "rest",     // REST API prefix: /rest/users
1079    "service",  // Service-oriented: /service/users
1080    "public",   // Public API: /public/users
1081    "internal", // Internal API: /internal/users
1082];
1083
1084/// Generates a human-readable description for an operation based on HTTP method and path.
1085fn generate_description(method: &http::Method, path: &str) -> Option<String> {
1086    let path = path.trim_start_matches('/');
1087    let segments: Vec<&str> = path.split('/').collect();
1088
1089    if segments.is_empty() || (segments.len() == 1 && segments[0].is_empty()) {
1090        return None;
1091    }
1092
1093    // Skip common API prefixes (api, v1, v2, rest, etc.)
1094    let start_index = segments
1095        .iter()
1096        .take_while(|&segment| SKIP_PATH_PREFIXES.contains(segment))
1097        .count();
1098
1099    if start_index >= segments.len() {
1100        return None;
1101    }
1102
1103    // Extract the resource name from the path
1104    let resource = if segments.len() == start_index + 1 {
1105        // Simple path like "/users" or "/api/users"
1106        segments[start_index]
1107    } else if segments.len() >= start_index + 2 {
1108        // Path with potential ID parameter like "/users/{id}" or "/users/123"
1109        // Or nested resource like "/users/profile" or "/observations/import"
1110        let last_segment = segments.last().unwrap();
1111        if last_segment.starts_with('{') && last_segment.ends_with('}') {
1112            // Last segment is a parameter, use the previous segment as resource
1113            segments[segments.len() - 2]
1114        } else if segments.len() > start_index + 1 {
1115            // Check if this is a nested action (like import, upload, etc.)
1116            let resource_name = segments[start_index];
1117            let action = last_segment;
1118
1119            // Special handling for common actions
1120            match *action {
1121                "import" => return Some(format!("Import {resource_name}")),
1122                "upload" => return Some(format!("Upload {resource_name}")),
1123                "export" => return Some(format!("Export {resource_name}")),
1124                "search" => return Some(format!("Search {resource_name}")),
1125                _ => last_segment, // Use the last segment as the resource
1126            }
1127        } else {
1128            last_segment
1129        }
1130    } else {
1131        segments[start_index]
1132    };
1133
1134    // Check if the path has an ID parameter (indicates single resource operation)
1135    let has_id = segments
1136        .iter()
1137        .any(|segment| segment.starts_with('{') && segment.ends_with('}'));
1138
1139    let action = match *method {
1140        http::Method::GET => {
1141            if has_id {
1142                format!("Retrieve {} by ID", singularize(resource))
1143            } else {
1144                format!("Retrieve {resource}")
1145            }
1146        }
1147        http::Method::POST => {
1148            if has_id {
1149                format!("Create {} by ID", singularize(resource))
1150            } else {
1151                format!("Create {}", singularize(resource))
1152            }
1153        }
1154        http::Method::PUT => {
1155            if has_id {
1156                format!("Update {} by ID", singularize(resource))
1157            } else {
1158                format!("Update {resource}")
1159            }
1160        }
1161        http::Method::PATCH => {
1162            if has_id {
1163                format!("Partially update {} by ID", singularize(resource))
1164            } else {
1165                format!("Partially update {resource}")
1166            }
1167        }
1168        http::Method::DELETE => {
1169            if has_id {
1170                format!("Delete {} by ID", singularize(resource))
1171            } else {
1172                format!("Delete {resource}")
1173            }
1174        }
1175        _ => return None,
1176    };
1177
1178    Some(action)
1179}
1180
1181/// Generates appropriate tags for an operation based on the path.
1182fn generate_tags(path: &str) -> Option<Vec<String>> {
1183    let path = path.trim_start_matches('/');
1184    let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
1185
1186    if segments.is_empty() {
1187        return None;
1188    }
1189
1190    let mut tags = Vec::new();
1191
1192    // Skip common API prefixes (api, v1, v2, rest, etc.)
1193    let start_index = segments
1194        .iter()
1195        .take_while(|&segment| SKIP_PATH_PREFIXES.contains(segment))
1196        .count();
1197
1198    if start_index >= segments.len() {
1199        return None;
1200    }
1201
1202    // Add the main resource name
1203    let resource = segments[start_index];
1204    tags.push(resource.to_string());
1205
1206    // Add action-specific tags for nested resources
1207    if segments.len() > start_index + 1 {
1208        let last_segment = segments.last().unwrap();
1209        // Only add as tag if it's not a parameter (doesn't contain braces)
1210        if !last_segment.starts_with('{') {
1211            match *last_segment {
1212                "import" | "upload" | "export" | "search" | "bulk" => {
1213                    tags.push(last_segment.to_string());
1214                }
1215                _ => {
1216                    // For other nested resources, add them as secondary tags
1217                    if segments.len() == start_index + 2 {
1218                        tags.push(last_segment.to_string());
1219                    }
1220                }
1221            }
1222        }
1223    }
1224
1225    if tags.is_empty() { None } else { Some(tags) }
1226}
1227
1228/// Singularize English words using the cruet crate with manual handling for known limitations.
1229/// This provides production-ready pluralization handling for API resource names.
1230/// Includes custom handling for irregular cases that cruet doesn't cover.
1231fn singularize(word: &str) -> String {
1232    // Handle special cases that cruet doesn't handle properly
1233    match word {
1234        "children" => return "child".to_string(),
1235        "people" => return "person".to_string(),
1236        "data" => return "datum".to_string(),
1237        "feet" => return "foot".to_string(),
1238        "teeth" => return "tooth".to_string(),
1239        "geese" => return "goose".to_string(),
1240        "men" => return "man".to_string(),
1241        "women" => return "woman".to_string(),
1242        _ => {}
1243    }
1244
1245    // Use cruet for most cases
1246    use cruet::*;
1247    let result = word.to_singular();
1248
1249    // Fallback to original word if cruet returns empty string
1250    if result.is_empty() && !word.is_empty() {
1251        word.to_string()
1252    } else {
1253        result
1254    }
1255}
1256
1257#[cfg(test)]
1258mod operation_metadata_tests {
1259    use super::*;
1260    use http::Method;
1261
1262    #[test]
1263    fn test_generate_description_simple_paths() {
1264        assert_eq!(
1265            generate_description(&Method::GET, "/users"),
1266            Some("Retrieve users".to_string())
1267        );
1268        assert_eq!(
1269            generate_description(&Method::POST, "/users"),
1270            Some("Create user".to_string())
1271        );
1272        assert_eq!(
1273            generate_description(&Method::PUT, "/users"),
1274            Some("Update users".to_string())
1275        );
1276        assert_eq!(
1277            generate_description(&Method::DELETE, "/users"),
1278            Some("Delete users".to_string())
1279        );
1280        assert_eq!(
1281            generate_description(&Method::PATCH, "/users"),
1282            Some("Partially update users".to_string())
1283        );
1284    }
1285
1286    #[test]
1287    fn test_generate_description_with_id_parameter() {
1288        assert_eq!(
1289            generate_description(&Method::GET, "/users/{id}"),
1290            Some("Retrieve user by ID".to_string())
1291        );
1292        assert_eq!(
1293            generate_description(&Method::PUT, "/users/{id}"),
1294            Some("Update user by ID".to_string())
1295        );
1296        assert_eq!(
1297            generate_description(&Method::DELETE, "/users/{id}"),
1298            Some("Delete user by ID".to_string())
1299        );
1300        assert_eq!(
1301            generate_description(&Method::PATCH, "/users/{id}"),
1302            Some("Partially update user by ID".to_string())
1303        );
1304    }
1305
1306    #[test]
1307    fn test_generate_description_special_actions() {
1308        assert_eq!(
1309            generate_description(&Method::POST, "/observations/import"),
1310            Some("Import observations".to_string())
1311        );
1312        assert_eq!(
1313            generate_description(&Method::POST, "/observations/upload"),
1314            Some("Upload observations".to_string())
1315        );
1316        assert_eq!(
1317            generate_description(&Method::POST, "/users/export"),
1318            Some("Export users".to_string())
1319        );
1320        assert_eq!(
1321            generate_description(&Method::GET, "/users/search"),
1322            Some("Search users".to_string())
1323        );
1324    }
1325
1326    #[test]
1327    fn test_generate_description_api_prefix() {
1328        assert_eq!(
1329            generate_description(&Method::GET, "/api/observations"),
1330            Some("Retrieve observations".to_string())
1331        );
1332        assert_eq!(
1333            generate_description(&Method::POST, "/api/observations/import"),
1334            Some("Import observations".to_string())
1335        );
1336        // Test multiple prefixes
1337        assert_eq!(
1338            generate_description(&Method::GET, "/api/v1/users"),
1339            Some("Retrieve users".to_string())
1340        );
1341        assert_eq!(
1342            generate_description(&Method::POST, "/rest/service/items"),
1343            Some("Create item".to_string())
1344        );
1345    }
1346
1347    #[test]
1348    fn test_generate_tags_simple_paths() {
1349        assert_eq!(generate_tags("/users"), Some(vec!["users".to_string()]));
1350        assert_eq!(
1351            generate_tags("/observations"),
1352            Some(vec!["observations".to_string()])
1353        );
1354    }
1355
1356    #[test]
1357    fn test_generate_tags_with_api_prefix() {
1358        assert_eq!(generate_tags("/api/users"), Some(vec!["users".to_string()]));
1359        assert_eq!(
1360            generate_tags("/api/observations"),
1361            Some(vec!["observations".to_string()])
1362        );
1363        // Test multiple prefixes
1364        assert_eq!(
1365            generate_tags("/api/v1/users"),
1366            Some(vec!["users".to_string()])
1367        );
1368        assert_eq!(
1369            generate_tags("/rest/service/items"),
1370            Some(vec!["items".to_string()])
1371        );
1372    }
1373
1374    #[test]
1375    fn test_generate_tags_with_special_actions() {
1376        assert_eq!(
1377            generate_tags("/api/observations/import"),
1378            Some(vec!["observations".to_string(), "import".to_string()])
1379        );
1380        assert_eq!(
1381            generate_tags("/api/observations/upload"),
1382            Some(vec!["observations".to_string(), "upload".to_string()])
1383        );
1384        assert_eq!(
1385            generate_tags("/users/export"),
1386            Some(vec!["users".to_string(), "export".to_string()])
1387        );
1388    }
1389
1390    #[test]
1391    fn test_generate_tags_with_id_parameter() {
1392        assert_eq!(
1393            generate_tags("/api/observations/{id}"),
1394            Some(vec!["observations".to_string()])
1395        );
1396        assert_eq!(
1397            generate_tags("/users/{user_id}"),
1398            Some(vec!["users".to_string()])
1399        );
1400    }
1401
1402    #[test]
1403    fn test_singularize() {
1404        // Regular plurals that cruet handles well
1405        assert_eq!(singularize("users"), "user");
1406        assert_eq!(singularize("observations"), "observation");
1407        assert_eq!(singularize("items"), "item");
1408
1409        // Irregular plurals - handled by manual overrides + cruet
1410        assert_eq!(singularize("mice"), "mouse"); // cruet handles this
1411        assert_eq!(singularize("children"), "child"); // manual override
1412        assert_eq!(singularize("people"), "person"); // manual override
1413        assert_eq!(singularize("feet"), "foot"); // manual override
1414        assert_eq!(singularize("teeth"), "tooth"); // manual override
1415        assert_eq!(singularize("geese"), "goose"); // manual override
1416        assert_eq!(singularize("men"), "man"); // manual override
1417        assert_eq!(singularize("women"), "woman"); // manual override
1418        assert_eq!(singularize("data"), "datum"); // manual override
1419
1420        // Words ending in 'es'
1421        assert_eq!(singularize("boxes"), "box");
1422        assert_eq!(singularize("watches"), "watch");
1423
1424        // Already singular - cruet handles these gracefully
1425        assert_eq!(singularize("user"), "user");
1426        assert_eq!(singularize("child"), "child");
1427
1428        // Edge cases - with fallback protection
1429        assert_eq!(singularize("s"), "s"); // Falls back to original when cruet returns empty
1430        assert_eq!(singularize(""), ""); // Empty string stays empty
1431
1432        // Complex cases that cruet handles well
1433        assert_eq!(singularize("categories"), "category");
1434        assert_eq!(singularize("companies"), "company");
1435        assert_eq!(singularize("libraries"), "library");
1436
1437        // Additional cases cruet handles
1438        assert_eq!(singularize("stories"), "story");
1439        assert_eq!(singularize("cities"), "city");
1440    }
1441}