clawspec_core/client/
mod.rs

1use std::mem;
2use std::sync::Arc;
3
4use http::{Method, Uri};
5use tokio::sync::RwLock;
6use utoipa::openapi::{Components, Info, OpenApi, Paths, Server, Tag};
7
8mod builder;
9pub use self::builder::ApiClientBuilder;
10
11mod call;
12pub use self::call::ApiCall;
13
14mod status;
15pub use self::status::ExpectedStatusCodes;
16
17mod param;
18pub use self::param::{ParamStyle, ParamValue, ParameterValue};
19
20mod path;
21pub use self::path::CallPath;
22
23mod query;
24pub use self::query::CallQuery;
25
26mod headers;
27pub use self::headers::CallHeaders;
28
29mod cookies;
30pub use self::cookies::CallCookies;
31
32mod auth;
33pub use self::auth::{Authentication, AuthenticationError, SecureString};
34
35mod call_parameters;
36
37mod body;
38pub use self::body::CallBody;
39
40mod schema;
41
42mod error;
43pub use self::error::ApiClientError;
44
45mod collectors;
46// CallResult, RawResult, and RawBody are public API, but CalledOperation and Collectors are internal
47pub use self::collectors::{CallResult, RawBody, RawResult};
48
49#[cfg(test)]
50mod integration_tests;
51
52mod output;
53
54/// A type-safe HTTP client for API testing and OpenAPI documentation generation.
55///
56/// `ApiClient` is the core component of clawspec that enables you to make HTTP requests
57/// while automatically capturing request/response schemas for OpenAPI specification generation.
58/// It provides a fluent API for building requests with comprehensive parameter support,
59/// status code validation, and automatic schema collection.
60///
61/// # Key Features
62///
63/// - **Test-Driven Documentation**: Automatically generates OpenAPI specifications from test execution
64/// - **Type Safety**: Compile-time guarantees for API parameters and response types  
65/// - **Flexible Status Code Validation**: Support for ranges, specific codes, and custom patterns
66/// - **Comprehensive Parameter Support**: Path, query, and header parameters with multiple styles
67/// - **Request Body Formats**: JSON, form-encoded, multipart, and raw binary data
68/// - **Schema Collection**: Automatic detection and collection of request/response schemas
69/// - **OpenAPI Metadata**: Configurable API info, servers, and operation documentation
70///
71/// # Basic Usage
72///
73/// ```rust,no_run
74/// use clawspec_core::ApiClient;
75/// use serde::{Deserialize, Serialize};
76/// use utoipa::ToSchema;
77///
78/// #[derive(Debug, Deserialize, ToSchema)]
79/// struct User {
80///     id: u32,
81///     name: String,
82///     email: String,
83/// }
84///
85/// #[tokio::main]
86/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
87///     // Create an API client
88///     let mut client = ApiClient::builder()
89///         .with_host("api.example.com")
90///         .with_base_path("/v1")?
91///         .build()?;
92///
93///     // Make a request and capture the schema
94///     let user: User = client
95///         .get("/users/123")?
96///         
97///         .await?
98///         .as_json()
99///         .await?;
100///
101///     println!("User: {:?}", user);
102///
103///     // Generate OpenAPI specification from collected data
104///     let openapi_spec = client.collected_openapi().await;
105///     let yaml = serde_yaml::to_string(&openapi_spec)?;
106///     println!("{yaml}");
107///
108///     Ok(())
109/// }
110/// ```
111///
112/// # Builder Pattern
113///
114/// The client is created using a builder pattern that allows you to configure various aspects:
115///
116/// ```rust
117/// use clawspec_core::ApiClient;
118/// use http::uri::Scheme;
119/// use utoipa::openapi::{InfoBuilder, ServerBuilder};
120///
121/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
122/// let client = ApiClient::builder()
123///     .with_scheme(Scheme::HTTPS)
124///     .with_host("api.github.com")
125///     .with_port(443)
126///     .with_base_path("/api/v3")?
127///     .with_info(
128///         InfoBuilder::new()
129///             .title("GitHub API Client")
130///             .version("1.0.0")
131///             .description(Some("Auto-generated from tests"))
132///             .build()
133///     )
134///     .add_server(
135///         ServerBuilder::new()
136///             .url("https://api.github.com/api/v3")
137///             .description(Some("GitHub API v3"))
138///             .build()
139///     )
140///     .build()?;
141/// # Ok(())
142/// # }
143/// ```
144///
145/// # Making Requests
146///
147/// The client supports all standard HTTP methods with a fluent API:
148///
149/// ```rust
150/// use clawspec_core::{ApiClient, expected_status_codes, CallQuery, CallHeaders, ParamValue};
151/// use serde::{Serialize, Deserialize};
152/// use utoipa::ToSchema;
153///
154/// #[derive(Serialize, Deserialize, ToSchema)]
155/// struct UserData { name: String }
156///
157/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
158/// let mut client = ApiClient::builder().build()?;
159/// let user_data = UserData { name: "John".to_string() };
160///
161/// // GET request with query parameters and headers
162/// let users = client
163///     .get("/users")?
164///     .with_query(
165///         CallQuery::new()
166///             .add_param("page", ParamValue::new(1))
167///             .add_param("per_page", ParamValue::new(50))
168///     )
169///     .with_header("Authorization", "Bearer token123")
170///     .with_expected_status_codes(expected_status_codes!(200, 404))
171///     
172///     .await?
173///     .as_json::<Vec<UserData>>()
174///     .await?;
175///
176/// // POST request with JSON body
177/// let new_user = client
178///     .post("/users")?
179///     .json(&user_data)?
180///     .with_expected_status_codes(expected_status_codes!(201, 409))
181///     
182///     .await?
183///     .as_json::<UserData>()
184///     .await?;
185/// # Ok(())
186/// # }
187/// ```
188///
189/// # Schema Registration
190///
191/// For types that aren't automatically detected, you can manually register them:
192///
193/// ```rust
194/// use clawspec_core::{ApiClient, register_schemas};
195/// # use utoipa::ToSchema;
196/// # use serde::{Deserialize, Serialize};
197///
198/// #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
199/// struct ErrorType { message: String }
200///
201/// #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]  
202/// struct NestedType { value: i32 }
203///
204/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
205/// let mut client = ApiClient::builder().build()?;
206///
207/// // Register multiple schemas at once
208/// register_schemas!(client, ErrorType, NestedType);
209///
210/// // Or register individually
211/// client.register_schema::<ErrorType>().await;
212/// # Ok(())
213/// # }
214/// ```
215///
216/// # OpenAPI Generation
217///
218/// The client automatically collects information during test execution and can generate
219/// comprehensive OpenAPI specifications:
220///
221/// ```rust
222/// # use clawspec_core::ApiClient;
223/// # use serde::{Serialize, Deserialize};
224/// # use utoipa::ToSchema;
225/// # #[derive(Serialize, Deserialize, ToSchema)]
226/// # struct UserData { name: String }
227/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
228/// let mut client = ApiClient::builder().build()?;
229/// let user_data = UserData { name: "John".to_string() };
230///
231/// // Make some API calls...
232/// client.get("/users")?.await?.as_json::<Vec<UserData>>().await?;
233/// client.post("/users")?.json(&user_data)?.await?.as_json::<UserData>().await?;
234///
235/// // Generate OpenAPI specification
236/// let openapi = client.collected_openapi().await;
237///
238/// // Convert to YAML or JSON
239/// let yaml = serde_yaml::to_string(&openapi)?;
240/// let json = serde_json::to_string_pretty(&openapi)?;
241/// # Ok(())
242/// # }
243/// ```
244///
245/// # Error Handling
246///
247/// The client provides comprehensive error handling for various scenarios:
248///
249/// ```rust
250/// use clawspec_core::{ApiClient, ApiClientError};
251///
252/// # async fn example() -> Result<(), ApiClientError> {
253/// let mut client = ApiClient::builder().build()?;
254///
255/// match client.get("/users/999")?.await {
256///     Ok(response) => {
257///         // Handle successful response
258///         println!("Success!");
259///     }
260///     Err(ApiClientError::UnexpectedStatusCode { status_code, body }) => {
261///         // Handle HTTP errors
262///         println!("HTTP {} error: {}", status_code, body);
263///     }
264///     Err(ApiClientError::ReqwestError(source)) => {
265///         // Handle network/request errors
266///         println!("Request failed: {}", source);
267///     }
268///     Err(err) => {
269///         // Handle other errors
270///         println!("Other error: {}", err);
271///     }
272/// }
273/// # Ok(())
274/// # }
275/// ```
276///
277/// # Thread Safety
278///
279/// `ApiClient` is designed to be safe to use across multiple threads. The internal schema
280/// collection is protected by async locks, allowing concurrent request execution while
281/// maintaining data consistency.
282///
283/// # Performance Considerations
284///
285/// - Schema collection has minimal runtime overhead
286/// - Request bodies are streamed when possible
287/// - Response processing is lazy - schemas are only collected when responses are consumed
288/// - Internal caching reduces redundant schema processing
289#[derive(Debug, Clone)]
290pub struct ApiClient {
291    client: reqwest::Client,
292    base_uri: Uri,
293    base_path: String,
294    info: Option<Info>,
295    servers: Vec<Server>,
296    collectors: Arc<RwLock<collectors::Collectors>>,
297    authentication: Option<Authentication>,
298}
299
300// Create
301impl ApiClient {
302    pub fn builder() -> ApiClientBuilder {
303        ApiClientBuilder::default()
304    }
305}
306
307// Collected
308impl ApiClient {
309    pub async fn collected_paths(&mut self) -> Paths {
310        let mut builder = Paths::builder();
311        let mut collectors = self.collectors.write().await;
312        for (path, item) in collectors.as_map(&self.base_path) {
313            builder = builder.path(path, item);
314        }
315        mem::drop(collectors);
316
317        builder.build()
318    }
319
320    /// Generates a complete OpenAPI specification from collected request/response data.
321    ///
322    /// This method aggregates all the information collected during API calls and produces
323    /// a comprehensive OpenAPI 3.1 specification including paths, components, schemas,
324    /// operation metadata, and server information.
325    ///
326    /// # Features
327    ///
328    /// - **Automatic Path Collection**: All endpoint calls are automatically documented
329    /// - **Schema Generation**: Request/response schemas are extracted from Rust types  
330    /// - **Operation Metadata**: Includes operation IDs, descriptions, and tags
331    /// - **Server Information**: Configurable server URLs and descriptions
332    /// - **Tag Collection**: Automatically computed from all operations
333    /// - **Component Schemas**: Reusable schema definitions with proper references
334    ///
335    /// # Example
336    ///
337    /// ```rust
338    /// use clawspec_core::ApiClient;
339    /// use utoipa::openapi::{InfoBuilder, ServerBuilder};
340    /// use serde::{Serialize, Deserialize};
341    /// use utoipa::ToSchema;
342    ///
343    /// #[derive(Serialize, Deserialize, ToSchema)]
344    /// struct UserData { name: String }
345    ///
346    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
347    /// let mut client = ApiClient::builder()
348    ///     .with_host("api.example.com")
349    ///     .with_info(
350    ///         InfoBuilder::new()
351    ///             .title("My API")
352    ///             .version("1.0.0")
353    ///             .build()
354    ///     )
355    ///     .add_server(
356    ///         ServerBuilder::new()
357    ///             .url("https://api.example.com")
358    ///             .description(Some("Production server"))
359    ///             .build()
360    ///     )
361    ///     .build()?;
362    ///
363    /// let user_data = UserData { name: "John".to_string() };
364    ///
365    /// // Make some API calls to collect data
366    /// client.get("/users")?.await?.as_json::<Vec<UserData>>().await?;
367    /// client.post("/users")?.json(&user_data)?.await?.as_json::<UserData>().await?;
368    ///
369    /// // Generate complete OpenAPI specification
370    /// let openapi = client.collected_openapi().await;
371    ///
372    /// // The generated spec includes:
373    /// // - API info (title, version, description)
374    /// // - Server definitions
375    /// // - All paths with operations
376    /// // - Component schemas
377    /// // - Computed tags from operations
378    ///
379    /// // Export to different formats
380    /// let yaml = serde_yaml::to_string(&openapi)?;
381    /// let json = serde_json::to_string_pretty(&openapi)?;
382    /// # Ok(())
383    /// # }
384    /// ```
385    ///
386    /// # Generated Content
387    ///
388    /// The generated OpenAPI specification includes:
389    ///
390    /// - **Info**: API metadata (title, version, description) if configured
391    /// - **Servers**: Server URLs and descriptions if configured  
392    /// - **Paths**: All documented endpoints with operations
393    /// - **Components**: Reusable schema definitions
394    /// - **Tags**: Automatically computed from operation tags
395    ///
396    /// # Tag Generation
397    ///
398    /// Tags are automatically computed from all operations and include:
399    /// - Explicit tags set on operations
400    /// - Auto-generated tags based on path patterns
401    /// - Deduplicated and sorted alphabetically
402    ///
403    /// # Performance Notes
404    ///
405    /// - This method acquires read locks on internal collections
406    /// - Schema processing is cached to avoid redundant work
407    /// - Tags are computed on-demand from operation metadata
408    pub async fn collected_openapi(&mut self) -> OpenApi {
409        let mut builder = OpenApi::builder();
410
411        // Add API info if configured
412        if let Some(ref info) = self.info {
413            builder = builder.info(info.clone());
414        }
415
416        // Add servers if configured
417        if !self.servers.is_empty() {
418            builder = builder.servers(Some(self.servers.clone()));
419        }
420
421        // Add paths
422        builder = builder.paths(self.collected_paths().await);
423
424        // Add components with schemas
425        let collectors = self.collectors.read().await;
426        let components = Components::builder()
427            .schemas_from_iter(collectors.schemas())
428            .build();
429
430        // Compute tags from all operations
431        let tags = self.compute_tags(&collectors).await;
432        mem::drop(collectors);
433
434        let builder = builder.components(Some(components));
435
436        // Add computed tags if any exist
437        let builder = if tags.is_empty() {
438            builder
439        } else {
440            builder.tags(Some(tags))
441        };
442
443        builder.build()
444    }
445
446    /// Computes the list of unique tags from all collected operations.
447    async fn compute_tags(&self, collectors: &collectors::Collectors) -> Vec<Tag> {
448        let mut tag_names = std::collections::BTreeSet::new();
449
450        // Collect all unique tag names from operations
451        for operation in collectors.operations() {
452            if let Some(tags) = operation.tags() {
453                for tag in tags {
454                    tag_names.insert(tag.clone());
455                }
456            }
457        }
458
459        // Convert to Tag objects
460        tag_names.into_iter().map(Tag::new).collect()
461    }
462
463    /// Manually registers a type in the schema collection.
464    ///
465    /// This method allows you to explicitly add types to the OpenAPI schema collection
466    /// that might not be automatically detected. This is useful for types that are
467    /// referenced indirectly, such as nested types.
468    ///
469    /// # Type Parameters
470    ///
471    /// * `T` - The type to register, must implement `ToSchema` and `'static`
472    ///
473    /// # Example
474    ///
475    /// ```rust
476    /// use clawspec_core::ApiClient;
477    /// # use utoipa::ToSchema;
478    /// # use serde::{Deserialize, Serialize};
479    ///
480    /// #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
481    /// struct NestedErrorType {
482    ///     message: String,
483    /// }
484    ///
485    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
486    /// let mut client = ApiClient::builder().build()?;
487    ///
488    /// // Register the nested type that might not be automatically detected
489    /// client.register_schema::<NestedErrorType>().await;
490    ///
491    /// // Now when you generate the OpenAPI spec, NestedErrorType will be included
492    /// let openapi = client.collected_openapi().await;
493    /// # Ok(())
494    /// # }
495    /// ```
496    pub async fn register_schema<T>(&mut self)
497    where
498        T: utoipa::ToSchema + 'static,
499    {
500        let mut schemas = schema::Schemas::default();
501        schemas.add::<T>();
502
503        let mut collectors = self.collectors.write().await;
504        collectors.collect_schemas(schemas);
505    }
506}
507
508impl ApiClient {
509    pub fn call(&self, method: Method, path: CallPath) -> Result<ApiCall, ApiClientError> {
510        ApiCall::build(
511            self.client.clone(),
512            self.base_uri.clone(),
513            Arc::clone(&self.collectors),
514            method,
515            path,
516            self.authentication.clone(),
517        )
518    }
519
520    pub fn get(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError> {
521        self.call(Method::GET, path.into())
522    }
523
524    pub fn post(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError> {
525        self.call(Method::POST, path.into())
526    }
527
528    pub fn put(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError> {
529        self.call(Method::PUT, path.into())
530    }
531
532    pub fn delete(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError> {
533        self.call(Method::DELETE, path.into())
534    }
535
536    pub fn patch(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError> {
537        self.call(Method::PATCH, path.into())
538    }
539}