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