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}