clawspec_core/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3//! # Clawspec Core
4//!
5//! Generate OpenAPI specifications from your HTTP client test code.
6//!
7//! This crate provides two main ways to generate OpenAPI documentation:
8//! - **[`ApiClient`]** - Direct HTTP client for fine-grained control
9//! - **[`TestClient`](test_client::TestClient)** - Test server integration with automatic lifecycle management
10//!
11//! **New to Clawspec?** Start with the **[Tutorial][_tutorial]** for a step-by-step guide.
12//!
13//! ## Quick Start
14//!
15//! ### Using ApiClient directly
16//!
17//! ```rust,no_run
18//! use clawspec_core::ApiClient;
19//! # use serde::Deserialize;
20//! # use utoipa::ToSchema;
21//! # #[derive(Deserialize, ToSchema)]
22//! # struct User { id: u32, name: String }
23//!
24//! # #[tokio::main]
25//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
26//! let mut client = ApiClient::builder()
27//!     .with_host("api.example.com")
28//!     .build()?;
29//!
30//! // Make requests - schemas are captured automatically  
31//! let user: User = client
32//!     .get("/users/123")?
33//!     .await?  // ← Direct await using IntoFuture
34//!     .as_json()  // ← Important: Must consume result for OpenAPI generation!
35//!     .await?;
36//!
37//! // Generate OpenAPI specification
38//! let spec = client.collected_openapi().await;
39//! # Ok(())
40//! # }
41//! ```
42//!
43//! ### Using TestClient with a test server
44//!
45//! For a complete working example, see the [axum example](https://github.com/ilaborie/clawspec/tree/main/examples/axum-example).
46//!
47//! ```rust,no_run
48//! use clawspec_core::test_client::{TestClient, TestServer};
49//! use std::net::TcpListener;
50//!
51//! # #[derive(Debug)]
52//! # struct MyServer;
53//! # impl TestServer for MyServer {
54//! #     type Error = std::io::Error;
55//! #     async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
56//! #         Ok(())
57//! #     }
58//! # }
59//! #[tokio::test]
60//! async fn test_api() -> Result<(), Box<dyn std::error::Error>> {
61//!     let mut client = TestClient::start(MyServer).await?;
62//!     
63//!     // Test your API
64//!     let response = client.get("/users")?.await?.as_json::<serde_json::Value>().await?;
65//!     
66//!     // Write OpenAPI spec
67//!     client.write_openapi("api.yml").await?;
68//!     Ok(())
69//! }
70//! ```
71//!
72//! ## Working with Parameters
73//!
74//! ```rust
75//! use clawspec_core::{ApiClient, CallPath, CallQuery, CallHeaders, CallCookies, ParamValue, ParamStyle};
76//!
77//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
78//! // Path parameters  
79//! let path = CallPath::from("/users/{id}")
80//!     .add_param("id", ParamValue::new(123));
81//!
82//! // Query parameters
83//! let query = CallQuery::new()
84//!     .add_param("page", ParamValue::new(1))
85//!     .add_param("limit", ParamValue::new(10));
86//!
87//! // Headers
88//! let headers = CallHeaders::new()
89//!     .add_header("Authorization", "Bearer token");
90//!
91//! // Cookies
92//! let cookies = CallCookies::new()
93//!     .add_cookie("session_id", "abc123")
94//!     .add_cookie("user_id", 456);
95//!
96//! // Direct await with parameters:
97//! let response = client
98//!     .get(path)?
99//!     .with_query(query)
100//!     .with_headers(headers)
101//!     .with_cookies(cookies)
102//!     .await?;  // Direct await using IntoFuture
103//! # Ok(())
104//! # }
105//! ```
106//!
107//! ## Parameter Styles
108//!
109//! The library supports OpenAPI 3.1.0 parameter styles. Use [`ParamStyle`] for advanced serialization:
110//!
111//! ```rust
112//! use clawspec_core::{CallPath, CallQuery, ParamValue, ParamStyle};
113//!
114//! // Path: simple (default), label, matrix
115//! let path = CallPath::from("/users/{id}").add_param("id", ParamValue::new(123));
116//!
117//! // Query: form (default), spaceDelimited, pipeDelimited, deepObject
118//! let query = CallQuery::new()
119//!     .add_param("tags", ParamValue::with_style(vec!["a", "b"], ParamStyle::PipeDelimited));
120//! ```
121//!
122//! See [Chapter 4: Advanced Parameters](crate::_tutorial::chapter_4) for detailed examples.
123//!
124//! ## Authentication
125//!
126//! Configure authentication at the client or per-request level:
127//!
128//! ```rust
129//! use clawspec_core::{ApiClient, Authentication};
130//!
131//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
132//! let mut client = ApiClient::builder()
133//!     .with_host("api.example.com")
134//!     .with_authentication(Authentication::Bearer("token".into()))
135//!     .build()?;
136//!
137//! // Override per-request
138//! client.get("/admin")?.with_authentication(Authentication::Bearer("admin-token".into())).await?;
139//! # Ok(())
140//! # }
141//! ```
142//!
143//! Supported types: `Bearer`, `Basic`, `ApiKey`. See [Chapter 4](crate::_tutorial::chapter_4) for details.
144//!
145//! ## Status Code Validation
146//!
147//! By default, requests expect status codes in the range 200-499 (inclusive of 200, exclusive of 500).
148//! You can customize this behavior:
149//!
150//! ```rust
151//! use clawspec_core::{ApiClient, expected_status_codes};
152//!
153//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
154//! // Single codes
155//! client.post("/users")?
156//!     .with_expected_status_codes(expected_status_codes!(201, 202))
157//!     .await?;
158//!
159//! // Ranges
160//! client.get("/health")?
161//!     .with_expected_status_codes(expected_status_codes!(200-299))
162//!     .await?;
163//! # Ok(())
164//! # }
165//! ```
166//!
167//! ## Response Descriptions
168//!
169//! Add descriptive text to your OpenAPI responses for better documentation:
170//!
171//! ```rust
172//! use clawspec_core::ApiClient;
173//!
174//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
175//! // Set a description for the actual returned status code
176//! client.get("/users/{id}")?
177//!     .with_response_description("User details if found, or error information")
178//!     .await?;
179//!
180//! // The description applies to whatever status code is actually returned
181//! client.post("/users")?
182//!     .with_response_description("User created successfully or validation error")
183//!     .await?;
184//! # Ok(())
185//! # }
186//! ```
187//!
188//! ## Response Redaction
189//!
190//! *Requires the `redaction` feature.*
191//!
192//! When generating OpenAPI examples from real API responses, dynamic values like UUIDs,
193//! timestamps, and tokens make examples unstable across test runs. The redaction feature
194//! allows you to replace these dynamic values with stable, predictable ones in the generated
195//! OpenAPI specification while preserving the actual values for assertions.
196//!
197//! This is particularly useful for:
198//! - **Snapshot testing**: Generated OpenAPI files remain stable across runs
199//! - **Documentation**: Examples show consistent, readable placeholder values
200//! - **Security**: Sensitive values can be masked in documentation
201//!
202//! ### Basic Usage
203//!
204#![cfg_attr(feature = "redaction", doc = "```rust")]
205#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
206//! use clawspec_core::ApiClient;
207//! # use serde::Deserialize;
208//! # use utoipa::ToSchema;
209//!
210//! #[derive(Deserialize, ToSchema)]
211//! struct User {
212//!     id: String,           // Dynamic UUID
213//!     name: String,
214//!     created_at: String,   // Dynamic timestamp
215//! }
216//!
217//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
218//! let user: User = client
219//!     .post("/users")?
220//!     .json(&serde_json::json!({"name": "Alice"}))?
221//!     .await?
222//!     .as_json_redacted()
223//!     .await?
224//!     // Replace dynamic UUID with stable value
225//!     .redact("/id", "00000000-0000-0000-0000-000000000001")?
226//!     // Replace timestamp with stable value
227//!     .redact("/created_at", "2024-01-01T00:00:00Z")?
228//!     .finish()
229//!     .await
230//!     .value;
231//!
232//! // The actual user has real dynamic values for assertions
233//! assert!(!user.id.is_empty());
234//! // But the OpenAPI example shows the redacted stable values
235//! # Ok(())
236//! # }
237//! ```
238//!
239//! ### Redaction Operations
240//!
241//! The redaction builder supports two operations using [JSON Pointer (RFC 6901)](https://tools.ietf.org/html/rfc6901):
242//!
243//! - **`redact(pointer, redactor)`**: Replace a value at the given path with a stable value or transformation
244//! - **`redact_remove(pointer)`**: Remove a value entirely from the OpenAPI example
245//!
246#![cfg_attr(feature = "redaction", doc = "```rust")]
247#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
248//! # use clawspec_core::ApiClient;
249//! # use serde::Deserialize;
250//! # use utoipa::ToSchema;
251//! # #[derive(Deserialize, ToSchema)]
252//! # struct Response { token: String, session_id: String, internal_ref: String }
253//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
254//! let response: Response = client
255//!     .post("/auth/login")?
256//!     .json(&serde_json::json!({"username": "test", "password": "secret"}))?
257//!     .await?
258//!     .as_json_redacted()
259//!     .await?
260//!     .redact("/token", "[REDACTED_TOKEN]")?
261//!     .redact("/session_id", "session-00000000")?
262//!     .redact_remove("/internal_ref")?  // Remove internal field from docs
263//!     .finish()
264//!     .await
265//!     .value;
266//! # Ok(())
267//! # }
268//! ```
269//!
270//! ### Path Syntax
271//!
272//! Paths are auto-detected based on their prefix:
273//! - `/...` → JSON Pointer (RFC 6901) - exact paths only
274//! - `$...` → JSONPath (RFC 9535) - supports wildcards
275//!
276//! ### JSON Pointer Syntax
277//!
278//! JSON Pointers use `/` as a path separator. Special characters are escaped:
279//! - `~0` represents `~`
280//! - `~1` represents `/`
281//!
282//! Examples:
283//! - `/id` - Top-level field named "id"
284//! - `/user/name` - Nested field "name" inside "user"
285//! - `/items/0/id` - First element's "id" in an array
286//! - `/foo~1bar` - Field named "foo/bar"
287//!
288//! ### JSONPath Wildcards
289//!
290//! For arrays, use JSONPath syntax (starting with `$`) to redact all elements:
291//!
292#![cfg_attr(feature = "redaction", doc = "```rust")]
293#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
294//! # use clawspec_core::ApiClient;
295//! # use serde::Deserialize;
296//! # use utoipa::ToSchema;
297//! # #[derive(Deserialize, ToSchema)]
298//! # struct User { id: String, created_at: String }
299//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
300//! let users: Vec<User> = client
301//!     .get("/users")?
302//!     .await?
303//!     .as_json_redacted()
304//!     .await?
305//!     .redact("$[*].id", "stable-uuid")?        // All IDs in array
306//!     .redact("$[*].created_at", "2024-01-01T00:00:00Z")?  // All timestamps
307//!     .finish()
308//!     .await
309//!     .value;
310//! # Ok(())
311//! # }
312//! ```
313//!
314//! ### Dynamic Transformations
315//!
316//! Pass a closure for dynamic redaction. The closure receives the concrete
317//! JSON Pointer path and current value:
318//!
319#![cfg_attr(feature = "redaction", doc = "```rust")]
320#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
321//! # use clawspec_core::ApiClient;
322//! # use serde::Deserialize;
323//! # use serde_json::Value;
324//! # use utoipa::ToSchema;
325//! # #[derive(Deserialize, ToSchema)]
326//! # struct User { id: String }
327//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
328//! let users: Vec<User> = client
329//!     .get("/users")?
330//!     .await?
331//!     .as_json_redacted()
332//!     .await?
333//!     // Create stable index-based IDs: user-0, user-1, user-2, ...
334//!     .redact("$[*].id", |path: &str, _val: &Value| {
335//!         let idx = path.split('/').nth(1).unwrap_or("0");
336//!         serde_json::json!(format!("user-{idx}"))
337//!     })?
338//!     .finish()
339//!     .await
340//!     .value;
341//! # Ok(())
342//! # }
343//! ```
344//!
345#![cfg_attr(feature = "redaction", doc = "### Getting Both Values")]
346#![cfg_attr(feature = "redaction", doc = "")]
347#![cfg_attr(
348    feature = "redaction",
349    doc = "The [`RedactedResult`] returned by `finish()` contains both:"
350)]
351#![cfg_attr(
352    feature = "redaction",
353    doc = "- `value`: The actual deserialized response (with real dynamic values)"
354)]
355#![cfg_attr(
356    feature = "redaction",
357    doc = "- `redacted`: The JSON with redacted values (as stored in OpenAPI)"
358)]
359//!
360#![cfg_attr(feature = "redaction", doc = "```rust")]
361#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
362//! # use clawspec_core::ApiClient;
363//! # use serde::Deserialize;
364//! # use utoipa::ToSchema;
365//! # #[derive(Deserialize, ToSchema)]
366//! # struct User { id: String }
367//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
368//! let result = client
369//!     .get("/users/123")?
370//!     .await?
371//!     .as_json_redacted::<User>()
372//!     .await?
373//!     .redact("/id", "user-00000000")?
374//!     .finish()
375//!     .await;
376//!
377//! // Use actual value for test assertions
378//! let user = result.value;
379//! assert!(!user.id.is_empty());
380//!
381//! // Access redacted JSON if needed
382//! let redacted_json = result.redacted;
383//! assert_eq!(redacted_json["id"], "user-00000000");
384//! # Ok(())
385//! # }
386//! ```
387//!
388//! ## Schema Registration
389//!
390//! Schemas are **automatically captured** when using `.json()` and `.as_json()` methods.
391//! For nested schemas or error types, use `register_schemas!`:
392//!
393//! ```rust
394//! use clawspec_core::{ApiClient, register_schemas};
395//! # use serde::{Serialize, Deserialize};
396//! # use utoipa::ToSchema;
397//! # #[derive(Serialize, Deserialize, ToSchema)]
398//! # struct Address { street: String }
399//! # #[derive(Deserialize, ToSchema)]
400//! # struct ErrorResponse { code: String }
401//!
402//! # async fn example(client: &mut ApiClient) {
403//! register_schemas!(client, Address, ErrorResponse).await;
404//! # }
405//! ```
406//!
407//! **Note**: Nested schemas may not be fully resolved automatically. Register them explicitly
408//! if they're missing from your OpenAPI output.
409//!
410//! ## Error Handling
411//!
412//! The library provides two main error types:
413//! - [`ApiClientError`] - HTTP client errors (network, parsing, validation)
414//! - [`TestAppError`](test_client::TestAppError) - Test server lifecycle errors
415//!
416//! ## YAML Serialization
417//!
418//! *Requires the `yaml` feature.*
419//!
420//! The library provides YAML serialization support using [serde-saphyr](https://github.com/saphyr-rs/serde_saphyr),
421//! the modern replacement for the deprecated `serde_yaml` crate.
422//!
423#![cfg_attr(feature = "yaml", doc = "```rust")]
424#![cfg_attr(not(feature = "yaml"), doc = "```rust,ignore")]
425//! use clawspec_core::{ApiClient, ToYaml};
426//!
427//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
428//! let mut client = ApiClient::builder()
429//!     .with_host("api.example.com")
430//!     .build()?;
431//!
432//! // ... make API calls ...
433//!
434//! let spec = client.collected_openapi().await;
435//! let yaml = spec.to_yaml()?;
436//!
437//! std::fs::write("openapi.yml", yaml)?;
438//! # Ok(())
439//! # }
440//! ```
441//!
442//! ## See Also
443//!
444//! - [`ApiClient`] - HTTP client with OpenAPI collection
445//! - [`ApiCall`] - Request builder with parameter support
446//! - [`test_client`] - Test server integration module
447//! - [`ExpectedStatusCodes`] - Status code validation
448#![cfg_attr(
449    feature = "redaction",
450    doc = "- [`RedactionBuilder`] - Builder for redacting response values in OpenAPI examples"
451)]
452#![cfg_attr(
453    feature = "redaction",
454    doc = "- [`RedactedResult`] - Result containing both actual and redacted values"
455)]
456#![cfg_attr(
457    feature = "redaction",
458    doc = "- [`RedactOptions`] - Options for configuring redaction behavior"
459)]
460#![cfg_attr(
461    feature = "redaction",
462    doc = "- [`Redactor`] - Trait for types that can be used to redact values"
463)]
464#![cfg_attr(
465    feature = "redaction",
466    doc = "- [`redact_value`] - Entry point for redacting arbitrary JSON values"
467)]
468#![cfg_attr(
469    feature = "redaction",
470    doc = "- [`ValueRedactionBuilder`] - Builder for redacting arbitrary JSON values (e.g., OpenAPI specs)"
471)]
472#![cfg_attr(
473    feature = "yaml",
474    doc = "- [`ToYaml`] - Extension trait for YAML serialization"
475)]
476#![cfg_attr(
477    feature = "yaml",
478    doc = "- [`YamlError`] - Error type for YAML serialization"
479)]
480//!
481//! ## Re-exports
482//!
483//! All commonly used types are re-exported from the crate root for convenience.
484
485// TODO: Add comprehensive unit tests for all modules - https://github.com/ilaborie/clawspec/issues/30
486
487pub mod _tutorial;
488
489mod client;
490
491pub mod split;
492
493#[cfg(feature = "yaml")]
494#[cfg_attr(docsrs, doc(cfg(feature = "yaml")))]
495mod yaml;
496
497pub mod test_client;
498
499// Public API - only expose user-facing types and functions
500pub use self::client::{
501    ApiCall, ApiClient, ApiClientBuilder, ApiClientError, ApiKeyLocation, Authentication,
502    AuthenticationError, CallBody, CallCookies, CallHeaders, CallPath, CallQuery, CallResult,
503    ExpectedStatusCodes, OAuth2Flow, OAuth2Flows, OAuth2ImplicitFlow, ParamStyle, ParamValue,
504    ParameterValue, RawBody, RawResult, SecureString, SecurityRequirement, SecurityScheme,
505};
506
507// Re-export external types so users don't need to add these crates to their Cargo.toml.
508//
509// With these re-exports, users can write:
510//   use clawspec_core::{ApiClient, OpenApi, ToSchema, StatusCode};
511// Instead of:
512//   use clawspec_core::ApiClient;
513//   use utoipa::openapi::OpenApi;
514//   use utoipa::ToSchema;
515//   use http::StatusCode;
516
517/// OpenAPI types re-exported from utoipa for convenience.
518pub use utoipa::openapi::{Info, InfoBuilder, OpenApi, Paths, Server, ServerBuilder};
519
520/// The `ToSchema` derive macro for generating OpenAPI schemas.
521/// Types used in JSON request/response bodies should derive this trait.
522pub use utoipa::ToSchema;
523
524/// HTTP status codes re-exported from the `http` crate.
525pub use http::StatusCode;
526
527#[cfg(feature = "redaction")]
528pub use self::client::{
529    RedactOptions, RedactedResult, RedactionBuilder, Redactor, ValueRedactionBuilder, redact_value,
530};
531
532#[cfg(feature = "oauth2")]
533pub use self::client::{OAuth2Config, OAuth2ConfigBuilder, OAuth2Error, OAuth2Token};
534
535#[cfg(feature = "yaml")]
536#[cfg_attr(docsrs, doc(cfg(feature = "yaml")))]
537pub use self::yaml::{ToYaml, YamlError};
538
539// Convenience macro re-exports are handled by the macro_rules! definitions below
540
541/// Creates an [`ExpectedStatusCodes`] instance with the specified status codes and ranges.
542///
543/// This macro provides a convenient syntax for defining expected HTTP status codes
544/// with support for individual codes, inclusive ranges, and exclusive ranges.
545///
546/// # Syntax
547///
548/// - Single codes: `200`, `201`, `404`
549/// - Inclusive ranges: `200-299` (includes both endpoints)
550/// - Exclusive ranges: `200..300` (excludes 300)
551/// - Mixed: `200, 201-204, 400..500`
552///
553/// # Examples
554///
555/// ```rust
556/// use clawspec_core::expected_status_codes;
557///
558/// // Single status codes
559/// let codes = expected_status_codes!(200, 201, 204);
560///
561/// // Ranges
562/// let success_codes = expected_status_codes!(200-299);
563/// let client_errors = expected_status_codes!(400..500);
564///
565/// // Mixed
566/// let mixed = expected_status_codes!(200-204, 301, 302, 400-404);
567/// ```
568#[macro_export]
569macro_rules! expected_status_codes {
570    // Empty case
571    () => {
572        $crate::ExpectedStatusCodes::default()
573    };
574
575    // Single element
576    ($single:literal) => {
577        $crate::ExpectedStatusCodes::from_single($single)
578    };
579
580    // Single range (inclusive)
581    ($start:literal - $end:literal) => {
582        $crate::ExpectedStatusCodes::from_inclusive_range($start..=$end)
583    };
584
585    // Single range (exclusive)
586    ($start:literal .. $end:literal) => {
587        $crate::ExpectedStatusCodes::from_exclusive_range($start..$end)
588    };
589
590    // Multiple elements - single code followed by more
591    ($first:literal, $($rest:tt)*) => {{
592        #[allow(unused_mut)]
593        let mut codes = $crate::ExpectedStatusCodes::from_single($first);
594        $crate::expected_status_codes!(@accumulate codes, $($rest)*);
595        codes
596    }};
597
598    // Multiple elements - inclusive range followed by more
599    ($start:literal - $end:literal, $($rest:tt)*) => {{
600        #[allow(unused_mut)]
601        let mut codes = $crate::ExpectedStatusCodes::from_inclusive_range($start..=$end);
602        $crate::expected_status_codes!(@accumulate codes, $($rest)*);
603        codes
604    }};
605
606    // Multiple elements - exclusive range followed by more
607    ($start:literal .. $end:literal, $($rest:tt)*) => {{
608        #[allow(unused_mut)]
609        let mut codes = $crate::ExpectedStatusCodes::from_exclusive_range($start..$end);
610        $crate::expected_status_codes!(@accumulate codes, $($rest)*);
611        codes
612    }};
613
614    // Internal accumulator - empty (base case for trailing commas)
615    (@accumulate $codes:ident,) => {
616        // Do nothing for trailing commas
617    };
618
619    // Internal accumulator - single code
620    (@accumulate $codes:ident, $single:literal) => {
621        $codes = $codes.add_single($single);
622    };
623
624    // Internal accumulator - single code followed by more
625    (@accumulate $codes:ident, $single:literal, $($rest:tt)*) => {
626        $codes = $codes.add_single($single);
627        $crate::expected_status_codes!(@accumulate $codes, $($rest)*);
628    };
629
630    // Internal accumulator - inclusive range
631    (@accumulate $codes:ident, $start:literal - $end:literal) => {
632        $codes = $codes.add_inclusive_range($start..=$end);
633    };
634
635    // Internal accumulator - inclusive range followed by more
636    (@accumulate $codes:ident, $start:literal - $end:literal, $($rest:tt)*) => {
637        $codes = $codes.add_inclusive_range($start..=$end);
638        $crate::expected_status_codes!(@accumulate $codes, $($rest)*);
639    };
640
641    // Internal accumulator - exclusive range
642    (@accumulate $codes:ident, $start:literal .. $end:literal) => {
643        $codes = $codes.add_exclusive_range($start..$end);
644    };
645
646    // Internal accumulator - exclusive range followed by more
647    (@accumulate $codes:ident, $start:literal .. $end:literal, $($rest:tt)*) => {
648        $codes = $codes.add_exclusive_range($start..$end);
649        $crate::expected_status_codes!(@accumulate $codes, $($rest)*);
650    };
651
652    // Internal accumulator - empty (catch all for trailing cases)
653    (@accumulate $codes:ident) => {
654        // Base case - do nothing
655    };
656}
657
658/// Registers multiple schema types with the ApiClient for OpenAPI documentation.
659///
660/// This macro simplifies the process of registering multiple types that implement
661/// [`utoipa::ToSchema`] with an [`ApiClient`] instance.
662///
663/// # When to Use
664///
665/// - **Nested Schemas**: When your JSON types contain nested structures that need to be fully resolved
666/// - **Error Types**: To ensure error response schemas are included in the OpenAPI specification
667/// - **Complex Dependencies**: When automatic schema capture doesn't include all referenced types
668///
669/// # Automatic vs Manual Registration
670///
671/// Most JSON request/response schemas are captured automatically when using `.json()` and `.as_json()` methods.
672/// Use this macro when you need to ensure complete schema coverage, especially for nested types.
673///
674/// # Examples
675///
676/// ## Basic Usage
677///
678/// ```rust
679/// use clawspec_core::{ApiClient, register_schemas};
680/// use serde::{Serialize, Deserialize};
681/// use utoipa::ToSchema;
682///
683/// #[derive(Serialize, Deserialize, ToSchema)]
684/// struct User {
685///     id: u64,
686///     name: String,
687/// }
688///
689/// #[derive(Serialize, Deserialize, ToSchema)]
690/// struct Post {
691///     id: u64,
692///     title: String,
693///     author_id: u64,
694/// }
695///
696/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
697/// let mut client = ApiClient::builder().build()?;
698///
699/// // Register multiple schemas at once
700/// register_schemas!(client, User, Post).await;
701/// # Ok(())
702/// # }
703/// ```
704///
705/// ## Nested Schemas
706///
707/// ```rust
708/// use clawspec_core::{ApiClient, register_schemas};
709/// use serde::{Serialize, Deserialize};
710/// use utoipa::ToSchema;
711///
712/// #[derive(Serialize, Deserialize, ToSchema)]
713/// struct Address {
714///     street: String,
715///     city: String,
716/// }
717///
718/// #[derive(Serialize, Deserialize, ToSchema)]
719/// struct User {
720///     id: u64,
721///     name: String,
722///     address: Address,  // Nested schema
723/// }
724///
725/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
726/// let mut client = ApiClient::builder().build()?;
727///
728/// // Register both main and nested schemas for complete OpenAPI generation
729/// register_schemas!(client, User, Address).await;
730/// # Ok(())
731/// # }
732/// ```
733#[macro_export]
734macro_rules! register_schemas {
735    ($client:expr, $($schema:ty),+ $(,)?) => {
736        async {
737            $(
738                $client.register_schema::<$schema>().await;
739            )+
740        }
741    };
742}
743
744#[cfg(test)]
745mod macro_tests {
746    use super::*;
747
748    #[test]
749    fn test_expected_status_codes_single() {
750        let codes = expected_status_codes!(200);
751        assert!(codes.contains(200));
752        assert!(!codes.contains(201));
753    }
754
755    #[test]
756    fn test_expected_status_codes_multiple_single() {
757        let codes = expected_status_codes!(200, 201, 204);
758        assert!(codes.contains(200));
759        assert!(codes.contains(201));
760        assert!(codes.contains(204));
761        assert!(!codes.contains(202));
762    }
763
764    #[test]
765    fn test_expected_status_codes_range() {
766        let codes = expected_status_codes!(200 - 204);
767        assert!(codes.contains(200));
768        assert!(codes.contains(202));
769        assert!(codes.contains(204));
770        assert!(!codes.contains(205));
771    }
772
773    #[test]
774    fn test_expected_status_codes_mixed() {
775        let codes = expected_status_codes!(200, 201 - 204, 301, 400 - 404);
776        assert!(codes.contains(200));
777        assert!(codes.contains(202));
778        assert!(codes.contains(301));
779        assert!(codes.contains(402));
780        assert!(!codes.contains(305));
781    }
782
783    #[test]
784    fn test_expected_status_codes_trailing_comma() {
785        let codes = expected_status_codes!(200, 201,);
786        assert!(codes.contains(200));
787        assert!(codes.contains(201));
788    }
789
790    #[test]
791    fn test_expected_status_codes_range_trailing_comma() {
792        let codes = expected_status_codes!(200 - 204,);
793        assert!(codes.contains(202));
794    }
795
796    #[test]
797    fn test_expected_status_codes_five_elements() {
798        let codes = expected_status_codes!(200, 201, 202, 203, 204);
799        assert!(codes.contains(200));
800        assert!(codes.contains(201));
801        assert!(codes.contains(202));
802        assert!(codes.contains(203));
803        assert!(codes.contains(204));
804    }
805
806    #[test]
807    fn test_expected_status_codes_eight_elements() {
808        let codes = expected_status_codes!(200, 201, 202, 203, 204, 205, 206, 207);
809        assert!(codes.contains(200));
810        assert!(codes.contains(204));
811        assert!(codes.contains(207));
812    }
813
814    #[test]
815    fn test_expected_status_codes_multiple_ranges() {
816        let codes = expected_status_codes!(200 - 204, 300 - 304, 400 - 404);
817        assert!(codes.contains(202));
818        assert!(codes.contains(302));
819        assert!(codes.contains(402));
820        assert!(!codes.contains(205));
821        assert!(!codes.contains(305));
822    }
823
824    #[test]
825    fn test_expected_status_codes_edge_cases() {
826        // Empty should work
827        let _codes = expected_status_codes!();
828
829        // Single range should work
830        let codes = expected_status_codes!(200 - 299);
831        assert!(codes.contains(250));
832    }
833
834    #[test]
835    fn test_expected_status_codes_common_patterns() {
836        // Success codes
837        let success = expected_status_codes!(200 - 299);
838        assert!(success.contains(200));
839        assert!(success.contains(201));
840        assert!(success.contains(204));
841
842        // Client errors
843        let client_errors = expected_status_codes!(400 - 499);
844        assert!(client_errors.contains(400));
845        assert!(client_errors.contains(404));
846        assert!(client_errors.contains(422));
847
848        // Specific success codes
849        let specific = expected_status_codes!(200, 201, 204);
850        assert!(specific.contains(200));
851        assert!(!specific.contains(202));
852    }
853
854    #[test]
855    fn test_expected_status_codes_builder_alternative() {
856        // Using macro
857        let macro_codes = expected_status_codes!(200 - 204, 301, 302, 400 - 404);
858
859        // Using builder (should be equivalent)
860        let builder_codes = ExpectedStatusCodes::default()
861            .add_inclusive_range(200..=204)
862            .add_single(301)
863            .add_single(302)
864            .add_inclusive_range(400..=404);
865
866        // Both should have same results
867        for code in [200, 202, 204, 301, 302, 400, 402, 404] {
868            assert_eq!(macro_codes.contains(code), builder_codes.contains(code));
869        }
870    }
871}
872
873#[cfg(test)]
874mod integration_tests {
875    use super::*;
876
877    #[test]
878    fn test_expected_status_codes_real_world_patterns() {
879        // REST API common patterns
880        let rest_success = expected_status_codes!(200, 201, 204);
881        assert!(rest_success.contains(200)); // GET success
882        assert!(rest_success.contains(201)); // POST created
883        assert!(rest_success.contains(204)); // DELETE success
884
885        // GraphQL typically uses 200 for everything
886        let graphql = expected_status_codes!(200);
887        assert!(graphql.contains(200));
888        assert!(!graphql.contains(201));
889
890        // Health check endpoints
891        let health = expected_status_codes!(200, 503);
892        assert!(health.contains(200)); // Healthy
893        assert!(health.contains(503)); // Unhealthy
894
895        // Authentication endpoints
896        let auth = expected_status_codes!(200, 201, 401, 403);
897        assert!(auth.contains(200)); // Login success
898        assert!(auth.contains(401)); // Unauthorized
899        assert!(auth.contains(403)); // Forbidden
900    }
901
902    #[tokio::test]
903    async fn test_expected_status_codes_with_api_call() {
904        // This tests that the macro works correctly with actual API calls
905        let client = ApiClient::builder().build().expect("should build client");
906        let codes = expected_status_codes!(200 - 299, 404);
907
908        // Should compile and be usable
909        let _call = client
910            .get("/test")
911            .expect("should create call")
912            .with_expected_status_codes(codes);
913    }
914
915    #[test]
916    fn test_expected_status_codes_method_chaining() {
917        let codes = expected_status_codes!(200)
918            .add_single(201)
919            .add_inclusive_range(300..=304);
920
921        assert!(codes.contains(200));
922        assert!(codes.contains(201));
923        assert!(codes.contains(302));
924    }
925
926    #[test]
927    fn test_expected_status_codes_vs_manual_creation() {
928        // Macro version
929        let macro_version = expected_status_codes!(200 - 204, 301, 400);
930
931        // Manual version
932        let manual_version = ExpectedStatusCodes::from_inclusive_range(200..=204)
933            .add_single(301)
934            .add_single(400);
935
936        // Should behave identically
937        for code in 100..600 {
938            assert_eq!(
939                macro_version.contains(code),
940                manual_version.contains(code),
941                "Mismatch for status code {code}"
942            );
943        }
944    }
945}