Skip to main content

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//! ### Request Body Redaction
240//!
241//! The same pattern works for request bodies using `json_redacted()`:
242//!
243#![cfg_attr(feature = "redaction", doc = "```rust")]
244#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
245//! use clawspec_core::ApiClient;
246//! # use serde::Serialize;
247//! # use utoipa::ToSchema;
248//!
249//! #[derive(Clone, Serialize, ToSchema)]
250//! struct LoginRequest {
251//!     username: String,
252//!     password: String,
253//! }
254//!
255//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
256//! let request = LoginRequest {
257//!     username: "alice".to_string(),
258//!     password: "my-secret-password".to_string(),
259//! };
260//!
261//! // The HTTP request contains the real password,
262//! // but the OpenAPI example shows "[REDACTED]"
263//! client
264//!     .post("/auth/login")?
265//!     .json_redacted(&request)?
266//!     .redact("/password", "[REDACTED]")?
267//!     .finish()?
268//!     .await?;
269//! # Ok(())
270//! # }
271//! ```
272//!
273//! ### Redaction Operations
274//!
275//! The redaction builder supports two operations using [JSON Pointer (RFC 6901)](https://tools.ietf.org/html/rfc6901)
276//! or [JSONPath (RFC 9535)](https://www.rfc-editor.org/rfc/rfc9535):
277//!
278//! - **`redact(path, redactor)`**: Replace a value at the given path with a stable value or transformation
279//! - **`redact_remove(path)`**: Remove a value entirely from the OpenAPI example
280//!
281#![cfg_attr(feature = "redaction", doc = "```rust")]
282#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
283//! # use clawspec_core::ApiClient;
284//! # use serde::Deserialize;
285//! # use utoipa::ToSchema;
286//! # #[derive(Deserialize, ToSchema)]
287//! # struct Response { token: String, session_id: String, internal_ref: String }
288//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
289//! let response: Response = client
290//!     .post("/auth/login")?
291//!     .json(&serde_json::json!({"username": "test", "password": "secret"}))?
292//!     .await?
293//!     .as_json_redacted()
294//!     .await?
295//!     .redact("/token", "[REDACTED_TOKEN]")?
296//!     .redact("/session_id", "session-00000000")?
297//!     .redact_remove("/internal_ref")?  // Remove internal field from docs
298//!     .finish()
299//!     .await
300//!     .value;
301//! # Ok(())
302//! # }
303//! ```
304//!
305//! ### Path Syntax
306//!
307//! Paths are auto-detected based on their prefix:
308//! - `/...` → JSON Pointer (RFC 6901) - exact paths only
309//! - `$...` → JSONPath (RFC 9535) - supports wildcards
310//!
311//! ### JSON Pointer Syntax
312//!
313//! JSON Pointers use `/` as a path separator. Special characters are escaped:
314//! - `~0` represents `~`
315//! - `~1` represents `/`
316//!
317//! Examples:
318//! - `/id` - Top-level field named "id"
319//! - `/user/name` - Nested field "name" inside "user"
320//! - `/items/0/id` - First element's "id" in an array
321//! - `/foo~1bar` - Field named "foo/bar"
322//!
323//! ### JSONPath Wildcards
324//!
325//! For arrays, use JSONPath syntax (starting with `$`) to redact all elements:
326//!
327#![cfg_attr(feature = "redaction", doc = "```rust")]
328#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
329//! # use clawspec_core::ApiClient;
330//! # use serde::Deserialize;
331//! # use utoipa::ToSchema;
332//! # #[derive(Deserialize, ToSchema)]
333//! # struct User { id: String, created_at: String }
334//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
335//! let users: Vec<User> = client
336//!     .get("/users")?
337//!     .await?
338//!     .as_json_redacted()
339//!     .await?
340//!     .redact("$[*].id", "stable-uuid")?        // All IDs in array
341//!     .redact("$[*].created_at", "2024-01-01T00:00:00Z")?  // All timestamps
342//!     .finish()
343//!     .await
344//!     .value;
345//! # Ok(())
346//! # }
347//! ```
348//!
349//! ### Dynamic Transformations
350//!
351//! Pass a closure for dynamic redaction. The closure receives the concrete
352//! JSON Pointer path and current value:
353//!
354#![cfg_attr(feature = "redaction", doc = "```rust")]
355#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
356//! # use clawspec_core::ApiClient;
357//! # use serde::Deserialize;
358//! # use serde_json::Value;
359//! # use utoipa::ToSchema;
360//! # #[derive(Deserialize, ToSchema)]
361//! # struct User { id: String }
362//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
363//! let users: Vec<User> = client
364//!     .get("/users")?
365//!     .await?
366//!     .as_json_redacted()
367//!     .await?
368//!     // Create stable index-based IDs: user-0, user-1, user-2, ...
369//!     .redact("$[*].id", |path: &str, _val: &Value| {
370//!         let idx = path.split('/').nth(1).unwrap_or("0");
371//!         serde_json::json!(format!("user-{idx}"))
372//!     })?
373//!     .finish()
374//!     .await
375//!     .value;
376//! # Ok(())
377//! # }
378//! ```
379//!
380#![cfg_attr(feature = "redaction", doc = "### Getting Both Values")]
381#![cfg_attr(feature = "redaction", doc = "")]
382#![cfg_attr(
383    feature = "redaction",
384    doc = "The [`RedactedResult`] returned by `finish()` contains both:"
385)]
386#![cfg_attr(
387    feature = "redaction",
388    doc = "- `value`: The actual deserialized response (with real dynamic values)"
389)]
390#![cfg_attr(
391    feature = "redaction",
392    doc = "- `redacted`: The JSON with redacted values (as stored in OpenAPI)"
393)]
394//!
395#![cfg_attr(feature = "redaction", doc = "```rust")]
396#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
397//! # use clawspec_core::ApiClient;
398//! # use serde::Deserialize;
399//! # use utoipa::ToSchema;
400//! # #[derive(Deserialize, ToSchema)]
401//! # struct User { id: String }
402//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
403//! let result = client
404//!     .get("/users/123")?
405//!     .await?
406//!     .as_json_redacted::<User>()
407//!     .await?
408//!     .redact("/id", "user-00000000")?
409//!     .finish()
410//!     .await;
411//!
412//! // Use actual value for test assertions
413//! let user = result.value;
414//! assert!(!user.id.is_empty());
415//!
416//! // Access redacted JSON if needed
417//! let redacted_json = result.redacted;
418//! assert_eq!(redacted_json["id"], "user-00000000");
419//! # Ok(())
420//! # }
421//! ```
422//!
423//! ## Schema Registration
424//!
425//! Schemas are **automatically captured** when using `.json()` and `.as_json()` methods.
426//! For nested schemas or error types, use `register_schemas!`:
427//!
428//! ```rust
429//! use clawspec_core::{ApiClient, register_schemas};
430//! # use serde::{Serialize, Deserialize};
431//! # use utoipa::ToSchema;
432//! # #[derive(Serialize, Deserialize, ToSchema)]
433//! # struct Address { street: String }
434//! # #[derive(Deserialize, ToSchema)]
435//! # struct ErrorResponse { code: String }
436//!
437//! # async fn example(client: &mut ApiClient) {
438//! register_schemas!(client, Address, ErrorResponse).await;
439//! # }
440//! ```
441//!
442//! **Note**: Nested schemas may not be fully resolved automatically. Register them explicitly
443//! if they're missing from your OpenAPI output.
444//!
445//! ## Error Handling
446//!
447//! The library provides two main error types:
448//! - [`ApiClientError`] - HTTP client errors (network, parsing, validation)
449//! - [`TestAppError`](test_client::TestAppError) - Test server lifecycle errors
450//!
451//! ## YAML Serialization
452//!
453//! *Requires the `yaml` feature.*
454//!
455//! The library provides YAML serialization support using [serde-saphyr](https://github.com/saphyr-rs/serde_saphyr),
456//! the modern replacement for the deprecated `serde_yaml` crate.
457//!
458#![cfg_attr(feature = "yaml", doc = "```rust")]
459#![cfg_attr(not(feature = "yaml"), doc = "```rust,ignore")]
460//! use clawspec_core::{ApiClient, ToYaml};
461//!
462//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
463//! let mut client = ApiClient::builder()
464//!     .with_host("api.example.com")
465//!     .build()?;
466//!
467//! // ... make API calls ...
468//!
469//! let spec = client.collected_openapi().await;
470//! let yaml = spec.to_yaml()?;
471//!
472//! std::fs::write("openapi.yml", yaml)?;
473//! # Ok(())
474//! # }
475//! ```
476//!
477//! ## See Also
478//!
479//! - [`ApiClient`] - HTTP client with OpenAPI collection
480//! - [`ApiCall`] - Request builder with parameter support
481//! - [`test_client`] - Test server integration module
482//! - [`ExpectedStatusCodes`] - Status code validation
483#![cfg_attr(
484    feature = "redaction",
485    doc = "- [`RedactionBuilder`] - Builder for redacting response values in OpenAPI examples"
486)]
487#![cfg_attr(
488    feature = "redaction",
489    doc = "- [`RedactedResult`] - Result containing both actual and redacted values"
490)]
491#![cfg_attr(
492    feature = "redaction",
493    doc = "- [`RedactOptions`] - Options for configuring redaction behavior"
494)]
495#![cfg_attr(
496    feature = "redaction",
497    doc = "- [`Redactor`] - Trait for types that can be used to redact values"
498)]
499#![cfg_attr(
500    feature = "redaction",
501    doc = "- [`redact_value`] - Entry point for redacting arbitrary JSON values"
502)]
503#![cfg_attr(
504    feature = "redaction",
505    doc = "- [`ValueRedactionBuilder`] - Builder for redacting arbitrary JSON values (e.g., OpenAPI specs)"
506)]
507#![cfg_attr(
508    feature = "yaml",
509    doc = "- [`ToYaml`] - Extension trait for YAML serialization"
510)]
511#![cfg_attr(
512    feature = "yaml",
513    doc = "- [`YamlError`] - Error type for YAML serialization"
514)]
515//!
516//! ## Re-exports
517//!
518//! All commonly used types are re-exported from the crate root for convenience.
519
520// TODO: Add comprehensive unit tests for all modules - https://github.com/ilaborie/clawspec/issues/30
521
522pub mod _tutorial;
523
524mod client;
525
526pub mod split;
527
528#[cfg(feature = "yaml")]
529#[cfg_attr(docsrs, doc(cfg(feature = "yaml")))]
530mod yaml;
531
532pub mod test_client;
533
534// Public API - only expose user-facing types and functions
535pub use self::client::{
536    ApiCall, ApiClient, ApiClientBuilder, ApiClientError, ApiKeyLocation, Authentication,
537    AuthenticationError, CallBody, CallCookies, CallHeaders, CallPath, CallQuery, CallResult,
538    ExpectedStatusCodes, OAuth2Flow, OAuth2Flows, OAuth2ImplicitFlow, ParamStyle, ParamValue,
539    ParameterValue, RawBody, RawResult, SecureString, SecurityRequirement, SecurityScheme,
540};
541
542// Re-export external types so users don't need to add these crates to their Cargo.toml.
543//
544// With these re-exports, users can write:
545//   use clawspec_core::{ApiClient, OpenApi, ToSchema, StatusCode};
546// Instead of:
547//   use clawspec_core::ApiClient;
548//   use utoipa::openapi::OpenApi;
549//   use utoipa::ToSchema;
550//   use http::StatusCode;
551
552/// OpenAPI types re-exported from utoipa for convenience.
553pub use utoipa::openapi::{Info, InfoBuilder, OpenApi, Paths, Server, ServerBuilder};
554
555/// The `ToSchema` derive macro for generating OpenAPI schemas.
556/// Types used in JSON request/response bodies should derive this trait.
557pub use utoipa::ToSchema;
558
559/// HTTP status codes re-exported from the `http` crate.
560pub use http::StatusCode;
561
562#[cfg(feature = "redaction")]
563pub use self::client::{
564    RedactOptions, RedactedResult, RedactionBuilder, Redactor, RequestBodyRedactionBuilder,
565    ValueRedactionBuilder, redact_value,
566};
567
568#[cfg(feature = "oauth2")]
569pub use self::client::{OAuth2Config, OAuth2ConfigBuilder, OAuth2Error, OAuth2Token};
570
571#[cfg(feature = "yaml")]
572#[cfg_attr(docsrs, doc(cfg(feature = "yaml")))]
573pub use self::yaml::{ToYaml, YamlError};
574
575// Convenience macro re-exports are handled by the macro_rules! definitions below
576
577/// Creates an [`ExpectedStatusCodes`] instance with the specified status codes and ranges.
578///
579/// This macro provides a convenient syntax for defining expected HTTP status codes
580/// with support for individual codes, inclusive ranges, and exclusive ranges.
581///
582/// # Syntax
583///
584/// - Single codes: `200`, `201`, `404`
585/// - Inclusive ranges: `200-299` (includes both endpoints)
586/// - Exclusive ranges: `200..300` (excludes 300)
587/// - Mixed: `200, 201-204, 400..500`
588///
589/// # Examples
590///
591/// ```rust
592/// use clawspec_core::expected_status_codes;
593///
594/// // Single status codes
595/// let codes = expected_status_codes!(200, 201, 204);
596///
597/// // Ranges
598/// let success_codes = expected_status_codes!(200-299);
599/// let client_errors = expected_status_codes!(400..500);
600///
601/// // Mixed
602/// let mixed = expected_status_codes!(200-204, 301, 302, 400-404);
603/// ```
604#[macro_export]
605macro_rules! expected_status_codes {
606    // Empty case
607    () => {
608        $crate::ExpectedStatusCodes::default()
609    };
610
611    // Single element
612    ($single:literal) => {
613        $crate::ExpectedStatusCodes::from_single($single)
614    };
615
616    // Single range (inclusive)
617    ($start:literal - $end:literal) => {
618        $crate::ExpectedStatusCodes::from_inclusive_range($start..=$end)
619    };
620
621    // Single range (exclusive)
622    ($start:literal .. $end:literal) => {
623        $crate::ExpectedStatusCodes::from_exclusive_range($start..$end)
624    };
625
626    // Multiple elements - single code followed by more
627    ($first:literal, $($rest:tt)*) => {{
628        #[allow(unused_mut)]
629        let mut codes = $crate::ExpectedStatusCodes::from_single($first);
630        $crate::expected_status_codes!(@accumulate codes, $($rest)*);
631        codes
632    }};
633
634    // Multiple elements - inclusive range followed by more
635    ($start:literal - $end:literal, $($rest:tt)*) => {{
636        #[allow(unused_mut)]
637        let mut codes = $crate::ExpectedStatusCodes::from_inclusive_range($start..=$end);
638        $crate::expected_status_codes!(@accumulate codes, $($rest)*);
639        codes
640    }};
641
642    // Multiple elements - exclusive range followed by more
643    ($start:literal .. $end:literal, $($rest:tt)*) => {{
644        #[allow(unused_mut)]
645        let mut codes = $crate::ExpectedStatusCodes::from_exclusive_range($start..$end);
646        $crate::expected_status_codes!(@accumulate codes, $($rest)*);
647        codes
648    }};
649
650    // Internal accumulator - empty (base case for trailing commas)
651    (@accumulate $codes:ident,) => {
652        // Do nothing for trailing commas
653    };
654
655    // Internal accumulator - single code
656    (@accumulate $codes:ident, $single:literal) => {
657        $codes = $codes.add_single($single);
658    };
659
660    // Internal accumulator - single code followed by more
661    (@accumulate $codes:ident, $single:literal, $($rest:tt)*) => {
662        $codes = $codes.add_single($single);
663        $crate::expected_status_codes!(@accumulate $codes, $($rest)*);
664    };
665
666    // Internal accumulator - inclusive range
667    (@accumulate $codes:ident, $start:literal - $end:literal) => {
668        $codes = $codes.add_inclusive_range($start..=$end);
669    };
670
671    // Internal accumulator - inclusive range followed by more
672    (@accumulate $codes:ident, $start:literal - $end:literal, $($rest:tt)*) => {
673        $codes = $codes.add_inclusive_range($start..=$end);
674        $crate::expected_status_codes!(@accumulate $codes, $($rest)*);
675    };
676
677    // Internal accumulator - exclusive range
678    (@accumulate $codes:ident, $start:literal .. $end:literal) => {
679        $codes = $codes.add_exclusive_range($start..$end);
680    };
681
682    // Internal accumulator - exclusive range followed by more
683    (@accumulate $codes:ident, $start:literal .. $end:literal, $($rest:tt)*) => {
684        $codes = $codes.add_exclusive_range($start..$end);
685        $crate::expected_status_codes!(@accumulate $codes, $($rest)*);
686    };
687
688    // Internal accumulator - empty (catch all for trailing cases)
689    (@accumulate $codes:ident) => {
690        // Base case - do nothing
691    };
692}
693
694/// Registers multiple schema types with the ApiClient for OpenAPI documentation.
695///
696/// This macro simplifies the process of registering multiple types that implement
697/// [`utoipa::ToSchema`] with an [`ApiClient`] instance.
698///
699/// # When to Use
700///
701/// - **Nested Schemas**: When your JSON types contain nested structures that need to be fully resolved
702/// - **Error Types**: To ensure error response schemas are included in the OpenAPI specification
703/// - **Complex Dependencies**: When automatic schema capture doesn't include all referenced types
704///
705/// # Automatic vs Manual Registration
706///
707/// Most JSON request/response schemas are captured automatically when using `.json()` and `.as_json()` methods.
708/// Use this macro when you need to ensure complete schema coverage, especially for nested types.
709///
710/// # Examples
711///
712/// ## Basic Usage
713///
714/// ```rust
715/// use clawspec_core::{ApiClient, register_schemas};
716/// use serde::{Serialize, Deserialize};
717/// use utoipa::ToSchema;
718///
719/// #[derive(Serialize, Deserialize, ToSchema)]
720/// struct User {
721///     id: u64,
722///     name: String,
723/// }
724///
725/// #[derive(Serialize, Deserialize, ToSchema)]
726/// struct Post {
727///     id: u64,
728///     title: String,
729///     author_id: u64,
730/// }
731///
732/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
733/// let mut client = ApiClient::builder().build()?;
734///
735/// // Register multiple schemas at once
736/// register_schemas!(client, User, Post).await;
737/// # Ok(())
738/// # }
739/// ```
740///
741/// ## Nested Schemas
742///
743/// ```rust
744/// use clawspec_core::{ApiClient, register_schemas};
745/// use serde::{Serialize, Deserialize};
746/// use utoipa::ToSchema;
747///
748/// #[derive(Serialize, Deserialize, ToSchema)]
749/// struct Address {
750///     street: String,
751///     city: String,
752/// }
753///
754/// #[derive(Serialize, Deserialize, ToSchema)]
755/// struct User {
756///     id: u64,
757///     name: String,
758///     address: Address,  // Nested schema
759/// }
760///
761/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
762/// let mut client = ApiClient::builder().build()?;
763///
764/// // Register both main and nested schemas for complete OpenAPI generation
765/// register_schemas!(client, User, Address).await;
766/// # Ok(())
767/// # }
768/// ```
769#[macro_export]
770macro_rules! register_schemas {
771    ($client:expr, $($schema:ty),+ $(,)?) => {
772        async {
773            $(
774                $client.register_schema::<$schema>().await;
775            )+
776        }
777    };
778}
779
780#[cfg(test)]
781mod macro_tests {
782    use super::*;
783
784    #[test]
785    fn test_expected_status_codes_single() {
786        let codes = expected_status_codes!(200);
787        assert!(codes.contains(200));
788        assert!(!codes.contains(201));
789    }
790
791    #[test]
792    fn test_expected_status_codes_multiple_single() {
793        let codes = expected_status_codes!(200, 201, 204);
794        assert!(codes.contains(200));
795        assert!(codes.contains(201));
796        assert!(codes.contains(204));
797        assert!(!codes.contains(202));
798    }
799
800    #[test]
801    fn test_expected_status_codes_range() {
802        let codes = expected_status_codes!(200 - 204);
803        assert!(codes.contains(200));
804        assert!(codes.contains(202));
805        assert!(codes.contains(204));
806        assert!(!codes.contains(205));
807    }
808
809    #[test]
810    fn test_expected_status_codes_mixed() {
811        let codes = expected_status_codes!(200, 201 - 204, 301, 400 - 404);
812        assert!(codes.contains(200));
813        assert!(codes.contains(202));
814        assert!(codes.contains(301));
815        assert!(codes.contains(402));
816        assert!(!codes.contains(305));
817    }
818
819    #[test]
820    fn test_expected_status_codes_trailing_comma() {
821        let codes = expected_status_codes!(200, 201,);
822        assert!(codes.contains(200));
823        assert!(codes.contains(201));
824    }
825
826    #[test]
827    fn test_expected_status_codes_range_trailing_comma() {
828        let codes = expected_status_codes!(200 - 204,);
829        assert!(codes.contains(202));
830    }
831
832    #[test]
833    fn test_expected_status_codes_five_elements() {
834        let codes = expected_status_codes!(200, 201, 202, 203, 204);
835        assert!(codes.contains(200));
836        assert!(codes.contains(201));
837        assert!(codes.contains(202));
838        assert!(codes.contains(203));
839        assert!(codes.contains(204));
840    }
841
842    #[test]
843    fn test_expected_status_codes_eight_elements() {
844        let codes = expected_status_codes!(200, 201, 202, 203, 204, 205, 206, 207);
845        assert!(codes.contains(200));
846        assert!(codes.contains(204));
847        assert!(codes.contains(207));
848    }
849
850    #[test]
851    fn test_expected_status_codes_multiple_ranges() {
852        let codes = expected_status_codes!(200 - 204, 300 - 304, 400 - 404);
853        assert!(codes.contains(202));
854        assert!(codes.contains(302));
855        assert!(codes.contains(402));
856        assert!(!codes.contains(205));
857        assert!(!codes.contains(305));
858    }
859
860    #[test]
861    fn test_expected_status_codes_edge_cases() {
862        // Empty should work
863        let _codes = expected_status_codes!();
864
865        // Single range should work
866        let codes = expected_status_codes!(200 - 299);
867        assert!(codes.contains(250));
868    }
869
870    #[test]
871    fn test_expected_status_codes_common_patterns() {
872        // Success codes
873        let success = expected_status_codes!(200 - 299);
874        assert!(success.contains(200));
875        assert!(success.contains(201));
876        assert!(success.contains(204));
877
878        // Client errors
879        let client_errors = expected_status_codes!(400 - 499);
880        assert!(client_errors.contains(400));
881        assert!(client_errors.contains(404));
882        assert!(client_errors.contains(422));
883
884        // Specific success codes
885        let specific = expected_status_codes!(200, 201, 204);
886        assert!(specific.contains(200));
887        assert!(!specific.contains(202));
888    }
889
890    #[test]
891    fn test_expected_status_codes_builder_alternative() {
892        // Using macro
893        let macro_codes = expected_status_codes!(200 - 204, 301, 302, 400 - 404);
894
895        // Using builder (should be equivalent)
896        let builder_codes = ExpectedStatusCodes::default()
897            .add_inclusive_range(200..=204)
898            .add_single(301)
899            .add_single(302)
900            .add_inclusive_range(400..=404);
901
902        // Both should have same results
903        for code in [200, 202, 204, 301, 302, 400, 402, 404] {
904            assert_eq!(macro_codes.contains(code), builder_codes.contains(code));
905        }
906    }
907}
908
909#[cfg(test)]
910mod integration_tests {
911    use super::*;
912
913    #[test]
914    fn test_expected_status_codes_real_world_patterns() {
915        // REST API common patterns
916        let rest_success = expected_status_codes!(200, 201, 204);
917        assert!(rest_success.contains(200)); // GET success
918        assert!(rest_success.contains(201)); // POST created
919        assert!(rest_success.contains(204)); // DELETE success
920
921        // GraphQL typically uses 200 for everything
922        let graphql = expected_status_codes!(200);
923        assert!(graphql.contains(200));
924        assert!(!graphql.contains(201));
925
926        // Health check endpoints
927        let health = expected_status_codes!(200, 503);
928        assert!(health.contains(200)); // Healthy
929        assert!(health.contains(503)); // Unhealthy
930
931        // Authentication endpoints
932        let auth = expected_status_codes!(200, 201, 401, 403);
933        assert!(auth.contains(200)); // Login success
934        assert!(auth.contains(401)); // Unauthorized
935        assert!(auth.contains(403)); // Forbidden
936    }
937
938    #[tokio::test]
939    async fn test_expected_status_codes_with_api_call() {
940        // This tests that the macro works correctly with actual API calls
941        let client = ApiClient::builder().build().expect("should build client");
942        let codes = expected_status_codes!(200 - 299, 404);
943
944        // Should compile and be usable
945        let _call = client
946            .get("/test")
947            .expect("should create call")
948            .with_expected_status_codes(codes);
949    }
950
951    #[test]
952    fn test_expected_status_codes_method_chaining() {
953        let codes = expected_status_codes!(200)
954            .add_single(201)
955            .add_inclusive_range(300..=304);
956
957        assert!(codes.contains(200));
958        assert!(codes.contains(201));
959        assert!(codes.contains(302));
960    }
961
962    #[test]
963    fn test_expected_status_codes_vs_manual_creation() {
964        // Macro version
965        let macro_version = expected_status_codes!(200 - 204, 301, 400);
966
967        // Manual version
968        let manual_version = ExpectedStatusCodes::from_inclusive_range(200..=204)
969            .add_single(301)
970            .add_single(400);
971
972        // Should behave identically
973        for code in 100..600 {
974            assert_eq!(
975                macro_version.contains(code),
976                manual_version.contains(code),
977                "Mismatch for status code {code}"
978            );
979        }
980    }
981}