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