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::{
25 RedactOptions, RedactedResult, RedactionBuilder, Redactor, ValueRedactionBuilder, redact_value,
26};
27
28mod auth;
29pub use self::auth::{Authentication, AuthenticationError, SecureString};
30
31#[cfg(feature = "oauth2")]
32pub mod oauth2;
33#[cfg(feature = "oauth2")]
34pub use self::oauth2::{OAuth2Config, OAuth2ConfigBuilder, OAuth2Error, OAuth2Token};
35
36mod security;
37pub use self::security::{
38 ApiKeyLocation, OAuth2Flow, OAuth2Flows, OAuth2ImplicitFlow, SecurityRequirement,
39 SecurityScheme,
40};
41
42mod call_parameters;
43
44mod openapi;
45// CallResult, RawResult, and RawBody are public API, but CalledOperation and Collectors are internal
46pub use self::openapi::{CallResult, RawBody, RawResult};
47
48mod error;
49pub use self::error::ApiClientError;
50
51#[cfg(test)]
52mod integration_tests;
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_saphyr::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. For simple cases, use the simplified methods:
115///
116/// ```rust
117/// use clawspec_core::ApiClient;
118///
119/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
120/// let client = ApiClient::builder()
121/// .with_https()
122/// .with_host("api.github.com")
123/// .with_port(443)
124/// .with_base_path("/api/v3")?
125/// .with_info_simple("GitHub API Client", "1.0.0")
126/// .with_description("Auto-generated from tests")
127/// .add_server_simple("https://api.github.com/api/v3", "GitHub API v3")
128/// .build()?;
129/// # Ok(())
130/// # }
131/// ```
132///
133/// For advanced configuration, use the builder types (re-exported from clawspec_core):
134///
135/// ```rust
136/// use clawspec_core::{ApiClient, InfoBuilder, ServerBuilder};
137///
138/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
139/// let client = ApiClient::builder()
140/// .with_https()
141/// .with_host("api.github.com")
142/// .with_port(443)
143/// .with_base_path("/api/v3")?
144/// .with_info(
145/// InfoBuilder::new()
146/// .title("GitHub API Client")
147/// .version("1.0.0")
148/// .description(Some("Auto-generated from tests"))
149/// .build()
150/// )
151/// .add_server(
152/// ServerBuilder::new()
153/// .url("https://api.github.com/api/v3")
154/// .description(Some("GitHub API v3"))
155/// .build()
156/// )
157/// .build()?;
158/// # Ok(())
159/// # }
160/// ```
161///
162/// # Making Requests
163///
164/// The client supports all standard HTTP methods with a fluent API:
165///
166/// ```rust
167/// use clawspec_core::{ApiClient, expected_status_codes, CallQuery, CallHeaders, ParamValue};
168/// use serde::{Serialize, Deserialize};
169/// use utoipa::ToSchema;
170///
171/// #[derive(Serialize, Deserialize, ToSchema)]
172/// struct UserData { name: String }
173///
174/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
175/// let mut client = ApiClient::builder().build()?;
176/// let user_data = UserData { name: "John".to_string() };
177///
178/// // GET request with query parameters and headers
179/// let users = client
180/// .get("/users")?
181/// .with_query(
182/// CallQuery::new()
183/// .add_param("page", ParamValue::new(1))
184/// .add_param("per_page", ParamValue::new(50))
185/// )
186/// .with_header("Authorization", "Bearer token123")
187/// .with_expected_status_codes(expected_status_codes!(200, 404))
188///
189/// .await?
190/// .as_json::<Vec<UserData>>()
191/// .await?;
192///
193/// // POST request with JSON body
194/// let new_user = client
195/// .post("/users")?
196/// .json(&user_data)?
197/// .with_expected_status_codes(expected_status_codes!(201, 409))
198///
199/// .await?
200/// .as_json::<UserData>()
201/// .await?;
202/// # Ok(())
203/// # }
204/// ```
205///
206/// # Schema Registration
207///
208/// For types that aren't automatically detected, you can manually register them:
209///
210/// ```rust
211/// use clawspec_core::{ApiClient, register_schemas};
212/// # use utoipa::ToSchema;
213/// # use serde::{Deserialize, Serialize};
214///
215/// #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
216/// struct ErrorType { message: String }
217///
218/// #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
219/// struct NestedType { value: i32 }
220///
221/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
222/// let mut client = ApiClient::builder().build()?;
223///
224/// // Register multiple schemas at once
225/// register_schemas!(client, ErrorType, NestedType);
226///
227/// // Or register individually
228/// client.register_schema::<ErrorType>().await;
229/// # Ok(())
230/// # }
231/// ```
232///
233/// # OpenAPI Generation
234///
235/// The client automatically collects information during test execution and can generate
236/// comprehensive OpenAPI specifications:
237///
238/// ```rust
239/// # use clawspec_core::ApiClient;
240/// # use serde::{Serialize, Deserialize};
241/// # use utoipa::ToSchema;
242/// # #[derive(Serialize, Deserialize, ToSchema)]
243/// # struct UserData { name: String }
244/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
245/// let mut client = ApiClient::builder().build()?;
246/// let user_data = UserData { name: "John".to_string() };
247///
248/// // Make some API calls...
249/// client.get("/users")?.await?.as_json::<Vec<UserData>>().await?;
250/// client.post("/users")?.json(&user_data)?.await?.as_json::<UserData>().await?;
251///
252/// // Generate OpenAPI specification
253/// let openapi = client.collected_openapi().await;
254///
255/// // Convert to YAML or JSON
256/// let yaml = serde_saphyr::to_string(&openapi)?;
257/// let json = serde_json::to_string_pretty(&openapi)?;
258/// # Ok(())
259/// # }
260/// ```
261///
262/// # Error Handling
263///
264/// The client provides comprehensive error handling for various scenarios:
265///
266/// ```rust
267/// use clawspec_core::{ApiClient, ApiClientError};
268///
269/// # async fn example() -> Result<(), ApiClientError> {
270/// let mut client = ApiClient::builder().build()?;
271///
272/// match client.get("/users/999")?.await {
273/// Ok(response) => {
274/// // Handle successful response
275/// println!("Success!");
276/// }
277/// Err(ApiClientError::UnexpectedStatusCode { status_code, body }) => {
278/// // Handle HTTP errors
279/// println!("HTTP {} error: {}", status_code, body);
280/// }
281/// Err(ApiClientError::ReqwestError(source)) => {
282/// // Handle network/request errors
283/// println!("Request failed: {}", source);
284/// }
285/// Err(err) => {
286/// // Handle other errors
287/// println!("Other error: {}", err);
288/// }
289/// }
290/// # Ok(())
291/// # }
292/// ```
293///
294/// # Thread Safety
295///
296/// `ApiClient` is designed to be safe to use across multiple threads. The internal schema
297/// collection is protected by async locks, allowing concurrent request execution while
298/// maintaining data consistency.
299///
300/// # Performance Considerations
301///
302/// - Schema collection has minimal runtime overhead
303/// - Request bodies are streamed when possible
304/// - Response processing is lazy - schemas are only collected when responses are consumed
305/// - Internal caching reduces redundant schema processing
306use indexmap::IndexMap;
307
308#[derive(Debug, Clone)]
309pub struct ApiClient {
310 client: reqwest::Client,
311 base_uri: Uri,
312 base_path: String,
313 info: Option<Info>,
314 servers: Vec<Server>,
315 collector_handle: CollectorHandle,
316 authentication: Option<Authentication>,
317 security_schemes: IndexMap<String, SecurityScheme>,
318 default_security: Vec<SecurityRequirement>,
319}
320
321// Create
322impl ApiClient {
323 pub fn builder() -> ApiClientBuilder {
324 ApiClientBuilder::default()
325 }
326}
327
328// Collected
329impl ApiClient {
330 pub async fn collected_paths(&mut self) -> Paths {
331 let mut builder = Paths::builder();
332 let mut collectors = self.collector_handle.get_collectors().await;
333 for (path, item) in collectors.as_map(&self.base_path) {
334 builder = builder.path(path, item);
335 }
336 mem::drop(collectors);
337
338 builder.build()
339 }
340
341 /// Generates a complete OpenAPI specification from collected request/response data.
342 ///
343 /// This method aggregates all the information collected during API calls and produces
344 /// a comprehensive OpenAPI 3.1 specification including paths, components, schemas,
345 /// operation metadata, and server information.
346 ///
347 /// # Features
348 ///
349 /// - **Automatic Path Collection**: All endpoint calls are automatically documented
350 /// - **Schema Generation**: Request/response schemas are extracted from Rust types
351 /// - **Operation Metadata**: Includes operation IDs, descriptions, and tags
352 /// - **Server Information**: Configurable server URLs and descriptions
353 /// - **Tag Collection**: Automatically computed from all operations
354 /// - **Component Schemas**: Reusable schema definitions with proper references
355 ///
356 /// # Example
357 ///
358 /// ```rust
359 /// use clawspec_core::{ApiClient, ToSchema};
360 /// use serde::{Serialize, Deserialize};
361 ///
362 /// #[derive(Serialize, Deserialize, ToSchema)]
363 /// struct UserData { name: String }
364 ///
365 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
366 /// let mut client = ApiClient::builder()
367 /// .with_host("api.example.com")
368 /// .with_info_simple("My API", "1.0.0")
369 /// .add_server_simple("https://api.example.com", "Production server")
370 /// .build()?;
371 ///
372 /// let user_data = UserData { name: "John".to_string() };
373 ///
374 /// // Make some API calls to collect data
375 /// client.get("/users")?.await?.as_json::<Vec<UserData>>().await?;
376 /// client.post("/users")?.json(&user_data)?.await?.as_json::<UserData>().await?;
377 ///
378 /// // Generate complete OpenAPI specification
379 /// let openapi = client.collected_openapi().await;
380 ///
381 /// // The generated spec includes:
382 /// // - API info (title, version, description)
383 /// // - Server definitions
384 /// // - All paths with operations
385 /// // - Component schemas
386 /// // - Computed tags from operations
387 ///
388 /// // Export to different formats
389 /// let yaml = serde_saphyr::to_string(&openapi)?;
390 /// let json = serde_json::to_string_pretty(&openapi)?;
391 /// # Ok(())
392 /// # }
393 /// ```
394 ///
395 /// # Generated Content
396 ///
397 /// The generated OpenAPI specification includes:
398 ///
399 /// - **Info**: API metadata (title, version, description) if configured
400 /// - **Servers**: Server URLs and descriptions if configured
401 /// - **Paths**: All documented endpoints with operations
402 /// - **Components**: Reusable schema definitions
403 /// - **Tags**: Automatically computed from operation tags
404 ///
405 /// # Tag Generation
406 ///
407 /// Tags are automatically computed from all operations and include:
408 /// - Explicit tags set on operations
409 /// - Auto-generated tags based on path patterns
410 /// - Deduplicated and sorted alphabetically
411 ///
412 /// # Performance Notes
413 ///
414 /// - This method acquires read locks on internal collections
415 /// - Schema processing is cached to avoid redundant work
416 /// - Tags are computed on-demand from operation metadata
417 pub async fn collected_openapi(&mut self) -> OpenApi {
418 let mut builder = OpenApi::builder();
419
420 // Add API info if configured
421 if let Some(ref info) = self.info {
422 builder = builder.info(info.clone());
423 }
424
425 // Add servers if configured
426 if !self.servers.is_empty() {
427 builder = builder.servers(Some(self.servers.clone()));
428 }
429
430 // Add paths
431 builder = builder.paths(self.collected_paths().await);
432
433 // Add components with schemas and security schemes
434 let collectors = self.collector_handle.get_collectors().await;
435 let mut components_builder = Components::builder().schemas_from_iter(collectors.schemas());
436
437 // Add security schemes to components
438 for (name, scheme) in &self.security_schemes {
439 components_builder = components_builder.security_scheme(name, scheme.to_utoipa());
440 }
441
442 let components = components_builder.build();
443
444 // Compute tags from all operations
445 let tags = self.compute_tags(&collectors).await;
446 mem::drop(collectors);
447
448 let builder = builder.components(Some(components));
449
450 // Add computed tags if any exist
451 let builder = if tags.is_empty() {
452 builder
453 } else {
454 builder.tags(Some(tags))
455 };
456
457 // Add default security requirements if configured
458 let builder = if self.default_security.is_empty() {
459 builder
460 } else {
461 let security: Vec<_> = self
462 .default_security
463 .iter()
464 .map(SecurityRequirement::to_utoipa)
465 .collect();
466 builder.security(Some(security))
467 };
468
469 builder.build()
470 }
471
472 /// Computes the list of unique tags from all collected operations.
473 async fn compute_tags(&self, collectors: &openapi::Collectors) -> Vec<Tag> {
474 let mut tag_names = BTreeSet::new();
475
476 // Collect all unique tag names from operations
477 for operation in collectors.operations() {
478 if let Some(tags) = operation.tags() {
479 for tag in tags {
480 tag_names.insert(tag.clone());
481 }
482 }
483 }
484
485 // Convert to Tag objects
486 tag_names.into_iter().map(Tag::new).collect()
487 }
488
489 /// Manually registers a type in the schema collection.
490 ///
491 /// This method allows you to explicitly add types to the OpenAPI schema collection
492 /// that might not be automatically detected. This is useful for types that are
493 /// referenced indirectly, such as nested types.
494 ///
495 /// # Type Parameters
496 ///
497 /// * `T` - The type to register, must implement `ToSchema` and `'static`
498 ///
499 /// # Example
500 ///
501 /// ```rust
502 /// use clawspec_core::ApiClient;
503 /// # use utoipa::ToSchema;
504 /// # use serde::{Deserialize, Serialize};
505 ///
506 /// #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
507 /// struct NestedErrorType {
508 /// message: String,
509 /// }
510 ///
511 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
512 /// let mut client = ApiClient::builder().build()?;
513 ///
514 /// // Register the nested type that might not be automatically detected
515 /// client.register_schema::<NestedErrorType>().await;
516 ///
517 /// // Now when you generate the OpenAPI spec, NestedErrorType will be included
518 /// let openapi = client.collected_openapi().await;
519 /// # Ok(())
520 /// # }
521 /// ```
522 pub async fn register_schema<T>(&mut self)
523 where
524 T: utoipa::ToSchema + 'static,
525 {
526 let mut schemas = Schemas::default();
527 schemas.add::<T>();
528
529 self.collector_handle
530 .sender()
531 .send(CollectorMessage::AddSchemas(schemas))
532 .await;
533 }
534}
535
536impl ApiClient {
537 pub fn call(&self, method: Method, path: CallPath) -> Result<ApiCall, ApiClientError> {
538 // Convert default_security to Option only if not empty
539 let default_security = if self.default_security.is_empty() {
540 None
541 } else {
542 Some(self.default_security.clone())
543 };
544
545 ApiCall::build(
546 self.client.clone(),
547 self.base_uri.clone(),
548 self.collector_handle.sender(),
549 method,
550 path,
551 self.authentication.clone(),
552 default_security,
553 )
554 }
555
556 pub fn get(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError> {
557 self.call(Method::GET, path.into())
558 }
559
560 pub fn post(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError> {
561 self.call(Method::POST, path.into())
562 }
563
564 pub fn put(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError> {
565 self.call(Method::PUT, path.into())
566 }
567
568 pub fn delete(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError> {
569 self.call(Method::DELETE, path.into())
570 }
571
572 pub fn patch(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError> {
573 self.call(Method::PATCH, path.into())
574 }
575}