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}