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/// HTTP client for API testing with automatic OpenAPI schema collection.
55///
56/// `ApiClient` captures request/response schemas during test execution to generate
57/// OpenAPI specifications. Use [`ApiClientBuilder`] to create instances.
58///
59/// # Example
60///
61/// ```rust,no_run
62/// use clawspec_core::ApiClient;
63/// # use serde::Deserialize;
64/// # use utoipa::ToSchema;
65/// # #[derive(Deserialize, ToSchema)]
66/// # struct User { id: u32, name: String }
67///
68/// # #[tokio::main]
69/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
70/// let mut client = ApiClient::builder()
71///     .with_host("api.example.com")
72///     .build()?;
73///
74/// // Schemas are captured automatically
75/// let user: User = client.get("/users/123")?.await?.as_json().await?;
76///
77/// // Generate OpenAPI spec
78/// let spec = client.collected_openapi().await;
79/// # Ok(())
80/// # }
81/// ```
82///
83/// See the [crate documentation](crate) for detailed usage and the
84/// [Tutorial](crate::_tutorial) for a step-by-step guide.
85///
86/// # Thread Safety
87///
88/// Schema collection is protected by async locks, allowing concurrent request execution.
89use indexmap::IndexMap;
90
91#[derive(Debug, Clone)]
92pub struct ApiClient {
93    client: reqwest::Client,
94    base_uri: Uri,
95    base_path: String,
96    info: Option<Info>,
97    servers: Vec<Server>,
98    collector_handle: CollectorHandle,
99    authentication: Option<Authentication>,
100    security_schemes: IndexMap<String, SecurityScheme>,
101    default_security: Vec<SecurityRequirement>,
102}
103
104// Create
105impl ApiClient {
106    pub fn builder() -> ApiClientBuilder {
107        ApiClientBuilder::default()
108    }
109}
110
111// Collected
112impl ApiClient {
113    pub async fn collected_paths(&mut self) -> Paths {
114        let mut builder = Paths::builder();
115        let mut collectors = self.collector_handle.get_collectors().await;
116        for (path, item) in collectors.as_map(&self.base_path) {
117            builder = builder.path(path, item);
118        }
119        mem::drop(collectors);
120
121        builder.build()
122    }
123
124    /// Generates a complete OpenAPI specification from collected request/response data.
125    ///
126    /// This method aggregates all the information collected during API calls and produces
127    /// a comprehensive OpenAPI 3.1 specification including paths, components, schemas,
128    /// operation metadata, and server information.
129    ///
130    /// # Features
131    ///
132    /// - **Automatic Path Collection**: All endpoint calls are automatically documented
133    /// - **Schema Generation**: Request/response schemas are extracted from Rust types
134    /// - **Operation Metadata**: Includes operation IDs, descriptions, and tags
135    /// - **Server Information**: Configurable server URLs and descriptions
136    /// - **Tag Collection**: Automatically computed from all operations
137    /// - **Component Schemas**: Reusable schema definitions with proper references
138    ///
139    /// # Example
140    ///
141    /// ```rust
142    /// use clawspec_core::{ApiClient, ToSchema};
143    /// use serde::{Serialize, Deserialize};
144    ///
145    /// #[derive(Serialize, Deserialize, ToSchema)]
146    /// struct UserData { name: String }
147    ///
148    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
149    /// let mut client = ApiClient::builder()
150    ///     .with_host("api.example.com")
151    ///     .with_info_simple("My API", "1.0.0")
152    ///     .add_server_simple("https://api.example.com", "Production server")
153    ///     .build()?;
154    ///
155    /// let user_data = UserData { name: "John".to_string() };
156    ///
157    /// // Make some API calls to collect data
158    /// client.get("/users")?.await?.as_json::<Vec<UserData>>().await?;
159    /// client.post("/users")?.json(&user_data)?.await?.as_json::<UserData>().await?;
160    ///
161    /// // Generate complete OpenAPI specification
162    /// let openapi = client.collected_openapi().await;
163    ///
164    /// // The generated spec includes:
165    /// // - API info (title, version, description)
166    /// // - Server definitions
167    /// // - All paths with operations
168    /// // - Component schemas
169    /// // - Computed tags from operations
170    ///
171    /// // Export to different formats
172    /// let yaml = serde_saphyr::to_string(&openapi)?;
173    /// let json = serde_json::to_string_pretty(&openapi)?;
174    /// # Ok(())
175    /// # }
176    /// ```
177    ///
178    /// # Generated Content
179    ///
180    /// The generated OpenAPI specification includes:
181    ///
182    /// - **Info**: API metadata (title, version, description) if configured
183    /// - **Servers**: Server URLs and descriptions if configured
184    /// - **Paths**: All documented endpoints with operations
185    /// - **Components**: Reusable schema definitions
186    /// - **Tags**: Automatically computed from operation tags
187    ///
188    /// # Tag Generation
189    ///
190    /// Tags are automatically computed from all operations and include:
191    /// - Explicit tags set on operations
192    /// - Auto-generated tags based on path patterns
193    /// - Deduplicated and sorted alphabetically
194    ///
195    /// # Performance Notes
196    ///
197    /// - This method acquires read locks on internal collections
198    /// - Schema processing is cached to avoid redundant work
199    /// - Tags are computed on-demand from operation metadata
200    pub async fn collected_openapi(&mut self) -> OpenApi {
201        let mut builder = OpenApi::builder();
202
203        // Add API info if configured
204        if let Some(ref info) = self.info {
205            builder = builder.info(info.clone());
206        }
207
208        // Add servers if configured
209        if !self.servers.is_empty() {
210            builder = builder.servers(Some(self.servers.clone()));
211        }
212
213        // Add paths
214        builder = builder.paths(self.collected_paths().await);
215
216        // Add components with schemas and security schemes
217        let collectors = self.collector_handle.get_collectors().await;
218        let mut components_builder = Components::builder().schemas_from_iter(collectors.schemas());
219
220        // Add security schemes to components
221        for (name, scheme) in &self.security_schemes {
222            components_builder = components_builder.security_scheme(name, scheme.to_utoipa());
223        }
224
225        let components = components_builder.build();
226
227        // Compute tags from all operations
228        let tags = self.compute_tags(&collectors).await;
229        mem::drop(collectors);
230
231        let builder = builder.components(Some(components));
232
233        // Add computed tags if any exist
234        let builder = if tags.is_empty() {
235            builder
236        } else {
237            builder.tags(Some(tags))
238        };
239
240        // Add default security requirements if configured
241        let builder = if self.default_security.is_empty() {
242            builder
243        } else {
244            let security: Vec<_> = self
245                .default_security
246                .iter()
247                .map(SecurityRequirement::to_utoipa)
248                .collect();
249            builder.security(Some(security))
250        };
251
252        builder.build()
253    }
254
255    /// Computes the list of unique tags from all collected operations.
256    async fn compute_tags(&self, collectors: &openapi::Collectors) -> Vec<Tag> {
257        let mut tag_names = BTreeSet::new();
258
259        // Collect all unique tag names from operations
260        for operation in collectors.operations() {
261            if let Some(tags) = operation.tags() {
262                for tag in tags {
263                    tag_names.insert(tag.clone());
264                }
265            }
266        }
267
268        // Convert to Tag objects
269        tag_names.into_iter().map(Tag::new).collect()
270    }
271
272    /// Manually registers a type in the schema collection.
273    ///
274    /// This method allows you to explicitly add types to the OpenAPI schema collection
275    /// that might not be automatically detected. This is useful for types that are
276    /// referenced indirectly, such as nested types.
277    ///
278    /// # Type Parameters
279    ///
280    /// * `T` - The type to register, must implement `ToSchema` and `'static`
281    ///
282    /// # Example
283    ///
284    /// ```rust
285    /// use clawspec_core::ApiClient;
286    /// # use utoipa::ToSchema;
287    /// # use serde::{Deserialize, Serialize};
288    ///
289    /// #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
290    /// struct NestedErrorType {
291    ///     message: String,
292    /// }
293    ///
294    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
295    /// let mut client = ApiClient::builder().build()?;
296    ///
297    /// // Register the nested type that might not be automatically detected
298    /// client.register_schema::<NestedErrorType>().await;
299    ///
300    /// // Now when you generate the OpenAPI spec, NestedErrorType will be included
301    /// let openapi = client.collected_openapi().await;
302    /// # Ok(())
303    /// # }
304    /// ```
305    pub async fn register_schema<T>(&mut self)
306    where
307        T: utoipa::ToSchema + 'static,
308    {
309        let mut schemas = Schemas::default();
310        schemas.add::<T>();
311
312        self.collector_handle
313            .sender()
314            .send(CollectorMessage::AddSchemas(schemas))
315            .await;
316    }
317}
318
319impl ApiClient {
320    pub fn call(&self, method: Method, path: CallPath) -> Result<ApiCall, ApiClientError> {
321        // Convert default_security to Option only if not empty
322        let default_security = if self.default_security.is_empty() {
323            None
324        } else {
325            Some(self.default_security.clone())
326        };
327
328        ApiCall::build(
329            self.client.clone(),
330            self.base_uri.clone(),
331            self.collector_handle.sender(),
332            method,
333            path,
334            self.authentication.clone(),
335            default_security,
336        )
337    }
338
339    pub fn get(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError> {
340        self.call(Method::GET, path.into())
341    }
342
343    pub fn post(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError> {
344        self.call(Method::POST, path.into())
345    }
346
347    pub fn put(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError> {
348        self.call(Method::PUT, path.into())
349    }
350
351    pub fn delete(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError> {
352        self.call(Method::DELETE, path.into())
353    }
354
355    pub fn patch(&self, path: impl Into<CallPath>) -> Result<ApiCall, ApiClientError> {
356        self.call(Method::PATCH, path.into())
357    }
358}