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}