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