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}