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}