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}