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}