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