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}