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}