clawspec_core/lib.rs
1//! # Clawspec Core
2//!
3//! Generate OpenAPI specifications from your HTTP client test code.
4//!
5//! This crate provides two main ways to generate OpenAPI documentation:
6//! - **[`ApiClient`]** - Direct HTTP client for fine-grained control
7//! - **[`TestClient`](test_client::TestClient)** - Test server integration with automatic lifecycle management
8//!
9//! ## Quick Start
10//!
11//! ### Using ApiClient directly
12//!
13//! ```rust,no_run
14//! use clawspec_core::ApiClient;
15//! # use serde::Deserialize;
16//! # use utoipa::ToSchema;
17//! # #[derive(Deserialize, ToSchema)]
18//! # struct User { id: u32, name: String }
19//!
20//! # #[tokio::main]
21//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
22//! let mut client = ApiClient::builder()
23//! .with_host("api.example.com")
24//! .build()?;
25//!
26//! // Make requests - schemas are captured automatically
27//! let user: User = client
28//! .get("/users/123")?
29//! .await? // ← Direct await using IntoFuture
30//! .as_json() // ← Important: Must consume result for OpenAPI generation!
31//! .await?;
32//!
33//! // Generate OpenAPI specification
34//! let spec = client.collected_openapi().await;
35//! # Ok(())
36//! # }
37//! ```
38//!
39//! ### Using TestClient with a test server
40//!
41//! For a complete working example, see the [axum example](https://github.com/ilaborie/clawspec/tree/main/examples/axum-example).
42//!
43//! ```rust,no_run
44//! use clawspec_core::test_client::{TestClient, TestServer};
45//! use std::net::TcpListener;
46//!
47//! # #[derive(Debug)]
48//! # struct MyServer;
49//! # impl TestServer for MyServer {
50//! # type Error = std::io::Error;
51//! # async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
52//! # Ok(())
53//! # }
54//! # }
55//! #[tokio::test]
56//! async fn test_api() -> Result<(), Box<dyn std::error::Error>> {
57//! let mut client = TestClient::start(MyServer).await?;
58//!
59//! // Test your API
60//! let response = client.get("/users")?.await?.as_json::<serde_json::Value>().await?;
61//!
62//! // Write OpenAPI spec
63//! client.write_openapi("api.yml").await?;
64//! Ok(())
65//! }
66//! ```
67//!
68//! ## Working with Parameters
69//!
70//! ```rust
71//! use clawspec_core::{ApiClient, CallPath, CallQuery, CallHeaders, ParamValue};
72//!
73//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
74//! // Path parameters
75//! let mut path = CallPath::from("/users/{id}");
76//! path.add_param("id", ParamValue::new(123));
77//!
78//! // Query parameters
79//! let query = CallQuery::new()
80//! .add_param("page", ParamValue::new(1))
81//! .add_param("limit", ParamValue::new(10));
82//!
83//! // Headers
84//! let headers = CallHeaders::new()
85//! .add_header("Authorization", "Bearer token");
86//!
87//! // Direct await with parameters:
88//! let response = client
89//! .get(path)?
90//! .with_query(query)
91//! .with_headers(headers)
92//! .await?; // Direct await using IntoFuture
93//! # Ok(())
94//! # }
95//! ```
96//!
97//! ## Status Code Validation
98//!
99//! By default, requests expect status codes in the range 200-499 (inclusive of 200, exclusive of 500).
100//! You can customize this behavior:
101//!
102//! ```rust
103//! use clawspec_core::{ApiClient, expected_status_codes};
104//!
105//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
106//! // Single codes
107//! client.post("/users")?
108//! .with_expected_status_codes(expected_status_codes!(201, 202))
109//!
110//! .await?;
111//!
112//! // Ranges
113//! client.get("/health")?
114//! .with_expected_status_codes(expected_status_codes!(200-299))
115//!
116//! .await?;
117//! # Ok(())
118//! # }
119//! ```
120//!
121//! ## Schema Registration
122//!
123//! ```rust
124//! use clawspec_core::{ApiClient, register_schemas};
125//! # use serde::Deserialize;
126//! # use utoipa::ToSchema;
127//!
128//! #[derive(Deserialize, ToSchema)]
129//! struct CreateUser { name: String, email: String }
130//!
131//! #[derive(Deserialize, ToSchema)]
132//! struct ErrorResponse { code: String, message: String }
133//!
134//! # fn example(client: &mut ApiClient) {
135//! // Register schemas for complete documentation
136//! register_schemas!(client, CreateUser, ErrorResponse);
137//! # }
138//! ```
139//!
140//! ## Error Handling
141//!
142//! The library provides two main error types:
143//! - [`ApiClientError`] - HTTP client errors (network, parsing, validation)
144//! - [`TestAppError`](test_client::TestAppError) - Test server lifecycle errors
145//!
146//! ## See Also
147//!
148//! - [`ApiClient`] - HTTP client with OpenAPI collection
149//! - [`ApiCall`] - Request builder with parameter support
150//! - [`test_client`] - Test server integration module
151//! - [`ExpectedStatusCodes`] - Status code validation
152//!
153//! ## Re-exports
154//!
155//! All commonly used types are re-exported from the crate root for convenience.
156
157// TODO: Add comprehensive unit tests for all modules - https://github.com/ilaborie/clawspec/issues/30
158
159mod client;
160
161pub mod test_client;
162
163// Public API - only expose user-facing types and functions
164pub use self::client::{
165 ApiCall, ApiClient, ApiClientBuilder, ApiClientError, CallBody, CallHeaders, CallPath,
166 CallQuery, CallResult, ExpectedStatusCodes, ParamStyle, ParamValue, ParameterValue, RawBody,
167 RawResult,
168};
169
170// Convenience macro re-exports are handled by the macro_rules! definitions below
171
172/// Creates an [`ExpectedStatusCodes`] instance with the specified status codes and ranges.
173///
174/// This macro provides a convenient syntax for defining expected HTTP status codes
175/// with support for individual codes, inclusive ranges, and exclusive ranges.
176///
177/// # Syntax
178///
179/// - Single codes: `200`, `201`, `404`
180/// - Inclusive ranges: `200-299` (includes both endpoints)
181/// - Exclusive ranges: `200..300` (excludes 300)
182/// - Mixed: `200, 201-204, 400..500`
183///
184/// # Examples
185///
186/// ```rust
187/// use clawspec_core::expected_status_codes;
188///
189/// // Single status codes
190/// let codes = expected_status_codes!(200, 201, 204);
191///
192/// // Ranges
193/// let success_codes = expected_status_codes!(200-299);
194/// let client_errors = expected_status_codes!(400..500);
195///
196/// // Mixed
197/// let mixed = expected_status_codes!(200-204, 301, 302, 400-404);
198/// ```
199#[macro_export]
200macro_rules! expected_status_codes {
201 // Empty case
202 () => {
203 $crate::ExpectedStatusCodes::default()
204 };
205
206 // Single element
207 ($single:literal) => {
208 $crate::ExpectedStatusCodes::from_single($single)
209 };
210
211 // Single range (inclusive)
212 ($start:literal - $end:literal) => {
213 $crate::ExpectedStatusCodes::from_inclusive_range($start..=$end)
214 };
215
216 // Single range (exclusive)
217 ($start:literal .. $end:literal) => {
218 $crate::ExpectedStatusCodes::from_exclusive_range($start..$end)
219 };
220
221 // Multiple elements - single code followed by more
222 ($first:literal, $($rest:tt)*) => {{
223 #[allow(unused_mut)]
224 let mut codes = $crate::ExpectedStatusCodes::from_single($first);
225 $crate::expected_status_codes!(@accumulate codes, $($rest)*);
226 codes
227 }};
228
229 // Multiple elements - inclusive range followed by more
230 ($start:literal - $end:literal, $($rest:tt)*) => {{
231 #[allow(unused_mut)]
232 let mut codes = $crate::ExpectedStatusCodes::from_inclusive_range($start..=$end);
233 $crate::expected_status_codes!(@accumulate codes, $($rest)*);
234 codes
235 }};
236
237 // Multiple elements - exclusive range followed by more
238 ($start:literal .. $end:literal, $($rest:tt)*) => {{
239 #[allow(unused_mut)]
240 let mut codes = $crate::ExpectedStatusCodes::from_exclusive_range($start..$end);
241 $crate::expected_status_codes!(@accumulate codes, $($rest)*);
242 codes
243 }};
244
245 // Internal accumulator - empty (base case for trailing commas)
246 (@accumulate $codes:ident,) => {
247 // Do nothing for trailing commas
248 };
249
250 // Internal accumulator - single code
251 (@accumulate $codes:ident, $single:literal) => {
252 $codes = $codes.add_single($single);
253 };
254
255 // Internal accumulator - single code followed by more
256 (@accumulate $codes:ident, $single:literal, $($rest:tt)*) => {
257 $codes = $codes.add_single($single);
258 $crate::expected_status_codes!(@accumulate $codes, $($rest)*);
259 };
260
261 // Internal accumulator - inclusive range
262 (@accumulate $codes:ident, $start:literal - $end:literal) => {
263 $codes = $codes.add_inclusive_range($start..=$end);
264 };
265
266 // Internal accumulator - inclusive range followed by more
267 (@accumulate $codes:ident, $start:literal - $end:literal, $($rest:tt)*) => {
268 $codes = $codes.add_inclusive_range($start..=$end);
269 $crate::expected_status_codes!(@accumulate $codes, $($rest)*);
270 };
271
272 // Internal accumulator - exclusive range
273 (@accumulate $codes:ident, $start:literal .. $end:literal) => {
274 $codes = $codes.add_exclusive_range($start..$end);
275 };
276
277 // Internal accumulator - exclusive range followed by more
278 (@accumulate $codes:ident, $start:literal .. $end:literal, $($rest:tt)*) => {
279 $codes = $codes.add_exclusive_range($start..$end);
280 $crate::expected_status_codes!(@accumulate $codes, $($rest)*);
281 };
282
283 // Internal accumulator - empty (catch all for trailing cases)
284 (@accumulate $codes:ident) => {
285 // Base case - do nothing
286 };
287}
288
289/// Registers multiple schema types with the ApiClient for OpenAPI documentation.
290///
291/// This macro simplifies the process of registering multiple types that implement
292/// [`utoipa::ToSchema`] with an [`ApiClient`] instance.
293///
294/// # Examples
295///
296/// ```rust
297/// use clawspec_core::{ApiClient, register_schemas};
298/// use serde::{Serialize, Deserialize};
299/// use utoipa::ToSchema;
300///
301/// #[derive(Serialize, Deserialize, ToSchema)]
302/// struct User {
303/// id: u64,
304/// name: String,
305/// }
306///
307/// #[derive(Serialize, Deserialize, ToSchema)]
308/// struct Post {
309/// id: u64,
310/// title: String,
311/// author_id: u64,
312/// }
313///
314/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
315/// let mut client = ApiClient::builder().build()?;
316///
317/// // Register multiple schemas at once
318/// register_schemas!(client, User, Post).await;
319/// # Ok(())
320/// # }
321/// ```
322#[macro_export]
323macro_rules! register_schemas {
324 ($client:expr, $($schema:ty),+ $(,)?) => {
325 async {
326 $(
327 $client.register_schema::<$schema>().await;
328 )+
329 }
330 };
331}
332
333#[cfg(test)]
334mod macro_tests {
335 use super::*;
336
337 #[test]
338 fn test_expected_status_codes_single() {
339 let codes = expected_status_codes!(200);
340 assert!(codes.contains(200));
341 assert!(!codes.contains(201));
342 }
343
344 #[test]
345 fn test_expected_status_codes_multiple_single() {
346 let codes = expected_status_codes!(200, 201, 204);
347 assert!(codes.contains(200));
348 assert!(codes.contains(201));
349 assert!(codes.contains(204));
350 assert!(!codes.contains(202));
351 }
352
353 #[test]
354 fn test_expected_status_codes_range() {
355 let codes = expected_status_codes!(200 - 204);
356 assert!(codes.contains(200));
357 assert!(codes.contains(202));
358 assert!(codes.contains(204));
359 assert!(!codes.contains(205));
360 }
361
362 #[test]
363 fn test_expected_status_codes_mixed() {
364 let codes = expected_status_codes!(200, 201 - 204, 301, 400 - 404);
365 assert!(codes.contains(200));
366 assert!(codes.contains(202));
367 assert!(codes.contains(301));
368 assert!(codes.contains(402));
369 assert!(!codes.contains(305));
370 }
371
372 #[test]
373 fn test_expected_status_codes_trailing_comma() {
374 let codes = expected_status_codes!(200, 201,);
375 assert!(codes.contains(200));
376 assert!(codes.contains(201));
377 }
378
379 #[test]
380 fn test_expected_status_codes_range_trailing_comma() {
381 let codes = expected_status_codes!(200 - 204,);
382 assert!(codes.contains(202));
383 }
384
385 #[test]
386 fn test_expected_status_codes_five_elements() {
387 let codes = expected_status_codes!(200, 201, 202, 203, 204);
388 assert!(codes.contains(200));
389 assert!(codes.contains(201));
390 assert!(codes.contains(202));
391 assert!(codes.contains(203));
392 assert!(codes.contains(204));
393 }
394
395 #[test]
396 fn test_expected_status_codes_eight_elements() {
397 let codes = expected_status_codes!(200, 201, 202, 203, 204, 205, 206, 207);
398 assert!(codes.contains(200));
399 assert!(codes.contains(204));
400 assert!(codes.contains(207));
401 }
402
403 #[test]
404 fn test_expected_status_codes_multiple_ranges() {
405 let codes = expected_status_codes!(200 - 204, 300 - 304, 400 - 404);
406 assert!(codes.contains(202));
407 assert!(codes.contains(302));
408 assert!(codes.contains(402));
409 assert!(!codes.contains(205));
410 assert!(!codes.contains(305));
411 }
412
413 #[test]
414 fn test_expected_status_codes_edge_cases() {
415 // Empty should work
416 let _codes = expected_status_codes!();
417
418 // Single range should work
419 let codes = expected_status_codes!(200 - 299);
420 assert!(codes.contains(250));
421 }
422
423 #[test]
424 fn test_expected_status_codes_common_patterns() {
425 // Success codes
426 let success = expected_status_codes!(200 - 299);
427 assert!(success.contains(200));
428 assert!(success.contains(201));
429 assert!(success.contains(204));
430
431 // Client errors
432 let client_errors = expected_status_codes!(400 - 499);
433 assert!(client_errors.contains(400));
434 assert!(client_errors.contains(404));
435 assert!(client_errors.contains(422));
436
437 // Specific success codes
438 let specific = expected_status_codes!(200, 201, 204);
439 assert!(specific.contains(200));
440 assert!(!specific.contains(202));
441 }
442
443 #[test]
444 fn test_expected_status_codes_builder_alternative() {
445 // Using macro
446 let macro_codes = expected_status_codes!(200 - 204, 301, 302, 400 - 404);
447
448 // Using builder (should be equivalent)
449 let builder_codes = ExpectedStatusCodes::default()
450 .add_inclusive_range(200..=204)
451 .add_single(301)
452 .add_single(302)
453 .add_inclusive_range(400..=404);
454
455 // Both should have same results
456 for code in [200, 202, 204, 301, 302, 400, 402, 404] {
457 assert_eq!(macro_codes.contains(code), builder_codes.contains(code));
458 }
459 }
460}
461
462#[cfg(test)]
463mod integration_tests {
464 use super::*;
465
466 #[test]
467 fn test_expected_status_codes_real_world_patterns() {
468 // REST API common patterns
469 let rest_success = expected_status_codes!(200, 201, 204);
470 assert!(rest_success.contains(200)); // GET success
471 assert!(rest_success.contains(201)); // POST created
472 assert!(rest_success.contains(204)); // DELETE success
473
474 // GraphQL typically uses 200 for everything
475 let graphql = expected_status_codes!(200);
476 assert!(graphql.contains(200));
477 assert!(!graphql.contains(201));
478
479 // Health check endpoints
480 let health = expected_status_codes!(200, 503);
481 assert!(health.contains(200)); // Healthy
482 assert!(health.contains(503)); // Unhealthy
483
484 // Authentication endpoints
485 let auth = expected_status_codes!(200, 201, 401, 403);
486 assert!(auth.contains(200)); // Login success
487 assert!(auth.contains(401)); // Unauthorized
488 assert!(auth.contains(403)); // Forbidden
489 }
490
491 #[test]
492 fn test_expected_status_codes_with_api_call() {
493 // This tests that the macro works correctly with actual API calls
494 let client = ApiClient::builder().build().unwrap();
495 let codes = expected_status_codes!(200 - 299, 404);
496
497 // Should compile and be usable
498 let _call = client
499 .get("/test")
500 .unwrap()
501 .with_expected_status_codes(codes);
502 }
503
504 #[test]
505 fn test_expected_status_codes_method_chaining() {
506 let codes = expected_status_codes!(200)
507 .add_single(201)
508 .add_inclusive_range(300..=304);
509
510 assert!(codes.contains(200));
511 assert!(codes.contains(201));
512 assert!(codes.contains(302));
513 }
514
515 #[test]
516 fn test_expected_status_codes_vs_manual_creation() {
517 // Macro version
518 let macro_version = expected_status_codes!(200 - 204, 301, 400);
519
520 // Manual version
521 let manual_version = ExpectedStatusCodes::from_inclusive_range(200..=204)
522 .add_single(301)
523 .add_single(400);
524
525 // Should behave identically
526 for code in 100..600 {
527 assert_eq!(
528 macro_version.contains(code),
529 manual_version.contains(code),
530 "Mismatch for status code {code}"
531 );
532 }
533 }
534}