clawspec_core/client/
mod.rs

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